Lines
94.48 %
Functions
25 %
Branches
100 %
//! Reverse-lowering of a `get-transaction-detail` plist reply into the
//! from→to [`EditableRow`]s that the transaction-edit form uses.
//!
//! The forward lowering (`server::logical::lower_logical_split`) converts each
//! logical from→to split into:
//! - a FROM physical split: `account=from`, `commodity=from_commodity`, `value = -value`
//! - a TO physical split: `account=to`, `commodity=to_commodity`, `value = +to_amount`
//! - for cross-commodity: a price row with `commodity_split=to_id`,
//! `currency_split=from_id`.
//! This module inverts that mapping.
use std::collections::{HashMap, HashSet};
use nomiscript::{Value, list_to_vec};
use num_rational::Rational64;
use crate::eval::{plist_field, plist_field_raw};
use crate::forms::validators::{amount_token, parse_amount};
use crate::render::{WireValue, parse_wire, reparse_list};
/// A transaction that can be pre-populated into the edit form.
#[derive(Debug, Clone, PartialEq)]
pub struct EditableTransaction {
pub id: String,
/// Empty string when the transaction has no note.
pub note: String,
/// Post-date in the format the server returned (RFC3339).
pub date: String,
pub rows: Vec<EditableRow>,
}
/// One from→to row for the edit form.
pub struct EditableRow {
pub from_account: String,
pub to_account: String,
pub from_commodity: String,
pub to_commodity: String,
/// Magnitude (positive) of the FROM side, as a string `parse_amount` accepts.
pub value: String,
/// Present only for cross-commodity rows; the TO side amount.
pub to_amount: Option<String>,
struct ParsedSplit {
id: String,
account_id: String,
commodity_id: String,
value_str: String,
struct ParsedPrice {
/// The TO-side physical split id (what you GET).
commodity_split: String,
/// The FROM-side physical split id (what you PAY with).
currency_split: String,
fn parse_splits(splits_val: &Value) -> Result<Vec<ParsedSplit>, String> {
let elements =
list_to_vec(splits_val).ok_or_else(|| "splits field is not a list".to_string())?;
elements
.iter()
.map(|e| {
Ok(ParsedSplit {
id: plist_field(e, ":id").ok_or_else(|| "split missing :id".to_string())?,
account_id: plist_field(e, ":account-id")
.ok_or_else(|| "split missing :account-id".to_string())?,
commodity_id: plist_field(e, ":commodity-id")
.ok_or_else(|| "split missing :commodity-id".to_string())?,
value_str: plist_field(e, ":value")
.ok_or_else(|| "split missing :value".to_string())?,
})
.collect()
fn parse_prices(prices_val: &Value) -> Result<Vec<ParsedPrice>, String> {
list_to_vec(prices_val).ok_or_else(|| "prices field is not a list".to_string())?;
Ok(ParsedPrice {
commodity_split: plist_field(e, ":commodity-split")
.ok_or_else(|| "price missing :commodity-split".to_string())?,
currency_split: plist_field(e, ":currency-split")
.ok_or_else(|| "price missing :currency-split".to_string())?,
/// Parse a split value into its sign (`-1`/`0`/`+1`) and panic-free magnitude
/// string. The magnitude is derived with `checked_abs` so an `i64::MIN`
/// numerator surfaces as `Err` instead of overflowing the unary negation.
fn split_value(value_str: &str) -> Result<(i8, String), String> {
let r = parse_amount(value_str)?;
let numer = *r.numer();
let mag_numer = numer
.checked_abs()
.ok_or_else(|| format!("value '{value_str}' magnitude overflows i64"))?;
let sign = numer.signum() as i8;
// `parse_amount` normalises through `Rational64::new`, so the denominator
// is always strictly positive and coprime with the (now non-negative)
// magnitude numerator — rebuilding the ratio reduces nothing further.
let mag = amount_token(&Rational64::new(mag_numer, *r.denom()));
Ok((sign, mag))
/// Record `id` as consumed; reject a split claimed by two prices.
fn consume(consumed: &mut HashSet<String>, id: String) -> Result<(), String> {
if consumed.insert(id) {
Ok(())
} else {
Err("split referenced by multiple prices — ambiguous".to_string())
fn invert_cross_commodity(
prices: Vec<ParsedPrice>,
split_map: &HashMap<String, ParsedSplit>,
consumed: &mut HashSet<String>,
) -> Result<Vec<EditableRow>, String> {
let mut rows = Vec::with_capacity(prices.len());
for price in prices {
let from_split = split_map.get(&price.currency_split).ok_or_else(|| {
format!(
"price currency-split '{}' not found in splits",
price.currency_split
)
})?;
let to_split = split_map.get(&price.commodity_split).ok_or_else(|| {
"price commodity-split '{}' not found in splits",
price.commodity_split
let (from_sign, value) = split_value(&from_split.value_str)?;
if from_sign >= 0 {
return Err(format!(
"cross-currency FROM split '{}' must have a negative value",
from_split.id
));
let (to_sign, to_amount) = split_value(&to_split.value_str)?;
if to_sign <= 0 {
"cross-currency TO split '{}' must have a positive value",
to_split.id
consume(consumed, price.currency_split)?;
consume(consumed, price.commodity_split)?;
rows.push(EditableRow {
from_account: from_split.account_id.clone(),
to_account: to_split.account_id.clone(),
from_commodity: from_split.commodity_id.clone(),
to_commodity: to_split.commodity_id.clone(),
value,
to_amount: Some(to_amount),
});
Ok(rows)
type MagnitudeBucket<'a> = (Vec<&'a ParsedSplit>, Vec<&'a ParsedSplit>);
/// Pair the non-price splits into from→to rows by exact magnitude.
///
/// The forward lowering stores no link between a row's two physical splits, so
/// pairing is unambiguous only when each (commodity, magnitude) holds exactly
/// one negative and one positive split. Two same-magnitude splits on either
/// side cannot be matched without guessing accounts, so the transaction is
/// declared non-editable.
fn invert_same_commodity(
consumed: &HashSet<String>,
let mut buckets: HashMap<(String, String), MagnitudeBucket> = HashMap::new();
for split in split_map.values() {
if consumed.contains(&split.id) {
continue;
let (sign, mag) =
split_value(&split.value_str).map_err(|e| format!("split '{}': {e}", split.id))?;
if sign == 0 {
return Err(format!("split '{}' has zero value", split.id));
let bucket = buckets
.entry((split.commodity_id.clone(), mag))
.or_default();
if sign < 0 {
bucket.0.push(split);
bucket.1.push(split);
let mut rows = Vec::new();
for ((commodity_id, mag), (negatives, positives)) in buckets {
if negatives.len() > 1 || positives.len() > 1 {
return Err(
"ambiguous: multiple same-commodity splits of equal magnitude — \
this transaction can't be edited in the form"
.to_string(),
);
match (negatives.first(), positives.first()) {
(Some(from_split), Some(to_split)) => rows.push(EditableRow {
from_commodity: commodity_id.clone(),
to_commodity: commodity_id,
value: mag,
to_amount: None,
}),
_ => {
"unpaired same-commodity split of magnitude {mag} in commodity {commodity_id}"
/// Parse the wire reply of `(get-transaction-detail ...)` into editable rows.
/// Returns `Err` when:
/// - the wire envelope or plist structure is malformed,
/// - a price references a split id not present in `:splits`,
/// - remaining (non-price) splits cannot be paired into balanced from→to rows.
pub fn parse_editable_transaction(wire: &str) -> Result<EditableTransaction, String> {
let plist_str = match parse_wire(wire) {
Ok(WireValue::Value(Value::String(s))) => s,
Ok(_) => return Err("get-transaction-detail reply is not a string value".to_string()),
Err(e) => return Err(format!("wire parse error: {e}")),
};
let plist =
reparse_list(&plist_str).map_err(|e| format!("transaction plist re-parse error: {e}"))?;
let id = plist_field(&plist, ":id").ok_or_else(|| "transaction missing :id".to_string())?;
let date = plist_field(&plist, ":post-date")
.ok_or_else(|| "transaction missing :post-date".to_string())?;
let note = plist_field(&plist, ":note").unwrap_or_default();
let splits_val = plist_field_raw(&plist, ":splits")
.ok_or_else(|| "transaction missing :splits".to_string())?;
let prices_val = plist_field_raw(&plist, ":prices")
.ok_or_else(|| "transaction missing :prices".to_string())?;
let splits = parse_splits(&splits_val)?;
let prices = parse_prices(&prices_val)?;
let split_map: HashMap<String, ParsedSplit> =
splits.into_iter().map(|s| (s.id.clone(), s)).collect();
let mut consumed = HashSet::new();
let mut rows = invert_cross_commodity(prices, &split_map, &mut consumed)?;
rows.extend(invert_same_commodity(&split_map, &consumed)?);
rows.sort_by(|a, b| {
(&a.from_account, &a.to_account, &a.value).cmp(&(&b.from_account, &b.to_account, &b.value))
if rows.is_empty() && !split_map.is_empty() {
return Err("no pairable rows found in transaction".to_string());
Ok(EditableTransaction {
id,
note,
date,
rows,
#[cfg(test)]
mod tests;