use std::{
collections::{BTreeMap, BTreeSet},
fs::File,
io::{Read as _, Write as _},
path::PathBuf,
};
use clap::{Parser, Subcommand};
use regex::Regex;
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Compile {
#[arg(short, long)]
output: PathBuf,
source: PathBuf,
},
Balance {
source: PathBuf,
pattern: Option<String>,
#[arg(long)]
begin: Option<String>,
#[arg(long)]
end: Option<String>,
#[arg(long)]
cleared: bool,
#[arg(long)]
tag: Option<String>,
#[arg(long)]
depth: Option<usize>,
#[arg(long, default_value_t = false)]
flat: bool,
#[arg(long, default_value = "text")]
format: String,
},
Register {
source: PathBuf,
pattern: Option<String>,
#[arg(long)]
begin: Option<String>,
#[arg(long)]
end: Option<String>,
#[arg(long, default_value_t = false)]
cleared: bool,
#[arg(long)]
tag: Option<String>,
#[arg(long, default_value = "text")]
format: String,
},
Print {
source: PathBuf,
},
Accounts {
source: PathBuf,
pattern: Option<String>,
},
Commodities { source: PathBuf },
Stats { source: PathBuf },
}
enum OutputFormat {
Text,
Json,
Csv,
}
impl OutputFormat {
fn parse(s: &str) -> Result<Self, Box<dyn std::error::Error>> {
match s {
"text" => Ok(OutputFormat::Text),
"json" => Ok(OutputFormat::Json),
"csv" => Ok(OutputFormat::Csv),
other => Err(format!(
"unknown format {:?}; valid options are: text, json, csv",
other
)
.into()),
}
}
}
fn load_journal(path: &PathBuf) -> Result<doppio::Journal, Box<dyn std::error::Error>> {
if let Some("dop") = path.extension().and_then(|e| e.to_str()) {
let mut f = File::open(path)?;
doppio::dop_read_header(&mut f, path)?;
let input_xz = xz::read::XzDecoder::new(f);
let buf_input = std::io::BufReader::new(input_xz);
let mut buf = vec![0; 102400];
Ok(postcard::from_io((buf_input, &mut buf))?.0)
} else {
let base_path = path.parent().unwrap().to_path_buf();
let parser = doppio::parser::Parser {
opener: doppio::file_opener,
base_path,
};
let mut file = String::new();
File::open(path)?.read_to_string(&mut file)?;
Ok(doppio::compile(&file, parser)?)
}
}
fn truncate_account(account: &str, depth: usize) -> &str {
let mut colon_pos = None;
let mut count = 0;
for (i, c) in account.char_indices() {
if c == ':' {
count += 1;
if count == depth {
colon_pos = Some(i);
break;
}
}
}
match colon_pos {
Some(pos) => &account[..pos],
None => account,
}
}
fn build_pattern_regex(pattern: Option<String>) -> Result<Regex, Box<dyn std::error::Error>> {
let raw = match pattern {
Some(p) => format!("(?i){}", p),
None => ".*".to_string(),
};
Regex::new(&raw).map_err(|e| format!("invalid account pattern: {e}").into())
}
struct JournalFilter {
pattern: Regex,
begin_date: Option<chrono::NaiveDate>,
end_date: Option<chrono::NaiveDate>,
cleared: bool,
tag: Option<String>,
}
impl JournalFilter {
fn new(
pattern: Option<String>,
begin: Option<&str>,
end: Option<&str>,
cleared: bool,
tag: Option<String>,
) -> Result<Self, Box<dyn std::error::Error>> {
let pattern = build_pattern_regex(pattern)?;
let begin_date = begin
.map(|s| {
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| {
format!("invalid --begin date '{}': expected format YYYY-MM-DD", s)
})
})
.transpose()?;
let end_date = end
.map(|s| {
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
.map_err(|_| format!("invalid --end date '{}': expected format YYYY-MM-DD", s))
})
.transpose()?;
Ok(JournalFilter {
pattern,
begin_date,
end_date,
cleared,
tag,
})
}
fn matches_transaction(&self, txn: &doppio::elaboration::ResolvedTransaction) -> bool {
if self.cleared && !matches!(txn.state, doppio::elaboration::TransactionState::Cleared) {
return false;
}
if let Some(ref t) = self.tag
&& !txn.tags.iter().any(|tag| tag == t)
&& !txn
.postings
.iter()
.any(|p| p.tags.iter().any(|tag| tag == t))
{
return false;
}
if self.begin_date.is_some() || self.end_date.is_some() {
let unix_epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
let txn_date = unix_epoch.checked_add_signed(chrono::Duration::days(txn.date as i64));
if let Some(txn_date) = txn_date {
if let Some(begin) = self.begin_date
&& txn_date < begin
{
return false;
}
if let Some(end) = self.end_date
&& txn_date > end
{
return false;
}
}
}
true
}
fn matches_account(&self, account: &str) -> bool {
self.pattern.is_match(account)
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
match cli.command {
Commands::Compile { output, source } => {
let base_path = source.parent().unwrap().to_path_buf();
let parser = doppio::parser::Parser {
opener: doppio::file_opener,
base_path,
};
let mut file = String::new();
File::open(source)?.read_to_string(&mut file)?;
let journal = doppio::compile(&file, parser)?;
let mut out_file = File::create(output)?;
doppio::dop_write_header(&mut out_file)?;
let mut output_xz = xz::write::XzEncoder::new(out_file, 1);
{
let mut buf = std::io::BufWriter::new(&mut output_xz);
postcard::to_io(&journal, &mut buf)?;
buf.flush()?;
}
output_xz.finish()?;
}
Commands::Register {
source,
pattern,
begin,
end,
cleared,
tag,
format,
} => {
let format = OutputFormat::parse(&format)?;
let filter =
JournalFilter::new(pattern, begin.as_deref(), end.as_deref(), cleared, tag)?;
let journal = load_journal(&source)?;
let mut running: BTreeMap<String, rust_decimal::Decimal> = BTreeMap::new();
let filtered_txns: Vec<_> = journal
.transactions
.iter()
.filter(|txn| filter.matches_transaction(txn))
.collect();
match format {
OutputFormat::Text => {
for txn in &filtered_txns {
let date = epoch_days_to_string(txn.date);
for posting in txn.postings.iter() {
if !filter.matches_account(&posting.account) {
continue;
}
for (commodity, amount) in posting.amount.0.iter() {
*running.entry(commodity.clone()).or_default() += amount;
}
let mut commodities_iter = posting.amount.0.iter();
if let Some((commodity, amount)) = commodities_iter.next() {
let amount_str =
display_amount(commodity, *amount, &journal.commodities);
let running_str = display_amount(
commodity,
running.get(commodity).copied().unwrap_or_default(),
&journal.commodities,
);
println!(
"{:<10} {:<20} {:<30} {:>15} {:>15}",
date,
txn.description.chars().take(20).collect::<String>(),
posting.account,
amount_str,
running_str,
);
}
for (commodity, amount) in commodities_iter {
let amount_str =
display_amount(commodity, *amount, &journal.commodities);
let running_str = display_amount(
commodity,
running.get(commodity).copied().unwrap_or_default(),
&journal.commodities,
);
println!(
"{:<10} {:<20} {:<30} {:>15} {:>15}",
"", "", "", amount_str, running_str,
);
}
}
}
}
OutputFormat::Json => {
let mut rows: Vec<serde_json::Value> = Vec::new();
for txn in &filtered_txns {
let date = epoch_days_to_string(txn.date);
for posting in txn.postings.iter() {
if !filter.matches_account(&posting.account) {
continue;
}
for (commodity, amount) in posting.amount.0.iter() {
*running.entry(commodity.clone()).or_default() += amount;
let running_total =
running.get(commodity).copied().unwrap_or_default();
rows.push(serde_json::json!({
"date": date,
"description": txn.description,
"account": posting.account,
"commodity": commodity,
"amount": amount.to_string(),
"running_total": running_total.to_string(),
}));
}
}
}
println!("{}", serde_json::to_string_pretty(&rows)?);
}
OutputFormat::Csv => {
println!("date,description,account,commodity,amount,running_total");
for txn in &filtered_txns {
let date = epoch_days_to_string(txn.date);
for posting in txn.postings.iter() {
if !filter.matches_account(&posting.account) {
continue;
}
for (commodity, amount) in posting.amount.0.iter() {
*running.entry(commodity.clone()).or_default() += amount;
let running_total =
running.get(commodity).copied().unwrap_or_default();
println!(
"{},{},{},{},{},{}",
csv_field(&date),
csv_field(&txn.description),
csv_field(&posting.account),
csv_field(commodity),
amount,
running_total,
);
}
}
}
}
}
}
Commands::Print { source } => {
if let Some("dop") = source.extension().and_then(|e| e.to_str()) {
return Err("print only works with .ledger source files; \
.dop binary archives do not preserve the original transaction structure"
.into());
}
let base_path = source.parent().unwrap().to_path_buf();
let mut parser = doppio::parser::Parser {
opener: doppio::file_opener,
base_path,
};
let mut file = String::new();
File::open(&source)?.read_to_string(&mut file)?;
let ast_journal: doppio::ast::Journal = parser.parse(&file)?;
let hir: doppio::resolution::HIR = ast_journal.try_into()?;
doppio::write_ledger(hir.transactions(), &mut std::io::stdout())?;
}
Commands::Accounts { source, pattern } => {
let journal = load_journal(&source)?;
let pattern = pattern.map(|p| p.to_lowercase()).unwrap_or_default();
for account in journal.accounts.keys() {
if account.to_lowercase().contains(&pattern) {
println!("{}", account);
}
}
}
Commands::Commodities { source } => {
let journal = load_journal(&source)?;
let commodities: BTreeSet<&String> = journal
.transactions
.iter()
.flat_map(|txn| txn.postings.iter())
.flat_map(|posting| posting.amount.0.keys())
.collect();
for commodity in commodities {
println!("{}", commodity);
}
}
Commands::Stats { source } => {
let journal = load_journal(&source)?;
let commodities: BTreeSet<&String> = journal
.transactions
.iter()
.flat_map(|txn| txn.postings.iter())
.flat_map(|posting| posting.amount.0.keys())
.collect();
let unix_epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
let first_date = journal.transactions.first().and_then(|txn| {
unix_epoch.checked_add_signed(chrono::Duration::days(txn.date as i64))
});
let last_date = journal.transactions.last().and_then(|txn| {
unix_epoch.checked_add_signed(chrono::Duration::days(txn.date as i64))
});
println!("Transactions: {}", journal.transactions.len());
println!("Accounts: {}", journal.accounts.len());
println!("Commodities: {}", commodities.len());
match (first_date, last_date) {
(Some(first), Some(last)) => {
println!("First date: {}", first);
println!("Last date: {}", last);
}
_ => {
println!("First date: N/A");
println!("Last date: N/A");
}
}
}
Commands::Balance {
source,
pattern,
begin,
end,
cleared,
tag,
depth,
flat,
format,
} => {
let format = OutputFormat::parse(&format)?;
let filter =
JournalFilter::new(pattern, begin.as_deref(), end.as_deref(), cleared, tag)?;
let journal = load_journal(&source)?;
let mut balances: BTreeMap<String, BTreeMap<String, rust_decimal::Decimal>> =
BTreeMap::new();
for txn in journal.transactions.iter() {
if !filter.matches_transaction(txn) {
continue;
}
for posting in txn.postings.iter() {
if !filter.matches_account(&posting.account) {
continue;
}
let account = match depth {
Some(d) => truncate_account(&posting.account, d).to_owned(),
None => posting.account.clone(),
};
for (commodity, amount) in posting.amount.0.iter() {
*(balances
.entry(account.clone())
.or_default()
.entry(commodity.clone())
.or_default()) += *amount;
}
}
}
match format {
OutputFormat::Text => {
for (account, commodities) in balances.iter() {
let indent_depth = account.chars().filter(|&c| c == ':').count();
let label: &str = if flat || indent_depth == 0 {
account.as_str()
} else {
account
.rsplit_once(':')
.map(|(_, last)| last)
.unwrap_or(account.as_str())
};
let indent = if flat { 0 } else { indent_depth * 2 };
let prefix = " ".repeat(indent);
let mut commodities_iter = commodities.iter();
if let Some((commodity, value)) = commodities_iter.next() {
let balance = display_amount(commodity, *value, &journal.commodities);
println!("{balance:>20} {prefix}{label}");
}
for (commodity, value) in commodities_iter {
let balance = display_amount(commodity, *value, &journal.commodities);
println!("{balance:>20}");
}
}
}
OutputFormat::Json => {
let rows: Vec<serde_json::Value> = balances
.iter()
.map(|(account, acct_balances)| {
let commodity_amounts: Vec<serde_json::Value> = acct_balances
.iter()
.map(|(commodity, amount)| {
serde_json::json!({
"commodity": commodity,
"amount": amount.to_string(),
})
})
.collect();
serde_json::json!({
"account": account,
"balances": commodity_amounts,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&rows)?);
}
OutputFormat::Csv => {
println!("account,commodity,amount");
for (account, acct_balances) in balances.iter() {
for (commodity, amount) in acct_balances.iter() {
println!("{},{},{}", csv_field(account), csv_field(commodity), amount,);
}
}
}
}
}
}
Ok(())
}
fn format_amount(commodity: &str, value: rust_decimal::Decimal, format: &str) -> String {
let first_digit = format
.char_indices()
.find(|(_, c)| c.is_ascii_digit() || *c == '-')
.map(|(i, _)| i);
let last_digit = format
.char_indices()
.rfind(|(_, c)| c.is_ascii_digit())
.map(|(i, _)| i);
let (prefix, number_part, suffix) = match (first_digit, last_digit) {
(Some(s), Some(e)) => (&format[..s], &format[s..=e], &format[e + 1..]),
_ => return format!("{commodity} {value}"),
};
let (decimal_sep, thousand_sep, decimal_places) = detect_separators(number_part);
apply_format(
commodity,
value,
prefix,
suffix,
decimal_sep,
thousand_sep,
decimal_places,
)
}
fn detect_separators(number: &str) -> (Option<char>, Option<char>, usize) {
let last_dot = number.rfind('.');
let last_comma = number.rfind(',');
let (decimal_sep, decimal_places) = match (last_dot, last_comma) {
(Some(di), Some(ci)) if di > ci => {
let places = number.len() - di - 1;
(Some('.'), places)
}
(Some(di), Some(ci)) if ci > di => {
let places = number.len() - ci - 1;
(Some(','), places)
}
(Some(di), None) => {
let trailing = number.len() - di - 1;
if trailing == 3 {
(None, 0) } else {
(Some('.'), trailing)
}
}
(None, Some(ci)) => {
let trailing = number.len() - ci - 1;
if trailing == 3 {
(None, 0)
} else {
(Some(','), trailing)
}
}
_ => (None, 0),
};
let thousand_sep = match decimal_sep {
Some('.') if number.contains(',') => Some(','),
Some(',') if number.contains('.') => Some('.'),
None if number.contains(',') => Some(','),
None if number.contains('.') => Some('.'),
_ => None,
};
(decimal_sep, thousand_sep, decimal_places)
}
fn apply_format(
commodity: &str,
value: rust_decimal::Decimal,
prefix: &str,
suffix: &str,
decimal_sep: Option<char>,
thousand_sep: Option<char>,
decimal_places: usize,
) -> String {
use rust_decimal::prelude::ToPrimitive as _;
let scaled = value.round_dp(decimal_places as u32);
let is_neg = scaled.is_sign_negative();
let abs = scaled.abs();
let integer_part = abs.trunc().to_u64().unwrap_or(0);
let frac_str = if decimal_places > 0 {
let frac = abs.fract();
let multiplier = rust_decimal::Decimal::from(10u64.pow(decimal_places as u32));
let frac_digits = (frac * multiplier).to_u64().unwrap_or(0);
format!("{frac_digits:0>width$}", width = decimal_places)
} else {
String::new()
};
let int_str = if let Some(sep) = thousand_sep {
let s = integer_part.to_string();
let mut out = String::new();
for (i, ch) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
out.push(sep);
}
out.push(ch);
}
out.chars().rev().collect::<String>()
} else {
integer_part.to_string()
};
let number = if decimal_places > 0 {
format!("{int_str}{}{frac_str}", decimal_sep.unwrap_or('.'))
} else {
int_str
};
let sign = if is_neg { "-" } else { "" };
if !prefix.is_empty() || !suffix.is_empty() {
format!("{sign}{prefix}{number}{suffix}")
} else {
format!("{sign}{number} {commodity}")
}
}
fn display_amount(
commodity: &str,
value: rust_decimal::Decimal,
commodities: &std::collections::BTreeMap<String, doppio::elaboration::CommodityProperties>,
) -> String {
if let Some(fmt) = commodities.get(commodity).and_then(|p| p.format.as_deref()) {
format_amount(commodity, value, fmt)
} else {
format!("{commodity} {value}")
}
}
fn epoch_days_to_string(days: i32) -> String {
chrono::NaiveDate::from_ymd_opt(1970, 1, 1)
.and_then(|epoch| epoch.checked_add_signed(chrono::Duration::days(days as i64)))
.map(|d| d.to_string())
.unwrap_or_else(|| "????-??-??".to_string())
}
fn csv_field(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}