use std::collections::BTreeMap;
use chrono::NaiveDate;
use crate::ast;
#[derive(Debug)]
pub struct HIR {
pub(crate) entries: Vec<ResolutionEntry>,
pub contexts: Vec<Context>,
pub global_context: GlobalContext,
pub prices: Vec<HistoricalPrice>,
pub(crate) auto_rules: Vec<ResolvedAutoRule>,
}
#[derive(Debug, Clone)]
pub struct HistoricalPrice {
pub date: NaiveDate,
pub time: Option<String>,
pub commodity: String,
pub price: ast::ValueExpr,
}
impl Default for HIR {
fn default() -> Self {
Self {
entries: vec![],
contexts: vec![Context::default()],
global_context: Default::default(),
prices: vec![],
auto_rules: vec![],
}
}
}
impl HIR {
pub fn new() -> Self {
Self::default()
}
pub fn append_entry(&mut self, entry: Entry) {
let context_id = self.contexts.len() - 1;
self.entries.push(ResolutionEntry {
context_id,
data: entry,
});
}
}
#[derive(Debug, Clone)]
pub(crate) struct ResolvedAutoRulePosting {
pub account: String,
pub amount: Option<ast::AmountDetails>,
}
#[derive(Debug, Clone)]
pub(crate) struct ResolvedAutoRule {
pub query: regex::Regex,
pub postings: Vec<ResolvedAutoRulePosting>,
}
#[derive(Default, Debug, Clone)]
pub struct Context {
pub account_aliases: BTreeMap<String, String>,
pub commodity_conversions: BTreeMap<String, (String, rust_decimal::Decimal)>,
pub default_commodity: Option<String>,
pub(crate) defines: BTreeMap<String, Define>,
}
impl Context {
pub(crate) fn resolve_commodity_conversion_chains(&mut self) -> Result<(), ResolutionError> {
let max_hops = self.commodity_conversions.len();
let keys: Vec<String> = self.commodity_conversions.keys().cloned().collect();
for start in keys {
let mut hops = 0usize;
let mut current = start.clone();
let mut total_divisor = rust_decimal::Decimal::ONE;
while let Some((next, divisor)) = self.commodity_conversions.get(¤t).cloned() {
if next == current {
break;
}
total_divisor *= divisor;
current = next;
hops += 1;
if hops > max_hops {
return Err(ResolutionError::CommodityConversionCycle(start));
}
}
if let Some(entry) = self.commodity_conversions.get_mut(&start) {
*entry = (current, total_divisor);
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub(crate) struct Define {
pub params: Vec<String>,
pub body: ast::DefineBody,
}
#[derive(Default, Debug)]
pub struct GlobalContext {
pub commodity_properties: BTreeMap<String, CommodityProperties>,
pub account_properties: BTreeMap<String, AccountProperties>,
pub tag_properties: BTreeMap<String, TagProperties>,
pub tolerance_overrides: BTreeMap<String, rust_decimal::Decimal>,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct ElaborationConfig {
pub tolerance_mode: ToleranceMode,
pub balance_mode: BalanceMode,
pub assertion_scope: AssertionScope,
pub lot_validation_mode: LotValidationMode,
pub default_booking_method: BookingMethod,
pub infer_implicit_total_cost: bool,
}
#[derive(Debug, Clone, Default)]
pub enum BalanceMode {
#[default]
CostBasis,
AtPriceWithSynthesis { gains_account: String },
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum AssertionScope {
#[default]
Direct,
Subtree,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum LotValidationMode {
#[default]
Permissive,
Strict,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum BookingMethod {
#[default]
Strict,
StrictWithSize,
None,
Average,
Fifo,
Lifo,
Hifo,
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum ToleranceMode {
FractionOfSmallestPrecision(rust_decimal::Decimal),
}
impl Default for ToleranceMode {
fn default() -> Self {
Self::FractionOfSmallestPrecision(rust_decimal::Decimal::ZERO)
}
}
#[derive(Default, Debug)]
pub struct TagProperties {
pub(crate) asserts: Vec<ast::BoolExpr>,
pub(crate) checks: Vec<ast::BoolExpr>,
}
#[derive(Default, Debug)]
pub struct CommodityProperties {
pub format: Option<String>,
pub no_market: bool,
pub note: Option<String>,
pub inferred_scale: u32,
}
#[derive(Default, Debug)]
#[non_exhaustive]
pub struct AccountProperties {
pub note: Option<String>,
pub(crate) asserts: Vec<ast::BoolExpr>,
pub(crate) checks: Vec<ast::BoolExpr>,
pub metadata: BTreeMap<String, String>,
pub booking_method: Option<BookingMethod>,
}
#[derive(Debug)]
pub(crate) struct ResolutionEntry {
pub context_id: usize, pub data: Entry,
}
#[derive(Debug)]
pub enum Entry {
Transaction(Transaction),
Assertion(AssertionDirective),
Pad(PadDirective),
}
#[derive(Debug)]
pub struct PadDirective {
pub date: chrono::NaiveDate,
pub target_account: String,
pub source_account: String,
}
#[derive(Debug)]
pub struct AssertionDirective {
pub date: chrono::NaiveDate,
pub account: String,
pub amount: ast::ValueExpr,
pub strict: bool,
}
#[derive(Default, Debug)]
pub struct Transaction {
pub date: NaiveDate,
pub secondary_date: Option<NaiveDate>,
pub state: ast::TransactionState,
pub code: Option<String>,
pub description: String,
pub comments: Vec<String>,
pub tags: Vec<String>,
pub metadata: BTreeMap<String, String>,
pub postings: Vec<Posting>,
}
impl Transaction {
pub fn new(date: chrono::NaiveDate, description: impl Into<String>) -> Self {
Self {
date,
description: description.into(),
..Default::default()
}
}
pub fn with_posting(mut self, posting: Posting) -> Self {
self.postings.push(posting);
self
}
pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self
}
pub fn with_comment(mut self, comment: impl Into<String>) -> Self {
self.comments.push(comment.into());
self
}
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
pub fn with_code(mut self, code: impl Into<String>) -> Self {
self.code = Some(code.into());
self
}
pub fn with_state(mut self, state: ast::TransactionState) -> Self {
self.state = state;
self
}
pub fn with_secondary_date(mut self, date: chrono::NaiveDate) -> Self {
self.secondary_date = Some(date);
self
}
}
impl std::fmt::Display for Transaction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.date.fmt(f)?;
if let Some(date) = self.secondary_date {
write!(f, "=")?;
date.fmt(f)?;
}
match self.state {
ast::TransactionState::Uncleared => {}
ast::TransactionState::Pending => write!(f, " !")?,
ast::TransactionState::Cleared => write!(f, " *")?,
}
if let Some(ref code) = self.code {
write!(f, " ({code})")?;
}
if let Some((comment, &[])) = self.comments.split_first() {
writeln!(f, " {} ; {comment}", self.description)?;
} else {
writeln!(f, " {}", self.description)?;
for comment in self.comments.iter() {
writeln!(f, " ; {comment}")?;
}
}
for tag in self.tags.iter() {
writeln!(f, " ; :{tag}:")?;
}
for (key, value) in self.metadata.iter() {
writeln!(f, " ; {key}: {value}")?;
}
for posting in self.postings.iter() {
posting.fmt(f)?;
}
Ok(())
}
}
#[derive(Default, Debug)]
pub struct Posting {
pub account: String,
pub amount: Option<ast::AmountDetails>,
pub state: ast::TransactionState,
pub tags: Vec<String>,
pub metadata: BTreeMap<String, String>,
pub comments: Vec<String>,
pub kind: ast::PostingKind,
}
impl Posting {
pub fn new<S: Into<String>>(account: S) -> Self {
Self {
account: account.into(),
..Default::default()
}
}
pub fn with_tag<S: Into<String>>(mut self, tag: S) -> Self {
self.tags.push(tag.into());
self
}
pub fn with_comment<S: Into<String>>(mut self, comment: S) -> Self {
self.comments.push(comment.into());
self
}
pub fn with_metadata<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
pub fn with_amount<A: Into<ast::AmountDetails>>(mut self, amount: A) -> Self {
self.amount = Some(amount.into());
self
}
}
impl std::fmt::Display for Posting {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, " ")?;
match self.state {
ast::TransactionState::Uncleared => {}
ast::TransactionState::Pending => write!(f, "! ")?,
ast::TransactionState::Cleared => write!(f, "* ")?,
}
write!(f, "{}", self.account)?;
if let Some(ref amount) = self.amount {
write!(f, " {amount}")?;
}
if let Some((comment, &[])) = self.comments.split_first() {
writeln!(f, " ; {comment}")?;
} else {
writeln!(f)?;
for comment in self.comments.iter() {
writeln!(f, " ; {comment}")?;
}
}
for tag in self.tags.iter() {
writeln!(f, " ; :{tag}:")?;
}
for (key, value) in self.metadata.iter() {
writeln!(f, " ; {key}: {value}")?;
}
Ok(())
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ResolutionError {
InvalidDate,
InvalidAutoRuleQuery(String, String),
CommodityConversionCycle(String),
InvalidCommodityConversion(String, String),
}
impl std::fmt::Display for ResolutionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ResolutionError::InvalidDate => {
write!(f, "Invalid date")
}
ResolutionError::InvalidAutoRuleQuery(query, err) => {
write!(f, "Invalid auto-rule query `{query}`: {err}")
}
ResolutionError::CommodityConversionCycle(commodity) => {
write!(
f,
"Cycle detected in C commodity-conversion directives \
involving commodity `{commodity}`"
)
}
ResolutionError::InvalidCommodityConversion(lhs, rhs) => {
write!(
f,
"C directive `C 0 {lhs} = ... {rhs}` has a zero LHS amount; \
divisor N2/N1 is undefined (division by zero)"
)
}
}
}
}
impl std::error::Error for ResolutionError {}
fn compile_auto_rule_query(query: &str) -> Result<regex::Regex, ResolutionError> {
let pattern = if query.starts_with('/') && query.ends_with('/') && query.len() >= 2 {
query[1..query.len() - 1].to_string()
} else {
format!("(?i){}", regex::escape(query))
};
regex::Regex::new(&pattern)
.map_err(|e| ResolutionError::InvalidAutoRuleQuery(query.to_string(), e.to_string()))
}
impl HIR {
pub fn transactions(self) -> impl Iterator<Item = Transaction> {
self.entries.into_iter().filter_map(|e| {
if let Entry::Transaction(txn) = e.data {
Some(txn)
} else {
None
}
})
}
fn resolve_date(
ast: &ast::Date,
fallback_year: Option<i32>,
) -> Result<NaiveDate, ResolutionError> {
let year = ast
.year
.or(fallback_year)
.ok_or(ResolutionError::InvalidDate)?;
NaiveDate::from_ymd_opt(year, ast.month, ast.date).ok_or(ResolutionError::InvalidDate)
}
fn resolve_metadata(
notes: Vec<String>,
) -> (Vec<String>, BTreeMap<String, String>, Vec<String>) {
let mut tags: Vec<String> = vec![];
let mut metadata: BTreeMap<String, String> = Default::default();
let mut comments: Vec<String> = vec![];
for note in notes {
let note = note.trim();
if let Some(note) = note.strip_prefix(":")
&& let Some(note) = note.strip_suffix(":")
{
for tag in note.split(":") {
tags.push(tag.into());
}
} else if let Some((key, value)) = note.split_once(":") {
metadata.insert(key.trim().into(), value.trim().into());
} else {
comments.push(note.to_string());
}
}
(tags, metadata, comments)
}
}
impl TryFrom<ast::Journal> for HIR {
type Error = ResolutionError;
fn try_from(ast: ast::Journal) -> Result<Self, Self::Error> {
let mut result: HIR = Default::default();
#[allow(unused_mut)]
let mut current_default_year = None;
for entry in ast.entries {
let mut new_context: Option<Context> = None;
let context_id = result.contexts.len() - 1; let context = &result.contexts[context_id];
match entry {
ast::Entry::Directive(ast::Directive::Unknown(_)) | ast::Entry::Comment(_) => {
}
ast::Entry::Pad(p) => {
let date = Self::resolve_date(&p.date, current_default_year)?;
let data = Entry::Pad(PadDirective {
date,
target_account: p.target_account,
source_account: p.source_account,
});
result.entries.push(ResolutionEntry { context_id, data });
}
ast::Entry::Directive(ast::Directive::Commodity {
name,
notes: _,
items,
}) => {
let global_context = result
.global_context
.commodity_properties
.entry(name.clone())
.or_default();
for item in items {
match item {
ast::CommodityItem::Alias(alias) => {
new_context = Some(new_context.unwrap_or_else(|| context.clone()))
.map(|mut ctx| {
ctx.commodity_conversions.insert(
alias,
(name.clone(), rust_decimal::Decimal::ONE),
);
ctx
});
}
ast::CommodityItem::Default => {
new_context = Some(new_context.unwrap_or_else(|| context.clone()))
.map(|mut ctx| {
ctx.default_commodity = Some(name.clone());
ctx
});
}
ast::CommodityItem::Format(format) => {
let fmt_scale = scale_from_format(&format);
global_context.inferred_scale =
global_context.inferred_scale.max(fmt_scale);
global_context.format = Some(format);
}
ast::CommodityItem::NoMarket => {
global_context.no_market = true;
}
ast::CommodityItem::Note(note) => {
global_context.note = Some(note);
}
ast::CommodityItem::Unknown(key, value) => {
eprintln!(
"warning: ignoring unrecognised commodity directive \
sub-key `{key}` (value: {value:?})"
);
}
}
}
}
ast::Entry::Directive(ast::Directive::Account { name, notes, items }) => {
let global_context = result
.global_context
.account_properties
.entry(name.clone())
.or_default();
let (_tags, header_metadata, _comments) = Self::resolve_metadata(notes);
for (k, v) in header_metadata {
global_context.metadata.insert(k, v);
}
for item in items {
match item {
ast::AccountItem::Alias(alias) => {
new_context = Some(new_context.unwrap_or_else(|| context.clone()))
.map(|mut ctx| {
ctx.account_aliases.insert(alias, name.clone());
ctx
});
}
ast::AccountItem::Note(note) => global_context.note = Some(note),
ast::AccountItem::Assert(expr) => {
global_context.asserts.push(expr);
}
ast::AccountItem::Check(expr) => {
global_context.checks.push(expr);
}
ast::AccountItem::Booking(method) => {
global_context.booking_method = Some(method);
}
ast::AccountItem::Unknown(key, value) => {
let val = value.unwrap_or_default();
global_context
.metadata
.insert(key.trim().to_string(), val.trim().to_string());
}
}
}
}
ast::Entry::Directive(ast::Directive::Alias { alias, account }) => {
new_context = Some({
let mut ctx = context.clone();
ctx.account_aliases.insert(alias, account);
ctx
});
}
ast::Entry::Directive(ast::Directive::Define { name, params, body }) => {
new_context = Some({
let mut ctx = new_context.unwrap_or_else(|| context.clone());
ctx.defines.insert(name, Define { params, body });
ctx
});
}
ast::Entry::Directive(ast::Directive::Tag {
name,
asserts,
checks,
}) => {
let props = result
.global_context
.tag_properties
.entry(name)
.or_default();
for expr in asserts {
props.asserts.push(expr);
}
for expr in checks {
props.checks.push(expr);
}
}
ast::Entry::Transaction(transaction) => {
let date = Self::resolve_date(&transaction.date, current_default_year)?;
let secondary_date = if let Some(ref d) = transaction.secondary_date {
Some(Self::resolve_date(d, current_default_year)?)
} else {
None
};
let (tags, metadata, comments) = Self::resolve_metadata(transaction.notes);
let postings: Vec<Posting> = transaction
.postings
.into_iter()
.map(|p| {
let (tags, metadata, comments) = Self::resolve_metadata(p.notes);
Posting {
account: p.account,
amount: p.amount,
state: p.state,
tags,
metadata,
comments,
kind: p.kind,
}
})
.collect();
{
let mut scales: BTreeMap<String, u32> = BTreeMap::new();
for posting in &postings {
if let Some(ast::AmountDetails::Amount { value, .. }) = &posting.amount
{
collect_scales_from_expr(value, &mut scales);
}
}
for (commodity, scale) in scales {
let canonical = context
.commodity_conversions
.get(&commodity)
.map(|(c, _)| c.clone())
.unwrap_or(commodity);
let props = result
.global_context
.commodity_properties
.entry(canonical)
.or_default();
props.inferred_scale = props.inferred_scale.max(scale);
}
}
let data = Entry::Transaction(Transaction {
date,
secondary_date,
state: transaction.state,
code: transaction.code,
description: transaction.description,
comments,
tags,
metadata,
postings,
});
result.entries.push(ResolutionEntry { context_id, data });
}
ast::Entry::HistoricalPrice(hp) => {
let date = Self::resolve_date(&hp.date, current_default_year)?;
result.prices.push(HistoricalPrice {
date,
time: hp.time,
commodity: hp.commodity,
price: hp.price,
});
}
ast::Entry::Assertion(a) => {
let date = Self::resolve_date(&a.date, current_default_year)?;
let data = Entry::Assertion(AssertionDirective {
date,
account: a.account,
amount: a.amount,
strict: a.strict,
});
result.entries.push(ResolutionEntry { context_id, data });
}
ast::Entry::AutoRule(rule) => {
let query = compile_auto_rule_query(&rule.query)?;
let postings = rule
.postings
.into_iter()
.map(|p| ResolvedAutoRulePosting {
account: p.account,
amount: p.amount,
})
.collect();
result.auto_rules.push(ResolvedAutoRule { query, postings });
}
ast::Entry::CommodityConversion { lhs, rhs } => {
let divisor = rhs.value.checked_div(lhs.value).ok_or_else(|| {
ResolutionError::InvalidCommodityConversion(
lhs.commodity.clone(),
rhs.commodity.clone(),
)
})?;
let mut ctx = new_context.unwrap_or_else(|| context.clone());
ctx.commodity_conversions
.insert(rhs.commodity.clone(), (lhs.commodity.clone(), divisor));
ctx.commodity_conversions
.entry(lhs.commodity.clone())
.or_insert_with(|| (lhs.commodity.clone(), rust_decimal::Decimal::ONE));
ctx.resolve_commodity_conversion_chains()?;
new_context = Some(ctx);
}
}
if let Some(new_context) = new_context {
result.contexts.push(new_context);
}
}
Ok(result)
}
}
fn collect_scales_from_expr(expr: &ast::ValueExpr, scales: &mut BTreeMap<String, u32>) {
match expr {
ast::ValueExpr::Amount {
value,
commodity: Some(c),
} => {
let entry = scales.entry(c.clone()).or_insert(0);
*entry = (*entry).max(value.scale());
}
ast::ValueExpr::Amount {
commodity: None, ..
} => {}
ast::ValueExpr::Unary { expr, .. } => collect_scales_from_expr(expr, scales),
ast::ValueExpr::Binary { lhs, rhs, .. } => {
collect_scales_from_expr(lhs, scales);
collect_scales_from_expr(rhs, scales);
}
ast::ValueExpr::Typed { expr, commodity } => {
if let Some(scale) = bare_number_scale(expr) {
let entry = scales.entry(commodity.clone()).or_insert(0);
*entry = (*entry).max(scale);
}
collect_scales_from_expr(expr, scales);
}
ast::ValueExpr::Group(bool_expr) => {
collect_scales_from_expr(&bool_expr.lhs, scales);
if let Some((_, rhs)) = &bool_expr.cmp {
collect_scales_from_expr(rhs, scales);
}
}
_ => {}
}
}
fn bare_number_scale(expr: &ast::ValueExpr) -> Option<u32> {
match expr {
ast::ValueExpr::Amount {
value,
commodity: None,
} => Some(value.scale()),
ast::ValueExpr::Unary { expr, .. } => bare_number_scale(expr),
_ => None,
}
}
fn scale_from_format(format: &str) -> u32 {
if let Some(dot_pos) = format.find('.') {
let after_dot = &format[dot_pos + 1..];
after_dot.chars().take_while(|c| c.is_ascii_digit()).count() as u32
} else {
0
}
}
#[cfg(test)]
mod resolution_tests {
use chrono::Datelike;
use super::*;
use crate::ast;
#[test]
fn test_date_resolution() {
let d1 = ast::Date {
year: Some(2024),
month: 2,
date: 29,
};
assert!(HIR::resolve_date(&d1, None).is_ok());
let d2 = ast::Date {
year: None,
month: 1,
date: 15,
};
let resolved = HIR::resolve_date(&d2, Some(2023)).unwrap();
assert_eq!(resolved.year(), 2023);
assert!(matches!(
HIR::resolve_date(&d2, None),
Err(ResolutionError::InvalidDate)
));
let d3 = ast::Date {
year: Some(2023),
month: 2,
date: 30,
};
assert!(matches!(
HIR::resolve_date(&d3, None),
Err(ResolutionError::InvalidDate)
));
}
#[test]
fn test_metadata_extraction() {
let notes = vec![
":Financial:Tax:".to_string(),
" Invoice: 1234 ".to_string(),
"Random comment".to_string(),
];
let (tags, meta, comments) = HIR::resolve_metadata(notes);
assert_eq!(tags, vec!["Financial", "Tax"]);
assert_eq!(meta.get("Invoice").unwrap(), "1234");
assert_eq!(meta.len(), 1);
assert_eq!(comments, vec!["Random comment"]);
}
#[test]
fn test_context_versioning() {
let mut journal = ast::Journal { entries: vec![] };
let tx_ast = ast::Transaction {
date: ast::Date {
year: Some(2024),
month: 1,
date: 1,
},
description: "Tx".into(),
..Default::default()
};
journal
.entries
.push(ast::Entry::Transaction(tx_ast.clone()));
journal
.entries
.push(ast::Entry::Directive(ast::Directive::Commodity {
name: "BTC".into(),
notes: vec![],
items: vec![ast::CommodityItem::Alias("Bitcoin".into())],
}));
journal.entries.push(ast::Entry::Transaction(tx_ast));
let hir = HIR::try_from(journal).unwrap();
assert_eq!(hir.contexts.len(), 2);
assert_eq!(hir.entries[0].context_id, 0);
assert_eq!(hir.entries[1].context_id, 1);
assert_eq!(
hir.contexts[1]
.commodity_conversions
.get("Bitcoin")
.unwrap()
.0,
"BTC"
);
assert!(hir.contexts[0].commodity_conversions.is_empty());
}
#[test]
fn test_historical_price_resolution() {
use chrono::Datelike;
let price_ast = ast::HistoricalPrice {
date: ast::Date {
year: Some(2024),
month: 6,
date: 15,
},
time: Some("14:30:00".into()),
commodity: "AAPL".into(),
price: ast::ValueExpr::amount(rust_decimal::Decimal::from(182), "$".into()),
};
let journal = ast::Journal {
entries: vec![ast::Entry::HistoricalPrice(price_ast)],
};
let hir = HIR::try_from(journal).unwrap();
assert_eq!(hir.prices.len(), 1);
let price = &hir.prices[0];
assert_eq!(price.date.year(), 2024);
assert_eq!(price.date.month(), 6);
assert_eq!(price.date.day(), 15);
assert_eq!(price.time.as_deref(), Some("14:30:00"));
assert_eq!(price.commodity, "AAPL");
}
#[test]
fn test_comment_preservation_roundtrip() {
let txn_ast = ast::Transaction {
date: ast::Date {
year: Some(2024),
month: 1,
date: 15,
},
description: "Groceries".into(),
notes: vec![
"just a note".into(),
"Invoice: 42".into(),
":groceries:".into(),
],
postings: vec![
ast::Posting::new("Expenses:Food")
.with_note("posting note")
.with_amount((rust_decimal::Decimal::TEN, "$")),
ast::Posting::new("Assets:Checking"),
],
..Default::default()
};
let journal = ast::Journal {
entries: vec![ast::Entry::Transaction(txn_ast)],
};
let hir = HIR::try_from(journal).unwrap();
let Entry::Transaction(ref txn) = hir.entries[0].data else {
panic!("expected a Transaction entry");
};
assert_eq!(txn.comments, vec!["just a note"]);
assert_eq!(txn.metadata.get("Invoice").unwrap(), "42");
assert_eq!(txn.tags, vec!["groceries"]);
assert_eq!(txn.postings[0].comments, vec!["posting note"]);
}
#[test]
fn test_posting_builder() {
let posting = Posting::new("Expenses:Food")
.with_tag("groceries")
.with_comment("weekly shop")
.with_metadata("ref", "123");
assert_eq!(posting.account, "Expenses:Food");
assert_eq!(posting.tags, vec!["groceries"]);
assert_eq!(posting.comments, vec!["weekly shop"]);
assert_eq!(posting.metadata.get("ref").unwrap(), "123");
assert!(posting.amount.is_none());
}
#[test]
fn test_transaction_display_with_comment() {
use chrono::NaiveDate;
let txn = Transaction {
date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
description: "Groceries".into(),
comments: vec!["weekly shop".into()],
postings: vec![Posting::new("Expenses:Food")],
..Default::default()
};
let s = txn.to_string();
assert!(s.contains("Groceries ; weekly shop"));
assert!(s.contains("Expenses:Food"));
}
#[test]
fn test_transaction_builder() {
use chrono::NaiveDate;
let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
let secondary = NaiveDate::from_ymd_opt(2024, 3, 16).unwrap();
let txn = Transaction::new(date, "Payroll")
.with_state(ast::TransactionState::Cleared)
.with_code("PAY-42")
.with_secondary_date(secondary)
.with_tag("income")
.with_comment("monthly salary")
.with_metadata("ref", "HR-99")
.with_posting(
Posting::new("Income:Salary")
.with_amount((rust_decimal::Decimal::from(5000u32), "USD")),
)
.with_posting(Posting::new("Assets:Checking"));
assert_eq!(txn.date, date);
assert_eq!(txn.secondary_date, Some(secondary));
assert!(matches!(txn.state, ast::TransactionState::Cleared));
assert_eq!(txn.code.as_deref(), Some("PAY-42"));
assert_eq!(txn.description, "Payroll");
assert_eq!(txn.tags, vec!["income"]);
assert_eq!(txn.comments, vec!["monthly salary"]);
assert_eq!(txn.metadata.get("ref").map(String::as_str), Some("HR-99"));
assert_eq!(txn.postings.len(), 2);
assert_eq!(txn.postings[0].account, "Income:Salary");
assert!(txn.postings[0].amount.is_some());
assert_eq!(txn.postings[1].account, "Assets:Checking");
}
#[test]
fn test_posting_amount_from_tuple_display() {
use rust_decimal::dec;
let posting = Posting::new("Expenses:Food").with_amount((dec!(10.50), "$"));
let rendered = posting.to_string();
assert!(
rendered.contains("10.50"),
"expected '10.50' in: {rendered}"
);
assert!(rendered.contains("$"), "expected '$' in: {rendered}");
assert!(
rendered.contains("Expenses:Food"),
"expected account in: {rendered}"
);
}
#[test]
fn test_define_directive_stored_in_context() {
let expr = ast::ValueExpr::Amount {
value: rust_decimal::Decimal::from(1500),
commodity: Some("$".into()),
};
let journal = ast::Journal {
entries: vec![ast::Entry::Directive(ast::Directive::Define {
name: "monthly_rent".into(),
params: vec![],
body: ast::DefineBody::Value(expr.clone()),
})],
};
let hir = HIR::try_from(journal).unwrap();
assert_eq!(hir.contexts.len(), 2);
assert!(
hir.contexts[1].defines.contains_key("monthly_rent"),
"define should be stored in the new context"
);
assert!(hir.contexts[0].defines.is_empty());
}
#[test]
fn test_define_directive_context_versioning() {
let tx_ast = ast::Transaction {
date: ast::Date {
year: Some(2024),
month: 1,
date: 1,
},
description: "Tx".into(),
..Default::default()
};
let expr = ast::ValueExpr::Amount {
value: rust_decimal::Decimal::from(500),
commodity: Some("$".into()),
};
let journal = ast::Journal {
entries: vec![
ast::Entry::Transaction(tx_ast.clone()),
ast::Entry::Directive(ast::Directive::Define {
name: "budget".into(),
params: vec![],
body: ast::DefineBody::Value(expr.clone()),
}),
ast::Entry::Transaction(tx_ast),
],
};
let hir = HIR::try_from(journal).unwrap();
assert_eq!(
hir.entries[0].context_id, 0,
"tx before define should use context 0"
);
assert_eq!(
hir.entries[1].context_id, 1,
"tx after define should use context 1"
);
assert!(hir.contexts[1].defines.contains_key("budget"));
}
#[test]
fn test_commodity_note_stored_in_global_context() {
let journal = ast::Journal {
entries: vec![ast::Entry::Directive(ast::Directive::Commodity {
name: "$".into(),
notes: vec![],
items: vec![
ast::CommodityItem::Note("American Dollars".into()),
ast::CommodityItem::Format("$1,000.00".into()),
],
})],
};
let hir = HIR::try_from(journal).unwrap();
let props = hir
.global_context
.commodity_properties
.get("$")
.expect("commodity '$' should have properties");
assert_eq!(
props.note.as_deref(),
Some("American Dollars"),
"note should be stored in CommodityProperties"
);
assert_eq!(
props.format.as_deref(),
Some("$1,000.00"),
"format should also be stored"
);
}
#[test]
fn test_assertion_directive_resolution() {
use chrono::Datelike;
let assertion_ast = ast::AssertionDirective {
date: ast::Date {
year: Some(2024),
month: 3,
date: 31,
},
account: "Assets:Checking".into(),
amount: ast::ValueExpr::amount(rust_decimal::Decimal::from(1000), "$".into()),
strict: true,
};
let journal = ast::Journal {
entries: vec![ast::Entry::Assertion(assertion_ast)],
};
let hir = HIR::try_from(journal).unwrap();
assert_eq!(hir.entries.len(), 1);
let Entry::Assertion(ref a) = hir.entries[0].data else {
panic!("expected Assertion entry");
};
assert_eq!(a.date.year(), 2024);
assert_eq!(a.date.month(), 3);
assert_eq!(a.date.day(), 31);
assert_eq!(a.account, "Assets:Checking");
assert!(a.strict);
assert!(
matches!(a.amount, ast::ValueExpr::Amount { commodity: Some(ref c), .. } if c == "$")
);
}
#[test]
fn test_inferred_scale_populated_in_hir() {
use rust_decimal::dec;
let make_posting = |value: rust_decimal::Decimal, commodity: &str| -> ast::Posting {
ast::Posting::new("Assets:Test").with_amount(ast::AmountDetails::Amount {
value: ast::ValueExpr::Amount {
value,
commodity: Some(commodity.into()),
},
lot_annotation: None,
lot_pricing: None,
balance_assertion: None,
})
};
let tx = ast::Transaction {
date: ast::Date {
year: Some(2024),
month: 1,
date: 1,
},
description: "Scale test".into(),
notes: vec![],
postings: vec![
make_posting(dec!(1.00), "$"),
make_posting(dec!(2.50), "$"),
make_posting(dec!(3.00), "$"),
ast::Posting::new("Assets:Other"),
],
..Default::default()
};
let journal = ast::Journal {
entries: vec![ast::Entry::Transaction(tx)],
};
let hir = HIR::try_from(journal).unwrap();
let props = hir
.global_context
.commodity_properties
.get("$")
.expect("$ should have properties after resolution");
assert_eq!(
props.inferred_scale, 2,
"inferred_scale for $ must be 2 (max of 2, 2, 2 from $1.00, $2.50, $3.00)"
);
}
#[test]
fn test_inferred_scale_hledger_typed_node() {
use rust_decimal::dec;
let typed_amount = ast::AmountDetails::Amount {
value: ast::ValueExpr::Typed {
expr: Box::new(ast::ValueExpr::Unary {
op: ast::Op::Sub,
expr: Box::new(ast::ValueExpr::Amount {
value: dec!(0.71),
commodity: None,
}),
}),
commodity: "B".into(),
},
lot_annotation: None,
lot_pricing: None,
balance_assertion: None,
};
let tx = ast::Transaction {
date: ast::Date {
year: Some(2024),
month: 1,
date: 1,
},
description: "Typed node test".into(),
notes: vec![],
postings: vec![
ast::Posting::new("Assets:A").with_amount(typed_amount),
ast::Posting::new("Assets:B"),
],
..Default::default()
};
let journal = ast::Journal {
entries: vec![ast::Entry::Transaction(tx)],
};
let hir = HIR::try_from(journal).unwrap();
let props = hir
.global_context
.commodity_properties
.get("B")
.expect("B should have properties after resolution of Typed node");
assert_eq!(
props.inferred_scale, 2,
"inferred_scale for B must be 2 from the Typed{{Unary{{Sub, 0.71}}}} node"
);
}
#[test]
fn test_inferred_scale_from_format_directive() {
let journal = ast::Journal {
entries: vec![ast::Entry::Directive(ast::Directive::Commodity {
name: "GOLD".into(),
notes: vec![],
items: vec![
ast::CommodityItem::Default,
ast::CommodityItem::Format("1.00 GOLD".into()),
],
})],
};
let hir = HIR::try_from(journal).unwrap();
let props = hir
.global_context
.commodity_properties
.get("GOLD")
.expect("GOLD should have properties from D directive");
assert_eq!(
props.inferred_scale, 2,
"inferred_scale for GOLD must be 2 from D 1.00 GOLD format directive"
);
}
#[test]
fn test_inferred_scale_format_and_direct_max() {
use rust_decimal::dec;
let format_directive = ast::Entry::Directive(ast::Directive::Commodity {
name: "GOLD".into(),
notes: vec![],
items: vec![ast::CommodityItem::Format("1.000 GOLD".into())],
});
let tx = ast::Transaction {
date: ast::Date {
year: Some(2024),
month: 1,
date: 1,
},
description: "Format + direct max test".into(),
postings: vec![
ast::Posting::new("Assets:Wallet").with_amount(ast::AmountDetails::Amount {
value: ast::ValueExpr::Amount {
value: dec!(1.00),
commodity: Some("GOLD".into()),
},
lot_annotation: None,
lot_pricing: None,
balance_assertion: None,
}),
ast::Posting::new("Assets:Cash"),
],
..Default::default()
};
let journal = ast::Journal {
entries: vec![format_directive, ast::Entry::Transaction(tx)],
};
let hir = HIR::try_from(journal).unwrap();
let props = hir
.global_context
.commodity_properties
.get("GOLD")
.expect("GOLD should have properties");
assert_eq!(
props.inferred_scale, 3,
"inferred_scale for GOLD must be 3 (max of format scale 3 and direct scale 2)"
);
}
#[test]
fn test_inferred_scale_at_price_not_included() {
use rust_decimal::dec;
let posting_direct =
ast::Posting::new("Assets:Wallet").with_amount(ast::AmountDetails::Amount {
value: ast::ValueExpr::Amount {
value: dec!(1),
commodity: Some("GOLD".into()),
},
lot_annotation: None,
lot_pricing: None,
balance_assertion: None,
});
let posting_at =
ast::Posting::new("Assets:Items").with_amount(ast::AmountDetails::Amount {
value: ast::ValueExpr::Amount {
value: dec!(1),
commodity: Some("ITEM".into()),
},
lot_annotation: None,
lot_pricing: Some(ast::LotPricing::Unit(ast::ValueExpr::Amount {
value: dec!(1.250),
commodity: Some("GOLD".into()),
})),
balance_assertion: None,
});
let counter = ast::Posting::new("Assets:Cash");
let tx = ast::Transaction {
date: ast::Date {
year: Some(2024),
month: 1,
date: 1,
},
description: "At-price not included test".into(),
postings: vec![posting_direct, posting_at, counter],
..Default::default()
};
let journal = ast::Journal {
entries: vec![ast::Entry::Transaction(tx)],
};
let hir = HIR::try_from(journal).unwrap();
let props = hir
.global_context
.commodity_properties
.get("GOLD")
.expect("GOLD should have properties from direct posting");
assert_eq!(
props.inferred_scale, 0,
"inferred_scale for GOLD must be 0 (direct posting only; @-price scale 3 excluded)"
);
}
#[test]
fn test_inferred_scale_lot_cost_not_included() {
use rust_decimal::dec;
let posting =
ast::Posting::new("Assets:Brokerage").with_amount(ast::AmountDetails::Amount {
value: ast::ValueExpr::Amount {
value: dec!(1),
commodity: Some("AAPL".into()),
},
lot_annotation: Some(ast::LotAnnotation {
cost: Some(ast::ValueExpr::Amount {
value: dec!(150.50),
commodity: Some("USD".into()),
}),
..Default::default()
}),
lot_pricing: None,
balance_assertion: None,
});
let counter = ast::Posting::new("Assets:Cash");
let tx = ast::Transaction {
date: ast::Date {
year: Some(2024),
month: 1,
date: 1,
},
description: "Lot cost not included test".into(),
postings: vec![posting, counter],
..Default::default()
};
let journal = ast::Journal {
entries: vec![ast::Entry::Transaction(tx)],
};
let hir = HIR::try_from(journal).unwrap();
let has_usd = hir
.global_context
.commodity_properties
.get("USD")
.map(|p| p.inferred_scale > 0)
.unwrap_or(false);
assert!(
!has_usd,
"inferred_scale for USD must NOT be populated from {{cost}} lot annotations"
);
}
}