Lines
89.95 %
Functions
62.86 %
Branches
100 %
use finance::tag::Tag;
use num_rational::Rational64;
use scripting::runtime::{alloc_entity_via_export, alloc_pair_chain, alloc_string_ref};
use server::command::{CmdError, CmdResult, FinanceEntity};
use wasmtime::{AnyRef, Caller, Rooted, StructRef, Val};
use crate::session::SessionData;
/// One listed transaction: `(id, note, amount, post-date)`.
pub(super) type TransactionEntry = (String, Option<String>, Option<String>, String);
pub(super) fn list_transaction_entries(
name: &str,
result: Result<Option<CmdResult>, CmdError>,
) -> wasmtime::Result<Vec<TransactionEntry>> {
match result {
Ok(Some(CmdResult::TaggedTransactions { entities, .. })) => Ok(entities
.into_iter()
.filter_map(|(entity, tags, amount)| match entity {
FinanceEntity::Transaction(tx) => Some((
tx.id.to_string(),
tag_value(&tags, "note").map(str::to_string),
amount,
tx.post_date.to_rfc3339(),
)),
_ => None,
})
.collect()),
Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
"{name}: expected TaggedTransactions, got {other:?}"
))),
Ok(None) => Ok(Vec::new()),
Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
}
pub(super) async fn alloc_transaction_entity(
caller: &mut Caller<'_, SessionData>,
id: &str,
note: Option<&str>,
amount: Option<&str>,
post_date: Option<&str>,
) -> wasmtime::Result<Rooted<StructRef>> {
let id_ref = alloc_string_ref(caller, id.as_bytes())?;
let note_ref = note
.map(|s| alloc_string_ref(caller, s.as_bytes()))
.transpose()?;
let amount_ref = amount
let date_ref = post_date
let args = [
Val::AnyRef(Some(id_ref.to_anyref())),
Val::AnyRef(note_ref.map(|r| r.to_anyref())),
Val::AnyRef(amount_ref.map(|r| r.to_anyref())),
Val::AnyRef(date_ref.map(|r| r.to_anyref())),
];
alloc_entity_via_export(caller, "alloc_transaction", &args).await
pub(super) async fn alloc_transaction_chain(
entries: Vec<TransactionEntry>,
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entries.len());
for (id, note, amount, post_date) in entries {
let entity_ref = alloc_transaction_entity(
caller,
&id,
note.as_deref(),
amount.as_deref(),
Some(&post_date),
)
.await?;
anyrefs.push(entity_ref.to_anyref());
alloc_pair_chain(caller, anyrefs).await
pub(super) fn tag_value<'a>(
tags: &'a std::collections::HashMap<String, FinanceEntity>,
key: &str,
) -> Option<&'a str> {
tags.get(key).and_then(|t| match t {
FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
/// Escape a string for nomiscript plist output.
pub(super) fn quote_string(s: &str) -> String {
let mut q = String::with_capacity(s.len() + 2);
q.push('"');
for ch in s.chars() {
match ch {
'"' => q.push_str("\\\""),
'\\' => q.push_str("\\\\"),
other => q.push(other),
q
fn format_ratio(num: i64, denom: i64) -> String {
let r = Rational64::new(num, denom);
if *r.denom() == 1 {
format!("{}", r.numer())
} else {
format!("{}/{}", r.numer(), r.denom())
/// Render a full transaction plist from a `GetTransactionDetail` result.
///
/// Backs the `get-transaction-detail` native: the reply is a string plist
/// carrying the transaction header plus its physical `:splits` and `:prices`,
/// with split/price keys symmetric to what create/update-transaction parse.
/// Returns `None` when the transaction was not found.
pub(super) fn render_full_transaction(
) -> wasmtime::Result<Option<String>> {
Ok(Some(CmdResult::TaggedEntities { entities, .. })) => {
let mut tx_data = None;
let mut splits = Vec::new();
let mut prices = Vec::new();
for (entity, tags) in entities {
match entity {
FinanceEntity::Transaction(tx) => {
let note = tag_value(&tags, "note").map(str::to_string);
tx_data = Some((tx, note));
FinanceEntity::Split(s) => splits.push(s),
FinanceEntity::Price(p) => prices.push(p),
_ => {}
let (tx, note) = match tx_data {
Some(d) => d,
None => return Ok(None),
};
let mut out = format!(
"(:id \"{}\" :post-date \"{}\" :enter-date \"{}\"",
tx.id,
tx.enter_date.to_rfc3339(),
);
if let Some(n) = note {
out.push_str(&format!(" :note {}", quote_string(&n)));
out.push_str(" :splits (");
for s in &splits {
out.push_str(&format!(
"(:id \"{}\" :account-id \"{}\" :commodity-id \"{}\" :value {})",
s.id,
s.account_id,
s.commodity_id,
format_ratio(s.value_num, s.value_denom),
));
out.push(')');
out.push_str(" :prices (");
for p in &prices {
if let (Some(cs), Some(cu)) = (p.commodity_split, p.currency_split) {
"(:id \"{}\" :commodity-id \"{}\" :currency-id \"{}\" \
:commodity-split \"{}\" :currency-split \"{}\" \
:value-num {} :value-denom {} :date \"{}\")",
p.id,
p.commodity_id,
p.currency_id,
cs,
cu,
p.value_num,
p.value_denom,
p.date.to_rfc3339(),
Ok(Some(out))
Ok(None) => Ok(None),
"{name}: expected TaggedEntities, got {other:?}"
/// Test-only legacy renderer; production paths now ship typed
/// `pair<transaction>` / `EntityRef(Transaction)` via the alloc helpers.
/// Retained for the existing format-assertion tests until A6 collapses
/// the streaming-string capture protocol.
#[cfg(test)]
pub(super) fn format_tagged_transactions(
entities: &[(
FinanceEntity,
std::collections::HashMap<String, FinanceEntity>,
)],
pagination: Option<&server::command::PaginationInfo>,
) -> String {
let mut out = String::from("(:transactions (");
for (idx, (entity, tags)) in entities.iter().enumerate() {
if idx > 0 {
out.push(' ');
tx.enter_date.to_rfc3339()
if let Some(note) = tag_value(tags, "note") {
out.push_str(&format!(" :note {}", quote_string(note)));
other => {
out.push_str(&format!("(:error \"unexpected entity {other:?}\")"));
out.push_str(") :pagination ");
match pagination {
Some(p) => out.push_str(&format!(
"(:total {} :limit {} :offset {} :has-more {})",
p.total_count,
p.limit,
p.offset,
if p.has_more { "t" } else { "nil" }
None => out.push_str("nil"),
out