use anyhow::{Context, Result, anyhow};
use clap::{Parser, ValueEnum};
use rust_decimal::Decimal;
use rustledger_loader::Loader;
use rustledger_ops::transfer::{
LocatedDirective, TransferConfig, TransferMatch, find_transfers_in_ledger,
};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::PathBuf;
use std::process::ExitCode;
use std::str::FromStr;
#[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, default_value_t = 0.8)]
pub min_confidence: f64,
#[arg(long, default_value_t = 3)]
pub date_window: i64,
#[arg(long, default_value = "0.01")]
pub amount_tolerance: String,
#[arg(long)]
pub apply: bool,
#[arg(long, short = 'f', value_enum, default_value_t = OutputFormat::Text)]
pub format: OutputFormat,
}
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 tolerance = Decimal::from_str(&args.amount_tolerance)
.with_context(|| format!("invalid --amount-tolerance: {}", args.amount_tolerance))?;
let config = TransferConfig {
date_window_days: args.date_window,
amount_tolerance: tolerance,
};
let mut loaded = Vec::with_capacity(args.files.len());
for path in &args.files {
let resolved = canonicalize_for_report(path)?;
let mut loader = Loader::new();
let result = loader
.load(path)
.with_context(|| format!("failed to load {}", path.display()))?;
loaded.push((resolved, result));
}
let located: Vec<LocatedDirective<'_>> = loaded
.iter()
.flat_map(|(resolved, result)| {
result.directives.iter().map(move |spanned| {
let (filename, lineno) =
if let Some(file) = result.source_map.get(spanned.file_id as usize) {
let (line, _col) = file.line_col(spanned.span.start);
(
Some(file.path.to_string_lossy().into_owned()),
u32::try_from(line).ok(),
)
} else {
(Some(resolved.clone()), None)
};
LocatedDirective {
directive: &spanned.value,
filename,
lineno,
}
})
})
.collect();
let all_matches = find_transfers_in_ledger(&located, &config);
let matches: Vec<TransferMatch> = all_matches
.into_iter()
.filter(|m| m.confidence >= args.min_confidence)
.collect();
if args.apply {
apply_links(&matches)?;
}
match args.format {
OutputFormat::Text => print_text_report(&matches, args.apply, out)?,
OutputFormat::Json => print_json_report(&matches, args.apply, out)?,
}
Ok(ExitCode::SUCCESS)
}
fn canonicalize_for_report(path: &std::path::Path) -> Result<String> {
if let Ok(canon) = path.canonicalize() {
Ok(canon.to_string_lossy().into_owned())
} else {
Ok(path.to_string_lossy().into_owned())
}
}
fn link_name_for(m: &TransferMatch) -> String {
let date_compact = m.date.replace('-', "");
let mut h = Sha256::new();
h.update(m.from_filename.as_deref().unwrap_or("").as_bytes());
h.update(b"\0");
h.update(m.from_lineno.unwrap_or(0).to_le_bytes());
h.update(b"\0");
h.update(m.to_filename.as_deref().unwrap_or("").as_bytes());
h.update(b"\0");
h.update(m.to_lineno.unwrap_or(0).to_le_bytes());
h.update(b"\0");
h.update(m.amount.to_string().as_bytes());
h.update(b"\0");
h.update(m.currency.as_bytes());
let digest = h.finalize();
let mut suffix = String::with_capacity(6);
for b in digest.iter().take(3) {
write!(suffix, "{b:02x}").expect("writing to String never fails");
}
format!("xfer-{date_compact}-{suffix}")
}
fn apply_links(matches: &[TransferMatch]) -> Result<()> {
let mut edits: BTreeMap<String, BTreeMap<u32, String>> = BTreeMap::new();
for m in matches {
let name = link_name_for(m);
for (file, line) in [
(m.from_filename.as_deref(), m.from_lineno),
(m.to_filename.as_deref(), m.to_lineno),
] {
let (Some(file), Some(line)) = (file, line) else {
continue;
};
edits
.entry(file.to_string())
.or_default()
.insert(line, name.clone());
}
}
for (file, line_edits) in edits {
apply_file_edits(&file, &line_edits)?;
}
Ok(())
}
fn apply_file_edits(path: &str, edits: &BTreeMap<u32, String>) -> Result<()> {
let original = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {path} for --apply"))?;
let mut lines: Vec<String> = original.split_inclusive('\n').map(String::from).collect();
for (&lineno, link_name) in edits {
let idx = lineno
.checked_sub(1)
.ok_or_else(|| anyhow!("invalid 0 lineno in {path}"))? as usize;
let Some(line) = lines.get_mut(idx) else {
return Err(anyhow!(
"line {lineno} out of range in {path} ({} total)",
lines.len()
));
};
*line = insert_link_into_header(line, link_name);
}
let new = lines.concat();
if new != original {
std::fs::write(path, new).with_context(|| format!("failed to write {path}"))?;
}
Ok(())
}
fn insert_link_into_header(line: &str, link_name: &str) -> String {
let needle = format!("^{link_name}");
let (body, term) = split_terminator(line);
if body.split_whitespace().any(|tok| tok == needle) {
return line.to_string();
}
let trimmed = body.trim_end_matches([' ', '\t']);
format!("{trimmed} {needle}{term}")
}
fn split_terminator(line: &str) -> (&str, &str) {
if let Some(body) = line.strip_suffix("\r\n") {
(body, "\r\n")
} else if let Some(body) = line.strip_suffix('\n') {
(body, "\n")
} else {
(line, "")
}
}
#[derive(Serialize)]
struct JsonMatch<'a> {
confidence: f64,
date: &'a str,
amount: String,
currency: &'a str,
from: JsonSide<'a>,
to: JsonSide<'a>,
link_name: String,
}
#[derive(Serialize)]
struct JsonSide<'a> {
account: Option<&'a str>,
filename: Option<&'a str>,
lineno: Option<u32>,
}
#[derive(Serialize)]
struct JsonReport<'a> {
matches: Vec<JsonMatch<'a>>,
applied: bool,
}
fn print_json_report<W: std::io::Write>(
matches: &[TransferMatch],
applied: bool,
out: &mut W,
) -> Result<()> {
let report = JsonReport {
matches: matches
.iter()
.map(|m| JsonMatch {
confidence: round_to(m.confidence, 3),
date: &m.date,
amount: m.amount.to_string(),
currency: &m.currency,
from: JsonSide {
account: m.from_account.as_deref(),
filename: m.from_filename.as_deref(),
lineno: m.from_lineno,
},
to: JsonSide {
account: m.to_account.as_deref(),
filename: m.to_filename.as_deref(),
lineno: m.to_lineno,
},
link_name: link_name_for(m),
})
.collect(),
applied,
};
serde_json::to_writer_pretty(&mut *out, &report).context("write JSON report")?;
writeln!(out).ok();
Ok(())
}
fn print_text_report<W: std::io::Write>(
matches: &[TransferMatch],
applied: bool,
out: &mut W,
) -> Result<()> {
if matches.is_empty() {
writeln!(out, "No transfer pairs detected.").ok();
return Ok(());
}
writeln!(
out,
"{} likely transfer{} detected:\n",
matches.len(),
if matches.len() == 1 { "" } else { "s" }
)
.ok();
for m in matches {
let link = link_name_for(m);
let from_acct = m.from_account.as_deref().unwrap_or("?");
let to_acct = m.to_account.as_deref().unwrap_or("?");
writeln!(
out,
" {} {} {} → {} confidence {:.2}",
m.amount, m.currency, from_acct, to_acct, m.confidence
)
.ok();
writeln!(
out,
" from: {}:{} {}",
m.from_filename.as_deref().unwrap_or("?"),
m.from_lineno.map_or_else(|| "?".into(), |n| n.to_string()),
m.date,
)
.ok();
writeln!(
out,
" to: {}:{}",
m.to_filename.as_deref().unwrap_or("?"),
m.to_lineno.map_or_else(|| "?".into(), |n| n.to_string()),
)
.ok();
writeln!(
out,
" {} ^{link}",
if applied { "applied:" } else { "would add:" }
)
.ok();
writeln!(out).ok();
}
if !applied {
writeln!(out, "Run with --apply to write these links.").ok();
}
Ok(())
}
fn round_to(x: f64, places: u32) -> f64 {
let mult = 10f64.powi(places as i32);
(x * mult).round() / mult
}
#[cfg(test)]
mod tests {
use super::*;
use rustledger_plugin::types::{AmountData, DirectiveData, PostingData, TransactionData};
fn match_with(
date: &str,
from_file: &str,
from_line: u32,
to_file: &str,
to_line: u32,
) -> TransferMatch {
TransferMatch {
from_group: 0,
from_index: 0,
from_account: Some("Assets:Checking".into()),
from_filename: Some(from_file.into()),
from_lineno: Some(from_line),
to_group: 1,
to_index: 0,
to_account: Some("Assets:Savings".into()),
to_filename: Some(to_file.into()),
to_lineno: Some(to_line),
amount: Decimal::new(50000, 2),
currency: "USD".into(),
confidence: 0.95,
date: date.into(),
}
}
#[test]
fn link_name_is_deterministic() {
let m = match_with("2024-01-15", "a.bean", 10, "b.bean", 20);
let n1 = link_name_for(&m);
let n2 = link_name_for(&m);
assert_eq!(n1, n2, "same match must produce same link name");
assert!(n1.starts_with("xfer-20240115-"));
assert_eq!(n1.len(), "xfer-YYYYMMDD-XXXXXX".len());
}
#[test]
fn link_name_differs_for_different_pairs() {
let m1 = match_with("2024-01-15", "a.bean", 10, "b.bean", 20);
let m2 = match_with("2024-01-15", "a.bean", 11, "b.bean", 20);
assert_ne!(link_name_for(&m1), link_name_for(&m2));
}
#[test]
fn insert_link_appends_to_header_preserving_newline() {
let line = "2024-01-15 * \"Transfer\"\n";
let out = insert_link_into_header(line, "xfer-20240115-abcdef");
assert_eq!(out, "2024-01-15 * \"Transfer\" ^xfer-20240115-abcdef\n");
}
#[test]
fn insert_link_handles_crlf() {
let line = "2024-01-15 * \"Transfer\"\r\n";
let out = insert_link_into_header(line, "xfer-20240115-abcdef");
assert_eq!(out, "2024-01-15 * \"Transfer\" ^xfer-20240115-abcdef\r\n");
}
#[test]
fn insert_link_strips_trailing_whitespace_before_appending() {
let line = "2024-01-15 * \"Transfer\" \n";
let out = insert_link_into_header(line, "xfer-20240115-abcdef");
assert_eq!(out, "2024-01-15 * \"Transfer\" ^xfer-20240115-abcdef\n");
}
#[test]
fn insert_link_is_idempotent_when_already_present() {
let line = "2024-01-15 * \"Transfer\" ^xfer-20240115-abcdef\n";
let out = insert_link_into_header(line, "xfer-20240115-abcdef");
assert_eq!(out, line, "already-present link must not be duplicated");
}
#[test]
fn insert_link_adds_alongside_existing_unrelated_link() {
let line = "2024-01-15 * \"Transfer\" ^batch-import-A\n";
let out = insert_link_into_header(line, "xfer-20240115-abcdef");
assert_eq!(
out,
"2024-01-15 * \"Transfer\" ^batch-import-A ^xfer-20240115-abcdef\n"
);
}
#[test]
fn insert_link_handles_no_terminator() {
let line = "2024-01-15 * \"Transfer\"";
let out = insert_link_into_header(line, "xfer-20240115-abcdef");
assert_eq!(out, "2024-01-15 * \"Transfer\" ^xfer-20240115-abcdef");
}
#[test]
fn end_to_end_apply_writes_and_is_idempotent() -> Result<()> {
let dir = tempfile::tempdir()?;
let checking = dir.path().join("checking.bean");
let savings = dir.path().join("savings.bean");
std::fs::write(
&checking,
"2024-01-01 open Assets:Checking USD\n\
\n\
2024-01-15 * \"Transfer to savings\"\n \
Assets:Checking -500.00 USD\n \
Assets:Savings 500.00 USD\n",
)?;
std::fs::write(
&savings,
"2024-01-01 open Assets:Savings USD\n\
\n\
2024-01-15 * \"Transfer from checking\"\n \
Assets:Savings 500.00 USD\n \
Assets:Checking -500.00 USD\n",
)?;
let args = Args {
files: vec![checking.clone(), savings.clone()],
min_confidence: 0.8,
date_window: 3,
amount_tolerance: "0.01".to_string(),
apply: true,
format: OutputFormat::Json,
};
let _ = run(&args)?;
let after_checking = std::fs::read_to_string(&checking)?;
let after_savings = std::fs::read_to_string(&savings)?;
assert!(
after_checking.contains("^xfer-20240115-"),
"checking file should have a link: {after_checking}"
);
assert!(
after_savings.contains("^xfer-20240115-"),
"savings file should have a link: {after_savings}"
);
let _ = run(&args)?;
let final_checking = std::fs::read_to_string(&checking)?;
let final_savings = std::fs::read_to_string(&savings)?;
assert_eq!(
after_checking, final_checking,
"second --apply run must not modify the checking file"
);
assert_eq!(
after_savings, final_savings,
"second --apply run must not modify the savings file"
);
Ok(())
}
#[test]
fn min_confidence_filters_low_confidence_matches() -> Result<()> {
let dir = tempfile::tempdir()?;
let a = dir.path().join("a.bean");
let b = dir.path().join("b.bean");
std::fs::write(
&a,
"2024-01-01 open Assets:A USD\n\
2024-01-15 * \"Something\"\n \
Assets:A -100.00 USD\n \
Assets:Other 100.00 USD\n",
)?;
std::fs::write(
&b,
"2024-01-01 open Assets:B USD\n\
2024-01-17 * \"Something else\"\n \
Assets:B 100.00 USD\n \
Assets:Other -100.00 USD\n",
)?;
let args = Args {
files: vec![a.clone(), b],
min_confidence: 0.8,
date_window: 3,
amount_tolerance: "0.01".to_string(),
apply: false,
format: OutputFormat::Json,
};
let apply_args = Args {
apply: true,
..clone_args(&args)
};
let _ = run(&apply_args)?;
let after_a = std::fs::read_to_string(&a)?;
assert!(
!after_a.contains("^xfer-"),
"0.7-confidence match must be filtered by default min_confidence 0.8; got {after_a}"
);
let permissive = Args {
min_confidence: 0.7,
apply: true,
..clone_args(&args)
};
let _ = run(&permissive)?;
let after_a2 = std::fs::read_to_string(&a)?;
assert!(
after_a2.contains("^xfer-"),
"with min_confidence 0.7 the 0.7-confidence match should be applied; got {after_a2}"
);
Ok(())
}
fn clone_args(a: &Args) -> Args {
Args {
files: a.files.clone(),
min_confidence: a.min_confidence,
date_window: a.date_window,
amount_tolerance: a.amount_tolerance.clone(),
apply: a.apply,
format: a.format,
}
}
#[test]
fn no_match_within_same_account() -> Result<()> {
let dir = tempfile::tempdir()?;
let f = dir.path().join("single.bean");
std::fs::write(
&f,
"2024-01-01 open Assets:Single USD\n\
2024-01-15 * \"Out\"\n \
Assets:Single -500.00 USD\n \
Equity:Misc 500.00 USD\n\
2024-01-15 * \"In\"\n \
Assets:Single 500.00 USD\n \
Equity:Misc -500.00 USD\n",
)?;
let args = Args {
files: vec![f.clone()],
min_confidence: 0.7, date_window: 3,
amount_tolerance: "0.01".to_string(),
apply: true,
format: OutputFormat::Json,
};
let _ = run(&args)?;
let after = std::fs::read_to_string(&f)?;
assert!(
!after.contains("^xfer-"),
"same-account transactions must not be paired"
);
Ok(())
}
#[allow(dead_code)]
fn _unused_imports() {
let _: DirectiveData;
let _: TransactionData;
let _: PostingData;
let _: AmountData;
}
}