use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use colored::Colorize;
use crate::commands::util::format_amount;
use crate::decimal::Decimal;
use crate::error::Error;
use crate::loader::Journal;
use crate::parser::located::Located;
use crate::parser::transaction::Transaction;
pub fn run(
journal: Journal,
account: &str,
segment: &str,
income: &str,
expense: &str,
) -> Result<(), Error> {
let precisions = journal.precisions.clone();
let title = account
.rsplit(':')
.next()
.unwrap_or(account)
.trim_matches(|c| c == '^' || c == '$')
.to_string();
let pattern = [account.to_string()];
let scoped = crate::filter::filter(journal, &pattern, None, None, false, false);
let (out, count) =
render_entries(&scoped.transactions, &title, segment, income, expense, &precisions);
if count == 0 {
println!("{} nothing to sweep", "!".yellow());
return Ok(());
}
let path = PathBuf::from(format!("{}.ledger", title));
append(&path, &out).map_err(|e| Error::from(format!("write {}: {}", path.display(), e)))?;
crate::commands::format::format_in_place(&path, true)?;
let label = if count == 1 { "transaction" } else { "transactions" };
println!("{} swept {} {} in {}", "✓".green(), count, label, path.display());
Ok(())
}
fn render_entries(
transactions: &[Located<Transaction>],
title: &str,
segment: &str,
income: &str,
expense: &str,
precisions: &HashMap<String, usize>,
) -> (String, usize) {
let mut groups: HashMap<(String, String), Vec<(String, Decimal)>> = HashMap::new();
for lt in transactions {
let date = lt.value.date.to_string();
for lp in <.value.postings {
let Some(a) = &lp.value.amount else { continue };
groups
.entry((lp.value.account.clone(), a.commodity.clone()))
.or_default()
.push((date.clone(), a.value));
}
}
let mut open: Vec<(String, String, String, Decimal)> = Vec::new();
for ((account, commodity), postings) in groups {
for (date, amount) in open_postings(postings) {
open.push((date, account.clone(), commodity.clone(), amount));
}
}
open.sort();
let mut out = String::new();
for (date, acct, commodity, amount) in &open {
let prefix = if amount.is_negative() { income } else { expense };
out.push_str(date);
out.push_str(" * ");
out.push_str(title);
out.push('\n');
out.push_str(&format!(
"\t{}\t{}\n",
acct,
format_amount(commodity, &(-*amount), precisions)
));
out.push_str(&format!("\t{}:{}\n", prefix, segment));
out.push('\n');
}
(out, open.len())
}
fn open_postings(postings: Vec<(String, Decimal)>) -> Vec<(String, Decimal)> {
let mut by_date: HashMap<String, Vec<(String, Decimal)>> = HashMap::new();
for entry in postings {
by_date.entry(entry.0.clone()).or_default().push(entry);
}
let mut remainder = Vec::new();
for group in by_date.into_values() {
remainder.extend(cancel(group));
}
cancel(remainder)
}
fn cancel(postings: Vec<(String, Decimal)>) -> Vec<(String, Decimal)> {
let mut by_amount: HashMap<Decimal, Vec<String>> = HashMap::new();
for (date, amount) in postings {
if amount.is_zero() {
continue;
}
by_amount.entry(amount).or_default().push(date);
}
let mut rest = Vec::new();
let mut seen: HashSet<Decimal> = HashSet::new();
let amounts: Vec<Decimal> = by_amount.keys().copied().collect();
for amount in amounts {
let abs = amount.abs();
if !seen.insert(abs) {
continue; }
let mut pos = by_amount.remove(&abs).unwrap_or_default();
let mut neg = by_amount.remove(&(-abs)).unwrap_or_default();
pos.sort();
neg.sort();
let net = pos.len() as i64 - neg.len() as i64;
if net > 0 {
for date in pos.into_iter().rev().take(net as usize) {
rest.push((date, abs));
}
} else if net < 0 {
for date in neg.into_iter().rev().take((-net) as usize) {
rest.push((date, -abs));
}
}
}
rest
}
fn append(path: &Path, content: &str) -> std::io::Result<()> {
let mut f = fs::OpenOptions::new().create(true).append(true).open(path)?;
f.write_all(content.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::posting::{Amount, Posting};
use crate::parser::transaction::State;
use std::sync::Arc;
fn posting(account: &str, commodity: &str, value: i64) -> Located<Posting> {
Located {
file: Arc::from(""),
line: 0,
value: Posting {
account: account.to_string(),
amount: Some(Amount {
commodity: commodity.to_string(),
value: Decimal::from(value),
decimals: 0,
}),
costs: None,
lot_cost: None,
lot_date: None,
balance_assertion: None,
is_virtual: false,
balanced: true,
comments: Vec::new(),
},
}
}
fn tx(date: &str, postings: Vec<Located<Posting>>) -> Located<Transaction> {
Located {
file: Arc::from(""),
line: 1,
value: Transaction {
date: crate::date::Date::parse(date).unwrap(),
state: State::Cleared,
code: None,
description: "x".to_string(),
postings,
comments: Vec::new(),
},
}
}
fn precisions() -> HashMap<String, usize> {
let mut m = HashMap::new();
m.insert("USD".to_string(), 2);
m
}
fn render(txs: &[Located<Transaction>]) -> (String, usize) {
render_entries(txs, "clearing", "foo:bar", "income", "expenses", &precisions())
}
#[test]
fn positive_balance_is_expense() {
let txs = vec![tx("2026-06-01", vec![posting("clearing", "USD", 100)])];
let (out, count) = render(&txs);
assert_eq!(count, 1);
assert!(out.contains("clearing\tUSD-100.00"), "{out}");
assert!(out.contains("\texpenses:foo:bar\n"), "{out}");
assert!(!out.contains("income:foo:bar"), "{out}");
}
#[test]
fn negative_balance_is_income() {
let txs = vec![tx("2026-06-01", vec![posting("clearing", "USD", -100)])];
let (out, count) = render(&txs);
assert_eq!(count, 1);
assert!(out.contains("clearing\tUSD100.00"), "{out}");
assert!(out.contains("\tincome:foo:bar\n"), "{out}");
assert!(!out.contains("expenses:foo:bar"), "{out}");
}
#[test]
fn same_sign_postings_stay_separate() {
let txs = vec![
tx("2026-06-01", vec![posting("clearing", "USD", 10)]),
tx("2026-06-01", vec![posting("clearing", "USD", 20)]),
tx("2026-06-01", vec![posting("clearing", "USD", 30)]),
tx("2026-06-01", vec![posting("clearing", "USD", 40)]),
tx("2026-06-01", vec![posting("clearing", "USD", 50)]),
];
let (out, count) = render(&txs);
assert_eq!(count, 5);
for v in ["10", "20", "30", "40", "50"] {
assert!(out.contains(&format!("clearing\tUSD-{}.00", v)), "{out}");
}
}
#[test]
fn different_dates_separate_entries() {
let txs = vec![
tx("2026-06-01", vec![posting("clearing", "USD", 50)]),
tx("2026-06-02", vec![posting("clearing", "USD", 30)]),
];
let (out, count) = render(&txs);
assert_eq!(count, 2);
assert!(out.contains("2026-06-01 * clearing"), "{out}");
assert!(out.contains("2026-06-02 * clearing"), "{out}");
}
#[test]
fn time_shifted_pair_cancels() {
let txs = vec![
tx("2026-06-01", vec![posting("clearing", "USD", 100)]),
tx("2026-06-30", vec![posting("clearing", "USD", -100)]),
];
let (out, count) = render(&txs);
assert_eq!(count, 0);
assert!(out.is_empty());
}
#[test]
fn only_remainder_after_pairing() {
let txs = vec![
tx("2026-06-01", vec![posting("clearing", "USD", 10)]),
tx("2026-06-02", vec![posting("clearing", "USD", 20)]),
tx("2026-06-03", vec![posting("clearing", "USD", 30)]),
tx("2026-06-10", vec![posting("clearing", "USD", -10)]),
tx("2026-06-11", vec![posting("clearing", "USD", -20)]),
];
let (out, count) = render(&txs);
assert_eq!(count, 1);
assert!(out.contains("clearing\tUSD-30.00"), "{out}");
}
#[test]
fn reopened_date_is_pulled_at_its_own_date() {
let txs = vec![
tx("2022-06-21", vec![posting("clearing", "USD", 39)]),
tx("2022-06-21", vec![posting("clearing", "USD", -39)]),
tx("2022-08-21", vec![posting("clearing", "USD", 39)]),
tx("2022-08-21", vec![posting("clearing", "USD", -39)]),
tx("2022-07-21", vec![posting("clearing", "USD", 39)]),
];
let (out, count) = render(&txs);
assert_eq!(count, 1);
assert!(out.contains("2022-07-21 * clearing"), "{out}");
assert!(!out.contains("2022-06-21 * clearing"), "{out}");
assert!(!out.contains("2022-08-21 * clearing"), "{out}");
}
#[test]
fn header_uses_date_and_title() {
let txs = vec![tx("2026-06-01", vec![posting("clearing", "USD", 100)])];
let (out, _) = render(&txs);
assert!(out.starts_with("2026-06-01 * clearing\n"), "{out}");
}
}