#![allow(dead_code)]
use std::{collections::BTreeMap, fmt::Display};
use chrono::{Datelike, NaiveDate};
use pest::Parser as PestParser;
use rust_decimal::Decimal;
use crate::grammars::ledger::{self, LedgerParser};
#[derive(Debug)]
pub struct Journal {
pub(crate) entries: Vec<Entry>,
}
#[derive(Debug)]
pub(crate) enum Entry {
Transaction(Transaction),
Directive(Directive),
HistoricalPrice(HistoricalPrice),
Assertion(AssertionDirective),
Pad(PadDirective),
Comment(String),
AutoRule(AutoRule),
CommodityConversion {
lhs: CommodityAmount,
rhs: CommodityAmount,
},
}
#[derive(Debug, Clone)]
pub(crate) struct CommodityAmount {
pub value: Decimal,
pub commodity: String,
}
#[derive(Clone, Default, Debug)]
pub(crate) struct AutoRule {
pub query: String,
pub postings: Vec<Posting>,
}
#[derive(Debug, Clone)]
pub(crate) struct PadDirective {
pub date: Date,
pub target_account: String,
pub source_account: String,
}
#[derive(Debug, Clone)]
pub(crate) struct AssertionDirective {
pub date: Date,
pub account: String,
pub amount: ValueExpr,
pub strict: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct HistoricalPrice {
pub date: Date,
pub time: Option<String>,
pub commodity: String,
pub price: ValueExpr,
}
#[derive(Debug)]
pub(crate) enum Directive {
Commodity {
name: String,
notes: Vec<String>,
items: Vec<CommodityItem>,
},
Account {
name: String,
notes: Vec<String>,
items: Vec<AccountItem>,
},
Unknown(String),
Alias {
alias: String,
account: String,
},
Define {
name: String,
params: Vec<String>,
body: DefineBody,
},
Tag {
name: String,
asserts: Vec<BoolExpr>,
checks: Vec<BoolExpr>,
},
}
#[derive(Clone, Debug)]
pub(crate) enum CommodityItem {
Alias(String),
Format(String),
NoMarket,
Default,
Note(String),
Unknown(String, Option<String>),
}
#[derive(Clone, Debug)]
pub(crate) enum AccountItem {
Alias(String),
Note(String),
Assert(BoolExpr),
Check(BoolExpr),
Booking(crate::resolution::BookingMethod),
Unknown(String, Option<String>),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BoolExpr {
pub lhs: ValueExpr,
pub cmp: Option<(CmpOp, ValueExpr)>,
pub chain: Option<(BoolOp, Box<BoolExpr>)>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CmpOp {
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
RegexMatch,
RegexNotMatch,
}
impl std::fmt::Display for CmpOp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CmpOp::Eq => write!(f, "=="),
CmpOp::Ne => write!(f, "!="),
CmpOp::Lt => write!(f, "<"),
CmpOp::Le => write!(f, "<="),
CmpOp::Gt => write!(f, ">"),
CmpOp::Ge => write!(f, ">="),
CmpOp::RegexMatch => write!(f, "=~"),
CmpOp::RegexNotMatch => write!(f, "!~"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BoolOp {
And,
Or,
}
impl std::fmt::Display for BoolOp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BoolOp::And => write!(f, "and"),
BoolOp::Or => write!(f, "or"),
}
}
}
impl std::fmt::Display for BoolExpr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.lhs)?;
if let Some((op, rhs)) = &self.cmp {
write!(f, " {op} {rhs}")?;
}
if let Some((op, cont)) = &self.chain {
write!(f, " {op} {cont}")?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum DefineBody {
Value(ValueExpr),
Bool(BoolExpr),
}
impl std::fmt::Display for DefineBody {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DefineBody::Value(e) => write!(f, "{e}"),
DefineBody::Bool(e) => write!(f, "{e}"),
}
}
}
#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Default, Debug)]
pub(crate) struct Date {
pub year: Option<i32>,
pub month: u32,
pub date: u32,
}
impl From<NaiveDate> for Date {
fn from(value: NaiveDate) -> Self {
Self {
year: Some(value.year()),
month: value.month0() + 1,
date: value.day0() + 1,
}
}
}
#[derive(Clone, Default, Debug)]
pub(crate) struct Transaction {
pub date: Date,
pub secondary_date: Option<Date>,
pub state: TransactionState,
pub code: Option<String>,
pub description: String,
pub notes: Vec<String>,
pub postings: Vec<Posting>,
}
impl Display for Transaction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(year) = self.date.year {
write!(f, "{year:04}-")?;
}
write!(f, "{:02}-{:02}", self.date.month, self.date.date)?;
if let Some(ref date) = self.secondary_date {
write!(f, "=")?;
if let Some(year) = date.year {
write!(f, "{year:04}-")?;
}
write!(f, "{:02}-{:02}", date.month, date.date)?;
}
match self.state {
TransactionState::Uncleared => {}
TransactionState::Pending => write!(f, " !")?,
TransactionState::Cleared => write!(f, " *")?,
}
if let Some(ref code) = self.code {
write!(f, " ({code})")?;
}
writeln!(f, " {}", self.description)?;
for note in self.notes.iter() {
writeln!(f, " ; {note}")?;
}
for posting in self.postings.iter() {
posting.fmt(f)?;
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PostingKind {
#[default]
Real,
VirtualUnbalanced,
VirtualBalanced,
}
#[derive(Clone, Default, Debug)]
pub(crate) struct Posting {
pub account: String,
pub amount: Option<AmountDetails>,
pub state: TransactionState,
pub notes: Vec<String>,
pub kind: PostingKind,
}
impl Posting {
pub fn new<S: Into<String>>(account: S) -> Self {
Self {
account: account.into(),
amount: None,
state: TransactionState::Uncleared,
notes: vec![],
kind: PostingKind::Real,
}
}
pub fn with_note<S: Into<String>>(mut self, note: S) -> Self {
self.notes.push(note.into());
self
}
pub fn with_amount<A: Into<AmountDetails>>(mut self, amount: A) -> Self {
self.amount = Some(amount.into());
self
}
}
impl Display for Posting {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, " ")?;
match self.state {
TransactionState::Uncleared => {}
TransactionState::Pending => write!(f, "! ")?,
TransactionState::Cleared => write!(f, "* ")?,
}
write!(f, "{}", self.account)?;
if let Some(ref amount) = self.amount {
write!(f, " {amount}")?;
}
writeln!(f)?;
for note in self.notes.iter() {
writeln!(f, " ; {note}")?;
}
Ok(())
}
}
#[derive(PartialEq, Eq, Clone, Debug)]
#[non_exhaustive]
pub enum AmountDetails {
Amount {
value: ValueExpr,
lot_annotation: Option<LotAnnotation>,
lot_pricing: Option<LotPricing>,
balance_assertion: Option<ValueExpr>,
},
BalanceAssignment(ValueExpr),
BalanceAssignmentAllCommodities(ValueExpr),
}
impl<I: Into<ValueExpr>> From<I> for AmountDetails {
fn from(value: I) -> Self {
AmountDetails::Amount {
value: value.into(),
lot_annotation: None,
lot_pricing: None,
balance_assertion: None,
}
}
}
impl Display for AmountDetails {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AmountDetails::Amount {
value,
lot_annotation,
lot_pricing,
balance_assertion,
} => {
write!(f, "{value}")?;
if let Some(ann) = lot_annotation {
if let Some(cost) = &ann.cost {
write!(f, " {{{cost}}}")?;
}
if let Some(date) = ann.date {
write!(f, " [{date}]")?;
}
if let Some(note) = &ann.note {
write!(f, " (({note}))")?;
}
}
if let Some(lot_pricing) = lot_pricing {
match lot_pricing {
LotPricing::Unit(value_expr) => write!(f, " @ {value_expr}")?,
LotPricing::Total(value_expr) => write!(f, " @@ {value_expr}")?,
}
}
if let Some(balance_assertion) = balance_assertion {
write!(f, " = {balance_assertion}")?;
}
Ok(())
}
AmountDetails::BalanceAssignment(value) => {
write!(f, "={value}")
}
AmountDetails::BalanceAssignmentAllCommodities(value) => {
write!(f, "==* {value}")
}
}
}
}
impl Display for ValueExpr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ValueExpr::Amount { value, commodity } => {
write!(f, "{value}")?;
if let Some(commodity) = commodity {
write!(f, " {commodity}")?;
}
Ok(())
}
ValueExpr::Str(s) => write!(f, "\"{s}\""),
ValueExpr::Regex(pattern) => write!(f, "/{pattern}/"),
ValueExpr::Unary { op, expr } => {
let op = match op {
Op::Add => "+",
Op::Sub => "-",
Op::Mul => "*",
Op::Div => "/",
};
write!(f, "{op}{expr}")
}
ValueExpr::Binary { lhs, rhs, op } => {
let op = match op {
Op::Add => "+",
Op::Sub => "-",
Op::Mul => "*",
Op::Div => "/",
};
write!(f, "{lhs} {op} {rhs}")
}
ValueExpr::Function { name, args } => {
write!(f, "{name}(")?;
let mut args = args.iter();
if let Some(a) = args.next() {
write!(f, "{a}")?;
}
for a in args {
write!(f, ", {a}")?;
}
write!(f, ")")
}
ValueExpr::Commodity(c) => write!(f, "{c}"),
ValueExpr::Typed { expr, commodity } => write!(f, "{expr} {commodity}"),
ValueExpr::Access { expr, field } => write!(f, "{expr}.{field}"),
ValueExpr::Object(_) => todo!(),
ValueExpr::Group(b) => write!(f, "({b})"),
}
}
}
#[derive(PartialEq, Eq, Debug, Clone)]
#[non_exhaustive]
pub enum ValueExpr {
Object(BTreeMap<String, Self>),
Amount {
value: Decimal,
commodity: Option<String>,
},
Unary { op: Op, expr: Box<ValueExpr> },
Binary {
lhs: Box<ValueExpr>,
rhs: Box<ValueExpr>,
op: Op,
},
Function { name: String, args: Vec<ValueExpr> },
Commodity(String),
Typed {
expr: Box<ValueExpr>,
commodity: String,
},
Str(String),
Regex(String),
Access { expr: Box<ValueExpr>, field: String },
Group(Box<BoolExpr>),
}
impl ValueExpr {
pub fn amount(value: Decimal, commodity: String) -> ValueExpr {
ValueExpr::Amount {
value,
commodity: Some(commodity),
}
}
pub fn parse(input: &str) -> Result<ValueExpr, pest::error::Error<ledger::Rule>> {
let mut pairs = LedgerParser::parse(ledger::Rule::value_expr, input)?;
let pair = pairs.next().unwrap();
Ok(ledger::parse_expr(pair))
}
}
impl<S: Into<String>> From<(Decimal, S)> for ValueExpr {
fn from(value: (Decimal, S)) -> Self {
Self::amount(value.0, value.1.into())
}
}
#[derive(PartialEq, Eq, Debug, Clone, Copy)]
pub enum Op {
Add,
Sub,
Mul,
Div,
}
#[derive(PartialEq, Eq, Clone, Debug)]
pub enum LotPricing {
Unit(ValueExpr),
Total(ValueExpr),
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct LotAnnotation {
pub cost: Option<ValueExpr>,
pub cost_is_total: bool,
pub date: Option<chrono::NaiveDate>,
pub note: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub enum TransactionState {
#[default]
Uncleared,
Pending,
Cleared,
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::Decimal;
#[test]
fn test_date_ordering() {
let d1 = Date {
year: Some(2024),
month: 1,
date: 1,
};
let d2 = Date {
year: Some(2024),
month: 6,
date: 15,
};
let d3 = Date {
year: Some(2025),
month: 1,
date: 1,
};
let d4 = Date {
year: Some(2024),
month: 1,
date: 1,
};
assert!(d1 < d2);
assert!(d2 < d3);
assert!(d1 < d3);
assert_eq!(d1, d4);
assert!(d3 > d1);
let mut dates = vec![d3.clone(), d1.clone(), d2.clone()];
dates.sort();
assert_eq!(dates, vec![d1, d2, d3]);
}
#[test]
fn test_transaction_display_indentation() {
let mut tx = Transaction::default();
tx.date = Date {
year: Some(2024),
month: 1,
date: 15,
};
tx.description = "Test payee".to_string();
tx.notes = vec!["a note".to_string()];
let mut posting = Posting::new("Assets:Bank");
posting.amount = Some(AmountDetails::Amount {
value: ValueExpr::Amount {
value: Decimal::from(100),
commodity: Some("USD".into()),
},
lot_annotation: None,
lot_pricing: None,
balance_assertion: None,
});
tx.postings = vec![posting];
let out = format!("{tx}");
assert!(
out.contains(" ; a note"),
"note should have 4-space indent, got:\n{out}"
);
assert!(
out.contains(" Assets:Bank"),
"posting should have 4-space indent, got:\n{out}"
);
}
#[test]
fn test_value_expr_parse_amount() {
let expr = ValueExpr::parse("100 USD").unwrap();
assert_eq!(
expr,
ValueExpr::Amount {
value: "100".parse().unwrap(),
commodity: Some("USD".into()),
}
);
}
#[test]
fn test_value_expr_parse_prefixed_commodity() {
let expr = ValueExpr::parse("$50").unwrap();
assert_eq!(
expr,
ValueExpr::Amount {
value: "50".parse().unwrap(),
commodity: Some("$".into()),
}
);
}
#[test]
fn test_value_expr_parse_arithmetic() {
let expr = ValueExpr::parse("10 + 5 USD").unwrap();
assert!(
matches!(expr, ValueExpr::Binary { .. } | ValueExpr::Typed { .. }),
"expected Binary or Typed, got {expr:?}"
);
}
#[test]
fn test_value_expr_parse_error() {
assert!(ValueExpr::parse("@@@invalid").is_err());
}
}