pub(crate) mod parsing;
use anyhow::{Context, Result, bail};
use clap::Parser;
use parsing::{calculate_balance, parse_amount, parse_date};
use rustledger_core::NaiveDate;
use rustledger_core::format::FormatConfig;
use rustledger_core::{Amount, Directive, Posting, Transaction};
use rustledger_parser::format::canonicalize_directives;
use rustledger_parser::parse;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::{CmdKind, Highlighter};
use rustyline::hint::Hinter;
use rustyline::history::DefaultHistory;
use rustyline::validate::Validator;
use rustyline::{Editor, Helper};
use std::borrow::Cow;
use std::fs::{self, File, OpenOptions};
use std::io::{Read as IoRead, Seek, SeekFrom, Write};
use std::path::PathBuf;
#[cfg(test)]
use {rust_decimal::Decimal, std::str::FromStr};
#[derive(Parser, Debug)]
#[command(name = "add")]
pub struct Args {
#[arg(value_name = "FILE")]
pub file: Option<PathBuf>,
#[arg(short, long)]
pub date: Option<String>,
#[arg(short = 'n', long)]
pub dry_run: bool,
#[arg(short, long)]
pub yes: bool,
#[arg(short, long, num_args = 4.., value_name = "ARGS")]
pub quick: Option<Vec<String>>,
#[arg(long)]
pub no_completion: bool,
}
struct AccountCompleter {
accounts: Vec<String>,
}
impl AccountCompleter {
const fn new(accounts: Vec<String>) -> Self {
Self { accounts }
}
const fn empty() -> Self {
Self {
accounts: Vec::new(),
}
}
}
impl Completer for AccountCompleter {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &rustyline::Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let prefix = &line[..pos];
let mut matches: Vec<Pair> = self
.accounts
.iter()
.filter(|a| a.starts_with(prefix) || a.to_lowercase().contains(&prefix.to_lowercase()))
.map(|a| Pair {
display: a.clone(),
replacement: a.clone(),
})
.collect();
matches.sort_by(|a, b| {
let a_prefix = a.replacement.starts_with(prefix);
let b_prefix = b.replacement.starts_with(prefix);
match (a_prefix, b_prefix) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.replacement.cmp(&b.replacement),
}
});
Ok((0, matches))
}
}
struct AddHelper {
completer: AccountCompleter,
}
impl AddHelper {
const fn new(completer: AccountCompleter) -> Self {
Self { completer }
}
}
impl Helper for AddHelper {}
impl Completer for AddHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
ctx: &rustyline::Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
self.completer.complete(line, pos, ctx)
}
}
impl Hinter for AddHelper {
type Hint = String;
fn hint(&self, _line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option<String> {
None
}
}
impl Highlighter for AddHelper {
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
Cow::Borrowed(line)
}
fn highlight_char(&self, _line: &str, _pos: usize, _kind: CmdKind) -> bool {
false
}
}
impl Validator for AddHelper {}
fn canonical_format_directive(directive: &Directive, config: &FormatConfig) -> Result<String> {
canonicalize_directives(std::iter::once(directive), config)
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
fn run_quick_mode(args: &Args, file: &PathBuf, date: NaiveDate) -> Result<()> {
let mut stdout = std::io::stdout().lock();
run_quick_mode_with_writer(args, file, date, &mut stdout)
}
pub fn run_quick_with_writer<W: std::io::Write>(
args: &Args,
file: &PathBuf,
out: &mut W,
) -> Result<()> {
if !args.yes && !args.dry_run {
bail!(
"ag-rledger add requires --yes (to append) or --dry-run (to preview); \
the agent path never prompts interactively"
);
}
let date = if let Some(ref d) = args.date {
parse_date(d)?
} else {
jiff::Zoned::now().date()
};
run_quick_mode_with_writer(args, file, date, out)
}
fn run_quick_mode_with_writer<W: std::io::Write>(
args: &Args,
file: &PathBuf,
date: NaiveDate,
out: &mut W,
) -> Result<()> {
let quick_args = args.quick.as_ref().expect("quick mode args");
if quick_args.len() < 4 {
bail!("Quick mode requires at least: payee narration account amount");
}
let payee = &quick_args[0];
let narration = &quick_args[1];
let mut postings: Vec<Posting> = Vec::new();
let mut amounts: Vec<Amount> = Vec::new();
let mut i = 2;
while i < quick_args.len() {
let account = &quick_args[i];
i += 1;
if i < quick_args.len() {
if let Ok(amount) = parse_amount(&quick_args[i]) {
postings.push(Posting::new(account.as_str(), amount.clone()));
amounts.push(amount);
i += 1;
} else {
postings.push(Posting::auto(account.as_str()));
}
} else {
postings.push(Posting::auto(account.as_str()));
}
}
if postings.len() < 2 {
bail!(
"Quick mode requires at least two postings (accounts), but only {} provided.",
postings.len()
);
}
let missing_units: Vec<usize> = postings
.iter()
.enumerate()
.filter(|(_, p)| !p.has_units())
.map(|(idx, _)| idx)
.collect();
if missing_units.len() > 1 {
bail!(
"Quick mode supports at most one posting without an explicit amount, \
but {} postings lack amounts.",
missing_units.len()
);
}
if let Some(&idx) = missing_units.first()
&& idx != postings.len() - 1
{
bail!(
"A posting without an amount must be the last posting, \
but posting {} (of {}) lacks an amount.",
idx + 1,
postings.len()
);
}
if let Some(last) = postings.last_mut()
&& !last.has_units()
{
if amounts.is_empty() {
bail!("Cannot auto-balance: no explicit amounts were provided for any posting.");
}
let balance = calculate_balance(&amounts)?;
*last = Posting::new(last.account.as_str(), balance);
}
let mut txn = Transaction::new(date, narration.as_str()).with_flag('*');
if !payee.is_empty() {
txn = txn.with_payee(payee.as_str());
}
for posting in postings {
txn = txn.with_synthesized_posting(posting);
}
let config = FormatConfig::default();
let directive = Directive::Transaction(txn);
let formatted = canonical_format_directive(&directive, &config)?;
if args.dry_run {
writeln!(out, "{formatted}")?;
return Ok(());
}
if !args.yes {
writeln!(out, "Preview:")?;
writeln!(out, "{formatted}")?;
write!(out, "Append to {}? [Y/n] ", file.display())?;
out.flush()?;
let mut response = String::new();
std::io::stdin().read_line(&mut response)?;
let response = response.trim().to_lowercase();
if !response.is_empty() && response != "y" && response != "yes" {
writeln!(out, "Cancelled.")?;
return Ok(());
}
}
append_transaction(file, &formatted)?;
writeln!(out, "Transaction appended to {}", file.display())?;
Ok(())
}
fn extract_accounts(file: &PathBuf) -> Vec<String> {
if !file.exists() {
return Vec::new();
}
let content = match fs::read_to_string(file) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let parse_result = parse(&content);
let mut accounts = Vec::new();
for spanned in &parse_result.directives {
match &spanned.value {
Directive::Open(open) => {
accounts.push(open.account.to_string());
}
Directive::Close(close) => {
accounts.push(close.account.to_string());
}
Directive::Balance(bal) => {
accounts.push(bal.account.to_string());
}
Directive::Pad(pad) => {
accounts.push(pad.account.to_string());
accounts.push(pad.source_account.to_string());
}
Directive::Transaction(txn) => {
for posting in &txn.postings {
accounts.push(posting.account.to_string());
}
}
_ => {}
}
}
accounts.sort();
accounts.dedup();
accounts
}
fn prompt_with_default(
rl: &mut Editor<AddHelper, DefaultHistory>,
prompt: &str,
default: &str,
) -> Result<String> {
let full_prompt = if default.is_empty() {
format!("{prompt}: ")
} else {
format!("{prompt} [{default}]: ")
};
match rl.readline(&full_prompt) {
Ok(line) => {
let trimmed = line.trim();
if trimmed.is_empty() {
Ok(default.to_string())
} else {
Ok(trimmed.to_string())
}
}
Err(ReadlineError::Interrupted | ReadlineError::Eof) => {
bail!("Cancelled.");
}
Err(e) => Err(e.into()),
}
}
fn run_interactive_mode(args: &Args, file: &PathBuf, date: NaiveDate) -> Result<()> {
let completer = if args.no_completion {
AccountCompleter::empty()
} else {
let accounts = extract_accounts(file);
AccountCompleter::new(accounts)
};
let helper = AddHelper::new(completer);
let mut rl: Editor<AddHelper, DefaultHistory> = Editor::new()?;
rl.set_helper(Some(helper));
let date_default = date.to_string();
let date_input = prompt_with_default(&mut rl, "Date", &date_default)?;
let transaction_date = parse_date(&date_input)?;
let payee = prompt_with_default(&mut rl, "Payee", "")?;
let narration = prompt_with_default(&mut rl, "Narration", "")?;
let mut postings: Vec<Posting> = Vec::new();
let mut amounts: Vec<Amount> = Vec::new();
let mut posting_num = 1;
loop {
let account_prompt = format!("Account {posting_num}");
let account = match rl.readline(&format!("{account_prompt}: ")) {
Ok(line) => {
let trimmed = line.trim();
if trimmed.is_empty() {
if posting_num == 1 {
bail!("At least one account is required.");
}
break;
}
trimmed.to_string()
}
Err(ReadlineError::Interrupted | ReadlineError::Eof) => {
if posting_num == 1 {
bail!("Cancelled.");
}
break;
}
Err(e) => return Err(e.into()),
};
let balance_hint = if amounts.is_empty() {
String::new()
} else {
match calculate_balance(&amounts) {
Ok(bal) => format!("auto: {} {}", bal.number, bal.currency),
Err(_) => String::new(),
}
};
let amount_prompt = format!("Amount {posting_num}");
let amount_default = if posting_num > 1 && !balance_hint.is_empty() {
balance_hint
} else {
String::new()
};
let amount_input = prompt_with_default(&mut rl, &amount_prompt, &amount_default)?;
if amount_input.is_empty() || amount_input == "none" {
postings.push(Posting::auto(&account));
} else if amount_input.starts_with("auto:") {
let balance = calculate_balance(&amounts)?;
postings.push(Posting::new(&account, balance));
} else {
let amount = parse_amount(&amount_input)?;
postings.push(Posting::new(&account, amount.clone()));
amounts.push(amount);
}
posting_num += 1;
if posting_num > 2 {
let more = prompt_with_default(&mut rl, "Add another posting?", "n")?;
if more.to_lowercase() != "y" && more.to_lowercase() != "yes" {
break;
}
}
}
if postings.len() < 2 {
bail!(
"At least two postings are required for a balanced transaction, but only {} provided.",
postings.len()
);
}
if let Some(last) = postings.last_mut()
&& !last.has_units()
&& !amounts.is_empty()
{
let balance = calculate_balance(&amounts)?;
*last = Posting::new(last.account.as_str(), balance);
}
let mut txn = Transaction::new(transaction_date, &narration).with_flag('*');
if !payee.is_empty() {
txn = txn.with_payee(&payee);
}
for posting in postings {
txn = txn.with_synthesized_posting(posting);
}
let config = FormatConfig::default();
let directive = Directive::Transaction(txn);
let formatted = canonical_format_directive(&directive, &config)?;
if args.dry_run {
println!("\n{formatted}");
return Ok(());
}
println!("\nPreview:");
println!("{formatted}");
if !args.yes {
print!("Append to {}? [Y/n] ", file.display());
std::io::stdout().flush()?;
let mut response = String::new();
std::io::stdin().read_line(&mut response)?;
let response = response.trim().to_lowercase();
if !response.is_empty() && response != "y" && response != "yes" {
println!("Cancelled.");
return Ok(());
}
}
append_transaction(file, &formatted)?;
println!("Transaction appended to {}", file.display());
Ok(())
}
fn read_file_tail(file: &PathBuf, n: u64) -> Result<Vec<u8>> {
let mut f = File::open(file).with_context(|| format!("Failed to open {}", file.display()))?;
let len = f.metadata()?.len();
if len == 0 {
return Ok(Vec::new());
}
let read_len = len.min(n);
let seek_pos = len.saturating_sub(n);
f.seek(SeekFrom::Start(seek_pos))?;
let mut buf = vec![0u8; read_len as usize];
f.read_exact(&mut buf)?;
Ok(buf)
}
fn append_transaction(file: &PathBuf, formatted: &str) -> Result<()> {
if !file.exists() {
fs::write(file, formatted)
.with_context(|| format!("Failed to create {}", file.display()))?;
return Ok(());
}
let tail = read_file_tail(file, 2)?;
let mut f = OpenOptions::new()
.append(true)
.open(file)
.with_context(|| format!("Failed to open {} for appending", file.display()))?;
if !tail.is_empty() {
let ends_with_newline = tail.last() == Some(&b'\n');
let ends_with_double_newline =
tail.len() >= 2 && tail[tail.len() - 2] == b'\n' && tail[tail.len() - 1] == b'\n';
if !ends_with_double_newline {
writeln!(f)?;
if !ends_with_newline {
writeln!(f)?;
}
}
}
write!(f, "{formatted}")?;
Ok(())
}
pub fn run(args: &Args, file: &PathBuf) -> Result<()> {
let date = if let Some(ref d) = args.date {
parse_date(d)?
} else {
jiff::Zoned::now().date()
};
if !file.exists() && !args.dry_run {
if !args.yes {
print!("File {} does not exist. Create it? [y/N] ", file.display());
std::io::stdout().flush()?;
let mut response = String::new();
std::io::stdin().read_line(&mut response)?;
let response = response.trim().to_lowercase();
if response != "y" && response != "yes" {
bail!("File does not exist: {}", file.display());
}
}
if let Some(parent) = file.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
}
if args.quick.is_some() {
run_quick_mode(args, file, date)
} else {
run_interactive_mode(args, file, date)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_date_today() {
let today = jiff::Zoned::now().date();
assert_eq!(parse_date("today").unwrap(), today);
assert_eq!(parse_date("").unwrap(), today);
assert_eq!(parse_date("TODAY").unwrap(), today);
}
#[test]
fn test_parse_date_yesterday() {
let yesterday = jiff::Zoned::now().date().yesterday().ok().unwrap();
assert_eq!(parse_date("yesterday").unwrap(), yesterday);
assert_eq!(parse_date("YESTERDAY").unwrap(), yesterday);
}
#[test]
fn test_parse_date_relative() {
let today = jiff::Zoned::now().date();
let tomorrow = today.tomorrow().ok().unwrap();
let yesterday = today.yesterday().ok().unwrap();
assert_eq!(parse_date("+1").unwrap(), tomorrow);
assert_eq!(parse_date("-1").unwrap(), yesterday);
assert_eq!(
parse_date("+7").unwrap(),
today.checked_add(jiff::ToSpan::days(7)).ok().unwrap()
);
}
#[test]
fn test_parse_date_explicit() {
assert_eq!(
parse_date("2026-03-21").unwrap(),
rustledger_core::naive_date(2026, 3, 21).unwrap()
);
assert_eq!(
parse_date("2024-01-01").unwrap(),
rustledger_core::naive_date(2024, 1, 1).unwrap()
);
}
#[test]
fn test_parse_date_invalid() {
assert!(parse_date("not-a-date").is_err());
assert!(parse_date("2026/03/21").is_err());
assert!(parse_date("03-21-2026").is_err());
}
#[test]
fn test_parse_amount_with_space() {
let amt = parse_amount("123.45 USD").unwrap();
assert_eq!(amt.number, Decimal::from_str("123.45").unwrap());
assert_eq!(amt.currency.as_str(), "USD");
}
#[test]
fn test_parse_amount_no_space() {
let amt = parse_amount("123.45USD").unwrap();
assert_eq!(amt.number, Decimal::from_str("123.45").unwrap());
assert_eq!(amt.currency.as_str(), "USD");
}
#[test]
fn test_parse_amount_negative() {
let amt = parse_amount("-50.00 EUR").unwrap();
assert_eq!(amt.number, Decimal::from_str("-50.00").unwrap());
assert_eq!(amt.currency.as_str(), "EUR");
}
#[test]
fn test_parse_amount_integer() {
let amt = parse_amount("100 BTC").unwrap();
assert_eq!(amt.number, Decimal::from_str("100").unwrap());
assert_eq!(amt.currency.as_str(), "BTC");
}
#[test]
fn test_parse_amount_invalid() {
assert!(parse_amount("123.45").is_err()); assert!(parse_amount("USD").is_err()); assert!(parse_amount("abc USD").is_err()); }
#[test]
fn test_calculate_balance_simple() {
let amounts = vec![Amount::new(Decimal::from_str("100.00").unwrap(), "USD")];
let balance = calculate_balance(&amounts).unwrap();
assert_eq!(balance.number, Decimal::from_str("-100.00").unwrap());
assert_eq!(balance.currency.as_str(), "USD");
}
#[test]
fn test_calculate_balance_multiple() {
let amounts = vec![
Amount::new(Decimal::from_str("50.00").unwrap(), "USD"),
Amount::new(Decimal::from_str("25.00").unwrap(), "USD"),
];
let balance = calculate_balance(&amounts).unwrap();
assert_eq!(balance.number, Decimal::from_str("-75.00").unwrap());
}
#[test]
fn test_calculate_balance_mixed_currencies() {
let amounts = vec![
Amount::new(Decimal::from_str("100.00").unwrap(), "USD"),
Amount::new(Decimal::from_str("50.00").unwrap(), "EUR"),
];
assert!(calculate_balance(&amounts).is_err());
}
#[test]
fn test_calculate_balance_empty() {
let amounts: Vec<Amount> = vec![];
assert!(calculate_balance(&amounts).is_err());
}
#[test]
fn test_account_completer_prefix_match() {
let completer = AccountCompleter::new(vec![
"Assets:Bank:Checking".to_string(),
"Assets:Bank:Savings".to_string(),
"Assets:Cash".to_string(),
"Expenses:Food".to_string(),
"Expenses:Transport".to_string(),
]);
let history = rustyline::history::DefaultHistory::new();
let ctx = rustyline::Context::new(&history);
let (start, matches) = completer.complete("Assets", 6, &ctx).unwrap();
assert_eq!(start, 0);
assert_eq!(matches.len(), 3);
assert!(matches.iter().any(|p| p.display == "Assets:Bank:Checking"));
assert!(matches.iter().any(|p| p.display == "Assets:Bank:Savings"));
assert!(matches.iter().any(|p| p.display == "Assets:Cash"));
}
#[test]
fn test_account_completer_substring_match() {
let completer = AccountCompleter::new(vec![
"Assets:Bank:Checking".to_string(),
"Expenses:Food".to_string(),
"Liabilities:CreditCard".to_string(),
]);
let history = rustyline::history::DefaultHistory::new();
let ctx = rustyline::Context::new(&history);
let (start, matches) = completer.complete("bank", 4, &ctx).unwrap();
assert_eq!(start, 0);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].display, "Assets:Bank:Checking");
}
#[test]
fn test_account_completer_empty() {
let completer = AccountCompleter::empty();
let history = rustyline::history::DefaultHistory::new();
let ctx = rustyline::Context::new(&history);
let (start, matches) = completer.complete("Assets", 6, &ctx).unwrap();
assert_eq!(start, 0);
assert!(matches.is_empty());
}
#[test]
fn test_extract_accounts_from_string() {
let content = r#"
2024-01-01 open Assets:Checking
2024-01-01 open Expenses:Food
2024-01-02 * "Store" "Groceries"
Expenses:Food 50.00 USD
Assets:Checking
"#;
let temp_file = unique_temp_file("extract_accounts");
std::fs::write(&temp_file, content).unwrap();
let accounts = extract_accounts(&temp_file);
assert!(accounts.contains(&"Assets:Checking".to_string()));
assert!(accounts.contains(&"Expenses:Food".to_string()));
std::fs::remove_file(&temp_file).ok();
}
#[test]
fn test_extract_accounts_nonexistent_file() {
let nonexistent = PathBuf::from("/nonexistent/file.beancount");
let accounts = extract_accounts(&nonexistent);
assert!(accounts.is_empty());
}
#[test]
fn test_parse_amount_stock_ticker() {
let amt = parse_amount("10 BRK.B").unwrap();
assert_eq!(amt.number, Decimal::from_str("10").unwrap());
assert_eq!(amt.currency.as_str(), "BRK.B");
}
#[test]
fn test_parse_amount_futures_contract() {
let amt = parse_amount("5 /ESM24").unwrap();
assert_eq!(amt.number, Decimal::from_str("5").unwrap());
assert_eq!(amt.currency.as_str(), "/ESM24");
}
#[test]
fn test_parse_amount_no_space_complex() {
let amt = parse_amount("100.5BRK.B").unwrap();
assert_eq!(amt.number, Decimal::from_str("100.5").unwrap());
assert_eq!(amt.currency.as_str(), "BRK.B");
}
fn unique_temp_file(name: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let temp_dir = std::env::temp_dir();
temp_dir.join(format!(
"rustledger_test_{}_{}_{}.beancount",
name,
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
))
}
#[test]
fn test_append_transaction_new_file() {
let temp_file = unique_temp_file("append_new");
std::fs::remove_file(&temp_file).ok();
let txn = "2024-01-01 * \"Test\"\n Assets:Cash 100 USD\n Expenses:Test\n";
append_transaction(&temp_file, txn).unwrap();
let content = std::fs::read_to_string(&temp_file).unwrap();
assert_eq!(content, txn);
std::fs::remove_file(&temp_file).ok();
}
#[test]
fn test_append_transaction_empty_file() {
let temp_file = unique_temp_file("append_empty");
std::fs::write(&temp_file, "").unwrap();
let txn = "2024-01-01 * \"Test\"\n Assets:Cash 100 USD\n";
append_transaction(&temp_file, txn).unwrap();
let content = std::fs::read_to_string(&temp_file).unwrap();
assert_eq!(content, txn);
std::fs::remove_file(&temp_file).ok();
}
#[test]
fn test_append_transaction_with_newline() {
let temp_file = unique_temp_file("append_newline");
std::fs::write(&temp_file, "2024-01-01 open Assets:Cash\n").unwrap();
let txn = "2024-01-02 * \"Test\"\n Assets:Cash 100 USD\n";
append_transaction(&temp_file, txn).unwrap();
let content = std::fs::read_to_string(&temp_file).unwrap();
assert!(content.contains("\n\n2024-01-02"));
std::fs::remove_file(&temp_file).ok();
}
#[test]
fn test_append_transaction_with_double_newline() {
let temp_file = unique_temp_file("append_double_newline");
std::fs::write(&temp_file, "2024-01-01 open Assets:Cash\n\n").unwrap();
let txn = "2024-01-02 * \"Test\"\n";
append_transaction(&temp_file, txn).unwrap();
let content = std::fs::read_to_string(&temp_file).unwrap();
assert!(!content.contains("\n\n\n"));
std::fs::remove_file(&temp_file).ok();
}
}