use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use rustledger_loader::{LoadOptions, Loader};
use rustledger_validate::ErrorCode;
use serde::Serialize;
use std::path::PathBuf;
use std::process::ExitCode;
const CLOSE_NONEMPTY_CODE: &str = ErrorCode::AccountCloseNotEmpty.code();
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub enum OutputFormat {
#[default]
Text,
Json,
}
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
pub struct Args {
#[arg(value_name = "FILE", required = true)]
pub files: Vec<PathBuf>,
#[arg(long, short = 'f', value_enum, default_value_t = OutputFormat::Text)]
pub format: OutputFormat,
}
#[derive(Serialize)]
struct Finding {
file: Option<String>,
line: Option<usize>,
message: String,
}
#[derive(Serialize)]
struct Report {
findings: Vec<Finding>,
}
pub fn run(args: &Args) -> Result<ExitCode> {
let mut stdout = std::io::stdout().lock();
run_with_writer(args, &mut stdout)
}
pub fn run_with_writer<W: std::io::Write>(args: &Args, out: &mut W) -> Result<ExitCode> {
let mut findings = Vec::new();
let options = LoadOptions {
run_plugins: true,
validate: true,
..Default::default()
};
for path in &args.files {
let load_result = Loader::new()
.load(path)
.with_context(|| format!("failed to load {}", path.display()))?;
if !load_result.errors.is_empty() {
let joined = load_result
.errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ");
anyhow::bail!(
"{}: cannot lint a file with load errors: {joined}",
path.display()
);
}
let ledger = rustledger_loader::process(load_result, &options)
.with_context(|| format!("failed to process {}", path.display()))?;
for err in &ledger.errors {
if err.code == CLOSE_NONEMPTY_CODE {
findings.push(Finding {
file: err.location.as_ref().map(|l| l.file.display().to_string()),
line: err.location.as_ref().map(|l| l.line),
message: err.message.clone(),
});
}
}
}
match args.format {
OutputFormat::Text => {
if findings.is_empty() {
writeln!(out, "No accounts closed with a non-zero balance.")?;
} else {
for f in &findings {
let loc = match (&f.file, f.line) {
(Some(file), Some(line)) => format!("{file}:{line}"),
(Some(file), None) => file.clone(),
_ => "<unknown>".to_string(),
};
writeln!(out, "{loc}: {}", f.message)?;
}
writeln!(
out,
"\n{} account(s) closed with a non-zero balance.",
findings.len()
)?;
}
}
OutputFormat::Json => {
let report = Report { findings };
writeln!(out, "{}", serde_json::to_string_pretty(&report)?)?;
}
}
Ok(ExitCode::SUCCESS)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
fn lint_text(content: &str) -> String {
let mut file = tempfile::Builder::new()
.suffix(".beancount")
.tempfile()
.unwrap();
file.write_all(content.as_bytes()).unwrap();
let args = Args {
files: vec![file.path().to_path_buf()],
format: OutputFormat::Text,
};
let mut buf: Vec<u8> = Vec::new();
run_with_writer(&args, &mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
#[test]
fn reports_account_closed_with_balance() {
let out = lint_text(concat!(
"2024-01-01 open Assets:Cash\n",
"2024-01-01 open Equity:Opening-Balances\n",
"2024-01-02 * \"deposit\"\n",
" Assets:Cash 100.00 USD\n",
" Equity:Opening-Balances\n",
"2024-12-31 close Assets:Cash\n",
));
assert!(
out.contains("Assets:Cash"),
"expected the closed account to be reported, got: {out}"
);
assert!(
out.contains("1 account(s)"),
"expected a one-account summary, got: {out}"
);
}
#[test]
fn silent_when_account_closed_empty() {
let out = lint_text(concat!(
"2024-01-01 open Assets:Cash\n",
"2024-01-01 open Equity:Opening-Balances\n",
"2024-01-02 * \"deposit\"\n",
" Assets:Cash 100.00 USD\n",
" Equity:Opening-Balances\n",
"2024-06-01 * \"withdraw all\"\n",
" Assets:Cash -100.00 USD\n",
" Equity:Opening-Balances\n",
"2024-12-31 close Assets:Cash\n",
));
assert!(
out.contains("No accounts closed with a non-zero balance"),
"expected no findings for a zero-balance close, got: {out}"
);
}
#[test]
fn errors_on_file_with_load_errors() {
let mut file = tempfile::Builder::new()
.suffix(".beancount")
.tempfile()
.unwrap();
file.write_all(b"include \"definitely-missing-xyz.beancount\"\n")
.unwrap();
let args = Args {
files: vec![file.path().to_path_buf()],
format: OutputFormat::Text,
};
let mut buf: Vec<u8> = Vec::new();
assert!(
run_with_writer(&args, &mut buf).is_err(),
"expected a hard error when the file has load errors"
);
}
}