#![allow(clippy::items_after_statements)]
mod accounts;
mod balances;
mod balsheet;
mod commodities;
mod holdings;
mod income;
mod journal;
mod networth;
mod prices;
mod stats;
use crate::cmd::completions::ShellType;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use rustledger_core::NaiveDate;
use rustledger_loader::LoadOptions;
use std::io;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "report")]
#[command(author, version, about, long_about = None)]
pub struct Args {
#[arg(long, value_name = "SHELL", hide = true)]
generate_completions: Option<ShellType>,
#[arg(value_name = "FILE")]
pub file: Option<PathBuf>,
#[command(subcommand)]
pub report: Option<Report>,
#[arg(short, long, global = true)]
pub verbose: bool,
#[arg(short = 'f', long, global = true)]
pub format: Option<OutputFormat>,
#[arg(long, global = true)]
pub no_pager: bool,
#[arg(long = "no-cache", global = true)]
pub no_cache: bool,
}
#[derive(Clone, Debug, Default, clap::ValueEnum)]
pub enum OutputFormat {
#[default]
Text,
Csv,
Json,
}
impl OutputFormat {
#[must_use]
pub fn from_str_config(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"text" => Some(Self::Text),
"csv" => Some(Self::Csv),
"json" => Some(Self::Json),
_ => None,
}
}
}
#[derive(Subcommand, Debug)]
pub enum Report {
Balances {
#[arg(short, long)]
account: Option<String>,
},
#[command(alias = "bal")]
Balsheet,
#[command(alias = "is")]
Income,
#[command(alias = "register")]
Journal {
#[arg(short, long)]
account: Option<String>,
#[arg(short, long)]
limit: Option<usize>,
},
Holdings {
#[arg(short, long)]
account: Option<String>,
},
Networth {
#[arg(short, long, default_value = "monthly")]
period: String,
#[arg(short, long)]
currency: Option<String>,
#[arg(short, long)]
account: Option<String>,
#[arg(long)]
no_zero: bool,
},
Accounts,
Commodities,
Stats,
Prices {
#[arg(short, long)]
commodity: Option<String>,
},
}
pub fn run(
file: &PathBuf,
report: &Report,
verbose: bool,
format: &OutputFormat,
no_pager: bool,
no_cache: bool,
) -> Result<()> {
let loaded = load(file, report, verbose, no_cache)?;
let use_pager = !no_pager && matches!(format, OutputFormat::Text);
let pager_cmd = if use_pager {
crate::config::Config::load()
.ok()
.and_then(|l| l.config.output.pager)
} else {
None
};
let mut writer = if use_pager {
crate::pager::create_pager(pager_cmd.as_deref())
} else {
crate::pager::PagerWriter::Stdout(io::stdout().lock())
};
let result = render(&loaded, report, file, format, &mut writer);
writer.finish();
result
}
pub fn run_with_writer<W: io::Write>(
file: &PathBuf,
report: &Report,
verbose: bool,
format: &OutputFormat,
out: &mut W,
) -> Result<()> {
let loaded = load(file, report, verbose, false)?;
render(&loaded, report, file, format, out)
}
struct LoadedReport {
directives: Vec<rustledger_core::Directive>,
balance_view: Option<Vec<rustledger_core::Directive>>,
}
fn load(file: &PathBuf, report: &Report, verbose: bool, no_cache: bool) -> Result<LoadedReport> {
if !file.exists() {
anyhow::bail!("file not found: {}", file.display());
}
let options = LoadOptions {
validate: false, ..Default::default()
};
let (raw, _from_cache) = crate::cmd::loadcache::load_result_cached(file, no_cache, verbose)?;
let ledger = rustledger_loader::process(raw, &options)
.with_context(|| format!("failed to load {}", file.display()))?;
for err in &ledger.errors {
eprintln!("{}: {}", err.code, err.message);
}
let needs_balance_view = matches!(
report,
Report::Balances { .. }
| Report::Balsheet
| Report::Income
| Report::Holdings { .. }
| Report::Networth { .. }
);
let has_pads = needs_balance_view
&& ledger
.directives
.iter()
.any(|s| matches!(&s.value, rustledger_core::Directive::Pad(_)));
let balance_view = if has_pads {
Some(ledger.balance_view())
} else {
None
};
let directives: Vec<_> = ledger.directives.into_iter().map(|s| s.value).collect();
Ok(LoadedReport {
directives,
balance_view,
})
}
fn render<W: io::Write>(
loaded: &LoadedReport,
report: &Report,
file: &PathBuf,
format: &OutputFormat,
writer: &mut W,
) -> Result<()> {
let directives = &loaded.directives;
let balance_input: &[rustledger_core::Directive] =
loaded.balance_view.as_deref().unwrap_or(directives);
match report {
Report::Balances { account } => {
balances::report_balances(balance_input, account.as_deref(), format, writer)?;
}
Report::Balsheet => {
balsheet::report_balsheet(balance_input, format, writer)?;
}
Report::Income => {
income::report_income(balance_input, format, writer)?;
}
Report::Journal { account, limit } => {
journal::report_journal(directives, account.as_deref(), *limit, format, writer)?;
}
Report::Holdings { account } => {
holdings::report_holdings(balance_input, account.as_deref(), format, writer)?;
}
Report::Networth {
period,
currency,
account,
no_zero,
} => {
networth::report_networth(
balance_input,
period,
currency.as_deref(),
account.as_deref(),
*no_zero,
format,
writer,
)?;
}
Report::Accounts => {
accounts::report_accounts(directives, format, writer)?;
}
Report::Commodities => {
commodities::report_commodities(directives, format, writer)?;
}
Report::Stats => {
stats::report_stats(directives, file, writer)?;
}
Report::Prices { commodity } => {
prices::report_prices(directives, commodity.as_deref(), format, writer)?;
}
}
Ok(())
}
pub(super) fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
pub(super) fn json_escape(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
#[derive(Default)]
pub(super) struct LedgerStats {
pub transactions: usize,
pub postings: usize,
pub accounts: usize,
pub commodities: usize,
pub balance_assertions: usize,
pub prices: usize,
pub pads: usize,
pub events: usize,
pub notes: usize,
pub documents: usize,
pub queries: usize,
pub custom: usize,
pub first_date: Option<NaiveDate>,
pub last_date: Option<NaiveDate>,
}