use clap::{Args as ClapArgs, Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "acc",
version,
about = "plaintext double-entry accounting command line tool"
)]
struct Args {
#[arg(short = 'f', long = "file", value_name = "PATH")]
paths: Vec<String>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(ClapArgs, Clone, Debug)]
struct ReportArgs {
#[arg(long = "begin", short = 'b', conflicts_with = "periods")]
begin: Option<String>,
#[arg(long = "end", short = 'e', conflicts_with = "periods")]
end: Option<String>,
#[arg(long = "period", short = 'p', value_name = "PERIOD")]
periods: Vec<String>,
#[arg(long)]
future: bool,
#[arg(short = 'R', long = "real")]
real: bool,
#[arg(short = 'r', long = "related")]
related: bool,
#[arg(long = "related-all")]
related_all: bool,
#[arg(short = 'S', long = "sort", default_value = "date")]
sort: Vec<String>,
#[arg(short = 'X', long = "exchange", value_name = "COMMODITY")]
exchange: Option<String>,
#[arg(long = "commodities", value_name = "N", conflicts_with = "mixed")]
commodities: Option<usize>,
#[arg(long = "mixed")]
mixed: bool,
}
#[derive(Subcommand)]
enum Command {
#[command(visible_alias = "bal")]
Balance {
#[command(flatten)]
filter: ReportArgs,
#[arg(long, conflicts_with = "tree")]
flat: bool,
#[arg(long)]
tree: bool,
#[arg(short = 'E', long)]
empty: bool,
pattern: Vec<String>,
},
#[command(visible_alias = "reg")]
Register {
#[command(flatten)]
filter: ReportArgs,
pattern: Vec<String>,
},
Print {
#[command(flatten)]
filter: ReportArgs,
#[arg(long)]
raw: bool,
pattern: Vec<String>,
},
Accounts {
#[command(flatten)]
filter: ReportArgs,
#[arg(long)]
flat: bool,
#[arg(long)]
tree: bool,
pattern: Vec<String>,
},
Codes {
#[command(flatten)]
filter: ReportArgs,
pattern: Vec<String>,
},
Commodities {
#[command(flatten)]
filter: ReportArgs,
#[arg(long)]
date: bool,
pattern: Vec<String>,
},
#[command(visible_aliases = ["nav", "ui"])]
Navigate {
#[command(flatten)]
filter: ReportArgs,
#[arg(short = 'E', long)]
empty: bool,
pattern: Vec<String>,
},
Check,
#[command(arg_required_else_help = true)]
Format {
#[arg(long = "sort")]
sort: bool,
paths: Vec<String>,
},
#[command(arg_required_else_help = true)]
Diff {
#[arg(long = "snapshot", value_name = "DIR")]
snapshot: Option<String>,
paths: Vec<String>,
},
#[command(arg_required_else_help = true)]
Sweep {
account: String,
segment: String,
income: String,
expense: String,
},
Update {
#[arg(long = "pair")]
pairs: Vec<String>,
#[arg(long = "since", conflicts_with = "date")]
since: Option<String>,
#[arg(long = "date")]
date: Option<String>,
#[arg(long, conflicts_with_all = ["monthly", "yearly"])]
daily: bool,
#[arg(long, conflicts_with_all = ["daily", "yearly", "crypto", "pairs"])]
monthly: bool,
#[arg(long, conflicts_with_all = ["daily", "monthly", "crypto", "pairs"])]
yearly: bool,
#[arg(long, conflicts_with_all = ["crypto", "pairs"])]
skip: bool,
#[arg(long)]
crypto: bool,
#[arg(long)]
fiat: bool,
},
}
impl Command {
fn patterns(&self) -> &[String] {
match self {
Self::Balance { pattern, .. }
| Self::Register { pattern, .. }
| Self::Print { pattern, .. }
| Self::Accounts { pattern, .. }
| Self::Codes { pattern, .. }
| Self::Commodities { pattern, .. }
| Self::Navigate { pattern, .. } => pattern.as_slice(),
Self::Update { .. }
| Self::Check
| Self::Format { .. }
| Self::Diff { .. }
| Self::Sweep { .. } => &[],
}
}
fn filter(&self) -> Option<&ReportArgs> {
match self {
Self::Balance { filter, .. }
| Self::Register { filter, .. }
| Self::Print { filter, .. }
| Self::Accounts { filter, .. }
| Self::Codes { filter, .. }
| Self::Commodities { filter, .. }
| Self::Navigate { filter, .. } => Some(filter),
Self::Update { .. }
| Self::Check
| Self::Format { .. }
| Self::Diff { .. }
| Self::Sweep { .. } => None,
}
}
}
fn main() {
if let Err(e) = start() {
eprintln!("{}", e);
std::process::exit(1);
}
}
fn fail(msg: &str) -> ! {
eprintln!("Error: {}", msg);
std::process::exit(1);
}
fn expand_period(s: &str) -> Result<(String, String), String> {
let parts: Vec<&str> = s.split('-').collect();
match parts.as_slice() {
[y] => {
let year: i32 = y.parse().map_err(|_| format!("invalid year: `{}`", s))?;
Ok((format!("{:04}-01-01", year), format!("{:04}-01-01", year + 1)))
}
[y, m] => {
let year: i32 = y.parse().map_err(|_| format!("invalid year: `{}`", s))?;
let month: u32 = m.parse().map_err(|_| format!("invalid month: `{}`", s))?;
if !(1..=12).contains(&month) {
return Err(format!("month out of range: `{}`", s));
}
let (ny, nm) = if month == 12 { (year + 1, 1) } else { (year, month + 1) };
Ok((
format!("{:04}-{:02}-01", year, month),
format!("{:04}-{:02}-01", ny, nm),
))
}
[_, _, _] => {
let date = acc::date::Date::parse(s).map_err(|e| e.to_string())?;
let next = acc::date::Date::from_days(date.days() + 1);
Ok((date.to_string(), next.to_string()))
}
_ => Err(format!("invalid period: `{}` (expected YYYY, YYYY-MM, or YYYY-MM-DD)", s)),
}
}
fn collect_ledger_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
if let Ok(entries) = std::fs::read_dir(dir) {
let mut paths: Vec<_> = entries.filter_map(|e| e.ok().map(|e| e.path())).collect();
paths.sort();
for path in paths {
if path.is_dir() {
collect_ledger_files(&path, out);
} else if path.is_file() && acc::is_journal_file(&path) {
out.push(path);
}
}
}
}
fn distinct_commodities(tx: &acc::parser::transaction::Transaction) -> usize {
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
for lp in &tx.postings {
if lp.value.is_virtual && !lp.value.balanced {
continue;
}
if let Some(a) = &lp.value.amount {
seen.insert(a.commodity.as_str());
}
}
seen.len()
}
fn split_file_args() -> (Vec<String>, Vec<String>) {
let mut iter = std::env::args();
let mut rest = vec![iter.next().unwrap()];
let mut files = Vec::new();
while let Some(a) = iter.next() {
match a.as_str() {
"-f" | "--file" => {
if let Some(v) = iter.next() {
files.push(v);
}
}
_ => rest.push(a),
}
}
(files, rest)
}
fn try_standalone(
command: &Command,
paths: &[String],
) -> Option<Result<(), acc::Error>> {
match command {
Command::Format { sort, paths } => Some(acc::commands::format::run(paths, *sort)),
Command::Diff { snapshot, paths } => {
if snapshot.is_none() && paths.len() != 2 {
use clap::CommandFactory;
let mut cmd = Args::command();
cmd.find_subcommand_mut("diff")
.unwrap()
.error(
clap::error::ErrorKind::WrongNumberOfValues,
format!(
"expected 2 paths (OLD NEW) without --snapshot, got {}",
paths.len()
),
)
.exit();
}
Some(acc::commands::diff::run(snapshot.as_deref(), paths))
}
Command::Update {
pairs,
since,
date,
monthly,
yearly,
skip,
crypto,
fiat,
..
} => {
let flags = if *crypto || *fiat {
acc::commands::update::UpdateFlags { crypto: *crypto, fiat: *fiat }
} else if !pairs.is_empty() {
acc::commands::update::UpdateFlags { crypto: true, fiat: false }
} else {
acc::commands::update::UpdateFlags { crypto: true, fiat: true }
};
let cadence = if *yearly {
acc::commands::update::Cadence::Yearly
} else if *monthly {
acc::commands::update::Cadence::Monthly
} else {
acc::commands::update::Cadence::Daily
};
Some(acc::commands::update::run(
pairs,
since.as_deref(),
date.as_deref(),
cadence,
*skip,
flags,
))
}
Command::Sweep { account, segment, income, expense } => {
if paths.is_empty() {
eprintln!("Error: No files specified. Use -f PATH.");
std::process::exit(1);
}
let mut sweep_paths: Vec<std::path::PathBuf> = Vec::new();
for input in paths {
let path = std::path::Path::new(input);
if path.is_dir() {
collect_ledger_files(path, &mut sweep_paths);
} else {
sweep_paths.push(path.to_path_buf());
}
}
Some(
acc::load(&sweep_paths)
.map_err(|e| acc::Error::from(e.to_string()))
.and_then(|j| acc::commands::sweep::run(j, account, segment, income, expense)),
)
}
_ => None,
}
}
fn resolve_date_range(
filter_args: Option<&ReportArgs>,
) -> (Vec<(String, String)>, Option<String>, Option<String>) {
let period_ranges: Vec<(String, String)> = filter_args
.map(|f| f.periods.as_slice())
.unwrap_or(&[])
.iter()
.map(|p| match expand_period(p) {
Ok(pair) => pair,
Err(e) => fail(&e),
})
.collect();
let (period_begin, period_end) = match period_ranges.len() {
1 => (
Some(period_ranges[0].0.clone()),
Some(period_ranges[0].1.clone()),
),
_ => (None, None),
};
let explicit_begin = filter_args
.and_then(|f| f.begin.as_deref())
.map(|b| match expand_period(b) {
Ok((start, _)) => start,
Err(e) => fail(&e),
});
let explicit_end = filter_args
.and_then(|f| f.end.as_deref())
.map(|e| match expand_period(e) {
Ok((start, _)) => start,
Err(e) => fail(&e),
});
let begin = explicit_begin.or(period_begin);
let user_end = explicit_end.or(period_end);
let show_future = filter_args.map(|f| f.future).unwrap_or(false);
let future_cap: Option<String> = (!show_future).then(|| {
let today_str = acc::date::ms_to_date(acc::date::current_ms());
let today = acc::date::Date::parse(&today_str)
.expect("current_ms() returns valid YYYY-MM-DD");
acc::date::Date::from_days(today.days() + 1).to_string()
});
let end = match (user_end, future_cap) {
(Some(u), Some(cap)) => Some(if u < cap { u } else { cap }),
(Some(u), None) => Some(u),
(None, Some(cap)) => Some(cap),
(None, None) => None,
};
(period_ranges, begin, end)
}
fn start() -> Result<(), acc::Error> {
let (files, argv) = split_file_args();
let mut args = Args::parse_from(argv);
args.paths = files;
let Some(command) = args.command else {
use clap::CommandFactory;
let _ = Args::command().print_help();
return Ok(());
};
if let Some(result) = try_standalone(&command, &args.paths) {
return result;
}
if args.paths.is_empty() {
eprintln!("Error: No files specified. Use -f PATH.");
std::process::exit(1);
}
let filter_args: Option<&ReportArgs> = command.filter();
let mut paths: Vec<std::path::PathBuf> = Vec::new();
if filter_args.map(|f| f.exchange.is_some()).unwrap_or(false) {
if let Ok(dir) = std::env::var("ACC_PRICES_DIR") {
let path = std::path::Path::new(&dir);
if path.is_dir() {
collect_ledger_files(path, &mut paths);
}
}
}
for input in &args.paths {
let path = std::path::Path::new(input);
if path.is_dir() {
collect_ledger_files(path, &mut paths);
} else {
paths.push(path.to_path_buf());
}
}
if let Command::Print { raw: true, .. } = &command {
for path in &paths {
let source = if path.to_str() == Some("-") {
use std::io::Read as _;
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
s
} else {
std::fs::read_to_string(path)?
};
print!("{}", source);
}
return Ok(());
}
let mut journal = acc::load(&paths).map_err(|e| acc::Error::from(e.to_string()))?;
let exchange_target: Option<String> = filter_args
.and_then(|f| f.exchange.as_deref())
.map(|t| {
journal
.aliases
.get(t)
.cloned()
.unwrap_or_else(|| t.to_string())
});
acc::pipeline::enrich(&mut journal, exchange_target.as_deref());
let (period_ranges, begin_owned, end_owned) = resolve_date_range(filter_args);
let begin = begin_owned.as_deref();
let end = end_owned.as_deref();
let related = filter_args.map(|f| f.related).unwrap_or(false);
let related_all = filter_args.map(|f| f.related_all).unwrap_or(false);
let whole_transactions = related_all || matches!(command, Command::Print { .. });
let mut journal = acc::filter::filter(
journal,
command.patterns(),
begin,
end,
related,
whole_transactions,
);
if period_ranges.len() > 1 {
let parsed: Vec<(acc::date::Date, acc::date::Date)> = period_ranges
.iter()
.filter_map(|(b, e)| {
let b = acc::date::Date::parse(b).ok()?;
let e = acc::date::Date::parse(e).ok()?;
Some((b, e))
})
.collect();
journal.transactions.retain(|lt| {
parsed.iter().any(|(b, e)| lt.value.date >= *b && lt.value.date < *e)
});
}
let min_commodities: Option<usize> = filter_args.and_then(|f| {
if f.mixed { Some(2) } else { f.commodities }
});
if let Some(min) = min_commodities {
journal
.transactions
.retain(|lt| distinct_commodities(<.value) >= min);
}
if let Some(target) = exchange_target.as_deref() {
acc::rebalancer::rebalance(&mut journal.transactions, target, &journal.prices);
}
let real = filter_args.map(|f| f.real).unwrap_or(false);
if real {
for lt in &mut journal.transactions {
lt.value.postings.retain(|lp| !lp.value.is_virtual);
}
journal.transactions.retain(|lt| !lt.value.postings.is_empty());
}
let default_sort = [String::from("date")];
let sort_keys: &[String] = filter_args
.map(|f| f.sort.as_slice())
.unwrap_or(&default_sort);
acc::sorter::sort(&mut journal.transactions, sort_keys);
match command {
Command::Balance { flat, empty, .. } => {
acc::commands::balance::run(&journal, !flat, empty);
}
Command::Register { .. } => acc::commands::register::run(&journal),
Command::Print { raw: false, .. } => {
if let Some(target) = exchange_target.as_deref() {
acc::rebalancer::round_for_print(
&mut journal.transactions,
target,
&journal.precisions,
);
}
acc::commands::print::run(&journal);
}
Command::Accounts { tree, .. } => acc::commands::accounts::run(&journal, tree),
Command::Codes { .. } => acc::commands::codes::run(&journal),
Command::Commodities { date, .. } => acc::commands::commodities::run(&journal, date),
Command::Navigate { empty, .. } => {
if let Err(e) = acc::commands::navigate::run(&journal, empty) {
eprintln!("navigate: {}", e);
}
}
Command::Check => acc::commands::checker::run(&journal),
_ => eprintln!("internal error: unexpected command reached match arm"),
}
Ok(())
}