use std::collections::HashMap;
use rustledger_core::{Directive, MetaValue, Metadata, NaiveDate};
use serde::Deserialize;
fn parse_input_amount(
field: &str,
amount: &InputAmount,
) -> Result<rustledger_core::Amount, String> {
let number = rustledger_core::Decimal::from_str_exact(&amount.number)
.map_err(|e| format!("invalid {field} number {:?}: {e}", amount.number))?;
Ok(rustledger_core::Amount {
number,
currency: amount.currency.clone().into(),
})
}
#[derive(Debug, Deserialize, Clone)]
pub struct InputAmount {
pub number: String,
pub currency: String,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InputCostNumber {
PerUnit {
value: String,
},
Total {
value: String,
},
Compound {
per_unit: String,
total: String,
},
PerUnitFromTotal {
per_unit: String,
total: String,
},
}
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct InputCost {
#[serde(default)]
pub number: Option<InputCostNumber>,
#[serde(default)]
pub currency: Option<String>,
#[serde(default)]
pub date: Option<String>,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub merge: bool,
}
#[derive(Debug, Deserialize, Clone)]
pub struct InputPosting {
pub account: String,
#[serde(default)]
pub units: Option<InputAmount>,
#[serde(default)]
pub cost: Option<InputCost>,
#[serde(default)]
pub price: Option<InputAmount>,
#[serde(default)]
pub meta: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum InputEntry {
Transaction {
date: String,
#[serde(default = "default_flag")]
flag: String,
#[serde(default)]
payee: Option<String>,
#[serde(default)]
narration: Option<String>,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
links: Vec<String>,
#[serde(default)]
postings: Vec<InputPosting>,
#[serde(default)]
meta: HashMap<String, serde_json::Value>,
},
Open {
date: String,
account: String,
#[serde(default)]
currencies: Vec<String>,
#[serde(default)]
booking: Option<String>,
#[serde(default)]
meta: HashMap<String, serde_json::Value>,
},
Close {
date: String,
account: String,
#[serde(default)]
meta: HashMap<String, serde_json::Value>,
},
Balance {
date: String,
account: String,
amount: InputAmount,
#[serde(default)]
meta: HashMap<String, serde_json::Value>,
},
Pad {
date: String,
account: String,
source_account: String,
#[serde(default)]
meta: HashMap<String, serde_json::Value>,
},
Commodity {
date: String,
currency: String,
#[serde(default)]
meta: HashMap<String, serde_json::Value>,
},
Price {
date: String,
currency: String,
amount: InputAmount,
#[serde(default)]
meta: HashMap<String, serde_json::Value>,
},
Event {
date: String,
event_type: String,
value: String,
#[serde(default)]
meta: HashMap<String, serde_json::Value>,
},
Note {
date: String,
account: String,
comment: String,
#[serde(default)]
meta: HashMap<String, serde_json::Value>,
},
Document {
date: String,
account: String,
path: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
links: Vec<String>,
#[serde(default)]
meta: HashMap<String, serde_json::Value>,
},
Query {
date: String,
name: String,
query_string: String,
#[serde(default)]
meta: HashMap<String, serde_json::Value>,
},
Custom {
date: String,
custom_type: String,
#[serde(default)]
values: Vec<serde_json::Value>,
#[serde(default)]
meta: HashMap<String, serde_json::Value>,
},
}
fn default_flag() -> String {
"*".to_string()
}
pub fn json_to_meta_value(value: &serde_json::Value) -> MetaValue {
rustledger_core::json_to_meta_value(value)
}
pub fn json_map_to_metadata(map: &HashMap<String, serde_json::Value>) -> Metadata {
map.iter()
.map(|(k, v)| (k.clone(), json_to_meta_value(v)))
.collect()
}
fn parse_account(context: &str, value: &str) -> Result<rustledger_core::Account, String> {
if rustledger_parser::is_valid_account_name(value) {
Ok(value.to_string().into())
} else {
Err(format!(
"invalid {context} account {value:?}: not a valid beancount account \
name (expected Type:Component[:Component...] — components start \
with an uppercase or caseless letter, sub-components may start \
with a digit; letters, digits, and hyphens only)"
))
}
}
pub fn input_entry_to_directive(entry: &InputEntry) -> Result<Directive, String> {
match entry {
InputEntry::Transaction {
date,
flag,
payee,
narration,
tags,
links,
postings,
meta,
} => {
let date = date
.parse::<NaiveDate>()
.map_err(|e| format!("Invalid date '{date}': {e}"))?;
let flag = match flag.as_str() {
"*" | "txn" => '*',
"!" => '!',
other => other.chars().next().unwrap_or('*'),
};
let postings: Vec<rustledger_core::Spanned<rustledger_core::Posting>> = postings
.iter()
.map(|p| {
let units = p
.units
.as_ref()
.map(|u| parse_input_amount("posting units", u))
.transpose()?
.map(rustledger_core::IncompleteAmount::Complete);
let cost = p
.cost
.as_ref()
.map(|c| {
let parse = |s: &str| {
rustledger_core::Decimal::from_str_exact(s).map_err(|e| {
format!("invalid cost number {s:?}: {e}")
})
};
let posting_units = units
.as_ref()
.and_then(|u: &rustledger_core::IncompleteAmount| u.as_amount());
let number = match c.number.as_ref() {
None => None,
Some(crate::types::input::InputCostNumber::PerUnit { value }) => {
Some(rustledger_core::CostNumber::PerUnit { value: parse(value)? })
}
Some(crate::types::input::InputCostNumber::Total { value }) => {
Some(rustledger_core::CostNumber::Total { value: parse(value)? })
}
Some(crate::types::input::InputCostNumber::Compound {
per_unit,
total,
}) => Some(rustledger_core::CostNumber::Compound {
per_unit: parse(per_unit)?,
total: parse(total)?,
}),
Some(crate::types::input::InputCostNumber::PerUnitFromTotal {
per_unit,
total,
}) => {
let per_unit_d = parse(per_unit)?;
let total_d = parse(total)?;
let units_n = posting_units.as_ref().map(|a| a.number).ok_or_else(|| {
"PerUnitFromTotal cost requires units on the posting — \
this is the post-booking shape and is meaningless without units. \
Send `{kind:\"per_unit\", value:...}` or `{kind:\"total\", value:...}` for unbooked specs.".to_string()
})?;
let booked = rustledger_core::BookedCost::try_new(
per_unit_d, total_d, units_n,
)
.map_err(|e| format!("{e}"))?;
Some(rustledger_core::CostNumber::PerUnitFromTotal(booked))
}
};
Ok::<_, String>(rustledger_core::CostSpec {
number,
currency: c.currency.clone().map(Into::into),
date: c.date.as_ref().and_then(|d| d.parse::<NaiveDate>().ok()),
label: c.label.clone(),
merge: c.merge,
})
})
.transpose()?;
let price = p
.price
.as_ref()
.map(|pr| parse_input_amount("posting price", pr))
.transpose()?
.map(rustledger_core::PriceAnnotation::unit);
Ok::<_, String>(rustledger_core::Spanned::synthesized(rustledger_core::Posting {
account: parse_account("posting", &p.account)?,
units,
cost,
price,
flag: None,
meta: json_map_to_metadata(&p.meta),
comments: Vec::new(),
trailing_comments: Vec::new(),
}))
})
.collect::<Result<_, String>>()?;
Ok(Directive::Transaction(rustledger_core::Transaction {
date,
flag,
payee: payee.clone().map(Into::into),
narration: narration.clone().unwrap_or_default().into(),
tags: tags.iter().map(|t| t.clone().into()).collect(),
links: links.iter().map(|l| l.clone().into()).collect(),
postings,
meta: json_map_to_metadata(meta),
trailing_comments: Vec::new(),
}))
}
InputEntry::Open {
date,
account,
currencies,
booking,
meta,
} => {
let date = date
.parse::<NaiveDate>()
.map_err(|e| format!("Invalid date '{date}': {e}"))?;
Ok(Directive::Open(rustledger_core::Open {
date,
account: parse_account("open", account)?,
currencies: currencies.iter().map(|c| c.clone().into()).collect(),
booking: booking.clone(),
meta: json_map_to_metadata(meta),
}))
}
InputEntry::Close {
date,
account,
meta,
} => {
let date = date
.parse::<NaiveDate>()
.map_err(|e| format!("Invalid date '{date}': {e}"))?;
Ok(Directive::Close(rustledger_core::Close {
date,
account: parse_account("close", account)?,
meta: json_map_to_metadata(meta),
}))
}
InputEntry::Balance {
date,
account,
amount,
meta,
} => {
let date = date
.parse::<NaiveDate>()
.map_err(|e| format!("Invalid date '{date}': {e}"))?;
Ok(Directive::Balance(rustledger_core::Balance {
date,
account: parse_account("balance", account)?,
amount: parse_input_amount("balance amount", amount)?,
tolerance: None,
meta: json_map_to_metadata(meta),
}))
}
InputEntry::Pad {
date,
account,
source_account,
meta,
} => {
let date = date
.parse::<NaiveDate>()
.map_err(|e| format!("Invalid date '{date}': {e}"))?;
Ok(Directive::Pad(rustledger_core::Pad {
date,
account: parse_account("pad", account)?,
source_account: parse_account("pad source", source_account)?,
meta: json_map_to_metadata(meta),
}))
}
InputEntry::Commodity {
date,
currency,
meta,
} => {
let date = date
.parse::<NaiveDate>()
.map_err(|e| format!("Invalid date '{date}': {e}"))?;
Ok(Directive::Commodity(rustledger_core::Commodity {
date,
currency: currency.clone().into(),
meta: json_map_to_metadata(meta),
}))
}
InputEntry::Price {
date,
currency,
amount,
meta,
} => {
let date = date
.parse::<NaiveDate>()
.map_err(|e| format!("Invalid date '{date}': {e}"))?;
Ok(Directive::Price(rustledger_core::Price {
date,
currency: currency.clone().into(),
amount: parse_input_amount("price amount", amount)?,
meta: json_map_to_metadata(meta),
}))
}
InputEntry::Event {
date,
event_type,
value,
meta,
} => {
let date = date
.parse::<NaiveDate>()
.map_err(|e| format!("Invalid date '{date}': {e}"))?;
Ok(Directive::Event(rustledger_core::Event {
date,
event_type: event_type.clone(),
value: value.clone(),
meta: json_map_to_metadata(meta),
}))
}
InputEntry::Note {
date,
account,
comment,
meta,
} => {
let date = date
.parse::<NaiveDate>()
.map_err(|e| format!("Invalid date '{date}': {e}"))?;
Ok(Directive::Note(rustledger_core::Note {
date,
account: parse_account("note", account)?,
comment: comment.clone(),
meta: json_map_to_metadata(meta),
}))
}
InputEntry::Document {
date,
account,
path,
tags,
links,
meta,
} => {
let date = date
.parse::<NaiveDate>()
.map_err(|e| format!("Invalid date '{date}': {e}"))?;
Ok(Directive::Document(rustledger_core::Document {
date,
account: parse_account("document", account)?,
path: path.clone(),
tags: tags.iter().map(|t| t.clone().into()).collect(),
links: links.iter().map(|l| l.clone().into()).collect(),
meta: json_map_to_metadata(meta),
}))
}
InputEntry::Query {
date,
name,
query_string,
meta,
} => {
let date = date
.parse::<NaiveDate>()
.map_err(|e| format!("Invalid date '{date}': {e}"))?;
Ok(Directive::Query(rustledger_core::Query {
date,
name: name.clone(),
query: query_string.clone(),
meta: json_map_to_metadata(meta),
}))
}
InputEntry::Custom {
date,
custom_type,
values,
meta,
} => {
let date = date
.parse::<NaiveDate>()
.map_err(|e| format!("Invalid date '{date}': {e}"))?;
Ok(Directive::Custom(rustledger_core::Custom {
date,
custom_type: custom_type.clone(),
values: values.iter().map(json_to_meta_value).collect(),
meta: json_map_to_metadata(meta),
}))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_cost_number_per_unit_parses() {
let json = r#"{"kind": "per_unit", "value": "100"}"#;
let cn: InputCostNumber = serde_json::from_str(json).unwrap();
match cn {
InputCostNumber::PerUnit { value } => assert_eq!(value, "100"),
_ => panic!("expected PerUnit"),
}
}
#[test]
fn input_cost_number_total_parses() {
let json = r#"{"kind": "total", "value": "1500"}"#;
let cn: InputCostNumber = serde_json::from_str(json).unwrap();
match cn {
InputCostNumber::Total { value } => assert_eq!(value, "1500"),
_ => panic!("expected Total"),
}
}
#[test]
fn input_cost_number_per_unit_from_total_parses() {
let json = r#"{"kind": "per_unit_from_total", "per_unit": "150", "total": "300"}"#;
let cn: InputCostNumber = serde_json::from_str(json).unwrap();
match cn {
InputCostNumber::PerUnitFromTotal { per_unit, total } => {
assert_eq!(per_unit, "150");
assert_eq!(total, "300");
}
_ => panic!("expected PerUnitFromTotal"),
}
}
#[test]
fn input_cost_number_rejects_unknown_kind() {
let json = r#"{"kind": "per_unit_with_total", "value": "100"}"#;
let result: Result<InputCostNumber, _> = serde_json::from_str(json);
assert!(result.is_err(), "expected error for unknown kind, got Ok");
}
#[test]
fn input_cost_number_rejects_missing_kind() {
let json = r#"{"value": "100"}"#;
let result: Result<InputCostNumber, _> = serde_json::from_str(json);
assert!(result.is_err(), "expected error without kind tag, got Ok");
}
#[test]
fn input_cost_number_rejects_legacy_flat_shape() {
let json = r#"{"number_per": "100", "number_total": null}"#;
let result: Result<InputCostNumber, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"expected error for legacy flat shape, got Ok"
);
}
#[test]
fn input_cost_with_no_number_parses_as_bare_brace() {
let json = r#"{"currency": "USD"}"#;
let cost: InputCost = serde_json::from_str(json).unwrap();
assert!(cost.number.is_none());
assert_eq!(cost.currency.as_deref(), Some("USD"));
}
#[test]
fn input_cost_rejects_unknown_field() {
let json = r#"{"cost_number": {"kind": "per_unit", "value": "100"}, "currency": "USD"}"#;
let result: Result<InputCost, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"expected parse error for unknown field, got Ok"
);
}
#[test]
fn input_cost_per_unit_from_total_rejected_when_inconsistent() {
let entry_json = r#"{
"type": "transaction",
"date": "2024-01-15",
"flag": "*",
"payee": null,
"narration": "Buy stock",
"tags": [],
"links": [],
"meta": {},
"postings": [
{
"account": "Assets:Stock",
"units": {"number": "10", "currency": "STK"},
"cost": {
"number": {"kind": "per_unit_from_total", "per_unit": "999999", "total": "0.01"},
"currency": "USD",
"date": null,
"label": null
},
"price": null,
"meta": {}
}
]
}"#;
let entry: InputEntry = serde_json::from_str(entry_json).unwrap();
let result = input_entry_to_directive(&entry);
assert!(
result.is_err(),
"inconsistent PerUnitFromTotal must reject at the bridge"
);
let err = result.unwrap_err();
assert!(
err.contains("invariant") || err.contains("per_unit") || err.contains("total"),
"error message must describe the invariant violation, got: {err}"
);
}
#[test]
fn balance_with_malformed_amount_is_rejected() {
let entry_json = r#"{
"type": "balance",
"date": "2024-01-15",
"account": "Assets:Bank",
"amount": {"number": "garbage", "currency": "USD"},
"meta": {}
}"#;
let entry: InputEntry = serde_json::from_str(entry_json).unwrap();
let result = input_entry_to_directive(&entry);
assert!(
result.is_err(),
"malformed balance amount must surface a parse error, not silently coerce to 0"
);
let err = result.unwrap_err();
assert!(
err.contains("balance amount") && err.contains("garbage"),
"error must name both the field and the offending value, got: {err}"
);
}
#[test]
fn price_with_malformed_amount_is_rejected() {
let entry_json = r#"{
"type": "price",
"date": "2024-01-15",
"currency": "AAPL",
"amount": {"number": "abc", "currency": "USD"},
"meta": {}
}"#;
let entry: InputEntry = serde_json::from_str(entry_json).unwrap();
let result = input_entry_to_directive(&entry);
assert!(
result.is_err(),
"malformed price amount must surface a parse error"
);
assert!(result.unwrap_err().contains("price amount"));
}
#[test]
fn posting_with_malformed_units_is_rejected() {
let entry_json = r#"{
"type": "transaction",
"date": "2024-01-15",
"flag": "*",
"payee": null,
"narration": "Buy",
"tags": [], "links": [],
"meta": {},
"postings": [
{
"account": "Assets:Stock",
"units": {"number": "not-a-number", "currency": "STK"},
"cost": null,
"price": null,
"meta": {}
}
]
}"#;
let entry: InputEntry = serde_json::from_str(entry_json).unwrap();
let result = input_entry_to_directive(&entry);
assert!(
result.is_err(),
"malformed posting units must surface a parse error"
);
assert!(result.unwrap_err().contains("posting units"));
}
#[test]
fn document_tags_and_links_round_trip_1213() {
let entry_json = r#"{
"type": "document",
"date": "2024-01-15",
"account": "Assets:Bank",
"path": "statements/2024-01.pdf",
"tags": ["statement", "bank"],
"links": ["inv-2024-01"],
"meta": {}
}"#;
let entry: InputEntry = serde_json::from_str(entry_json).unwrap();
let directive = input_entry_to_directive(&entry).expect("valid document must convert");
let Directive::Document(doc) = directive else {
panic!("expected Document directive");
};
assert_eq!(doc.tags.len(), 2);
assert_eq!(doc.tags[0].to_string(), "statement");
assert_eq!(doc.tags[1].to_string(), "bank");
assert_eq!(doc.links.len(), 1);
assert_eq!(doc.links[0].to_string(), "inv-2024-01");
}
#[test]
fn document_tags_and_links_default_to_empty_1213() {
let entry_json = r#"{
"type": "document",
"date": "2024-01-15",
"account": "Assets:Bank",
"path": "statements/2024-01.pdf",
"meta": {}
}"#;
let entry: InputEntry = serde_json::from_str(entry_json).unwrap();
let directive = input_entry_to_directive(&entry).expect("legacy payload must convert");
let Directive::Document(doc) = directive else {
panic!("expected Document directive");
};
assert!(doc.tags.is_empty());
assert!(doc.links.is_empty());
}
#[test]
fn input_cost_per_unit_from_total_rejected_when_units_missing() {
let entry_json = r#"{
"type": "transaction",
"date": "2024-01-15",
"flag": "*",
"payee": null,
"narration": "Buy stock",
"tags": [],
"links": [],
"meta": {},
"postings": [
{
"account": "Assets:Stock",
"units": null,
"cost": {
"number": {"kind": "per_unit_from_total", "per_unit": "999999", "total": "0.01"},
"currency": "USD",
"date": null,
"label": null
},
"price": null,
"meta": {}
}
]
}"#;
let entry: InputEntry = serde_json::from_str(entry_json).unwrap();
let result = input_entry_to_directive(&entry);
assert!(
result.is_err(),
"PerUnitFromTotal without units must reject (post-booking shape requires units)"
);
}
#[test]
fn create_rejects_unparsable_account() {
let entry = InputEntry::Open {
date: "2024-01-01".to_string(),
account: "assets:cash".to_string(), currencies: vec![],
booking: None,
meta: Default::default(),
};
let err = input_entry_to_directive(&entry).unwrap_err();
assert!(err.contains("invalid open account"), "{err}");
let entry = InputEntry::Close {
date: "2024-01-01".to_string(),
account: "Assets:Ca sh".to_string(),
meta: Default::default(),
};
let err = input_entry_to_directive(&entry).unwrap_err();
assert!(err.contains("invalid close account"), "{err}");
}
#[test]
fn create_accepts_valid_unicode_account() {
let entry = InputEntry::Open {
date: "2024-01-01".to_string(),
account: "資産:現金".to_string(),
currencies: vec![],
booking: None,
meta: Default::default(),
};
let d = input_entry_to_directive(&entry).expect("valid unicode account");
match d {
Directive::Open(o) => assert_eq!(o.account.as_str(), "資産:現金"),
other => panic!("expected Open, got {other:?}"),
}
}
}