use anyhow::{Context, Result};
use rustledger_core::{Directive, DisplayContext, Precision};
use rustledger_loader::{LoadResult, Loader};
use std::collections::HashSet;
use std::io::Write;
use std::path::PathBuf;
pub(super) fn cmd_display_context<W: Write>(file: &PathBuf, writer: &mut W) -> Result<()> {
let mut loader = Loader::new();
let load_result = loader
.load(file)
.with_context(|| format!("failed to load {}", file.display()))?;
let label = format!("Display Context for {}", file.display());
let sources = collect_fixed_sources(&load_result);
render_display_context(&load_result.display_context, &label, Some(&sources), writer)
}
#[derive(Debug, Default)]
struct FixedSources {
option: HashSet<String>,
metadata: HashSet<String>,
}
fn collect_fixed_sources(load_result: &LoadResult) -> FixedSources {
let option: HashSet<String> = load_result
.options
.display_precision
.keys()
.cloned()
.collect();
let metadata: HashSet<String> = load_result
.directives
.iter()
.filter_map(|spanned| {
let Directive::Commodity(comm) = &spanned.value else {
return None;
};
let value = comm.meta.get("precision")?;
rustledger_core::parse_precision_meta(value).ok()?;
Some(comm.currency.as_str().to_string())
})
.collect();
FixedSources { option, metadata }
}
fn fixed_label_suffix(
currency: &str,
has_fixed: bool,
sources: Option<&FixedSources>,
) -> &'static str {
if !has_fixed {
return "";
}
let Some(srcs) = sources else {
return " (fixed via programmatic source)";
};
let in_meta = srcs.metadata.contains(currency);
let in_opt = srcs.option.contains(currency);
match (in_meta, in_opt) {
(true, true) => " (fixed via commodity metadata, overrides option \"display_precision\")",
(true, false) => " (fixed via commodity metadata)",
(false, true) => " (fixed via option \"display_precision\")",
(false, false) => " (fixed via programmatic source)",
}
}
fn render_display_context<W: Write>(
dctx: &DisplayContext,
label: &str,
sources: Option<&FixedSources>,
writer: &mut W,
) -> Result<()> {
writeln!(writer, "{label}")?;
writeln!(writer, "{}", "=".repeat(60))?;
writeln!(writer)?;
writeln!(
writer,
"Inference policy: {:?} (default; matches Python bean-query)",
dctx.precision()
)?;
if dctx.render_commas() {
writeln!(writer, "Render commas: enabled")?;
}
writeln!(writer)?;
let currencies: Vec<&str> = dctx.currencies().collect();
if currencies.is_empty() {
writeln!(writer, "No currencies observed.")?;
return Ok(());
}
for currency in currencies {
let mode = dctx.precision_under(currency, Precision::MostCommon);
let max = dctx.precision_under(currency, Precision::Maximum);
let fixed = dctx.has_fixed_precision(currency);
writeln!(writer, "{currency}:")?;
let effective = dctx.get_precision(currency);
let effective_str = effective.map_or_else(|| "<none>".to_string(), |dp| dp.to_string());
let suffix = fixed_label_suffix(currency, fixed, sources);
writeln!(writer, " effective: {effective_str} dp{suffix}")?;
let hist = dctx.histogram(currency);
if !hist.is_empty() {
let parts: Vec<String> = hist
.iter()
.map(|(dp, count)| format!("dp={dp}: {count}"))
.collect();
writeln!(writer, " distribution: {}", parts.join(", "))?;
}
if let (Some(m), Some(x)) = (mode, max)
&& m != x
{
writeln!(writer, " mode (MostCommon): {m}")?;
writeln!(writer, " max (Maximum): {x}")?;
}
writeln!(writer)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn render(dctx: &DisplayContext) -> String {
let mut buf: Vec<u8> = Vec::new();
render_display_context(dctx, "Display Context (test)", None, &mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
fn render_with(dctx: &DisplayContext, sources: &FixedSources) -> String {
let mut buf: Vec<u8> = Vec::new();
render_display_context(dctx, "Display Context (test)", Some(sources), &mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
fn sources(option: &[&str], metadata: &[&str]) -> FixedSources {
FixedSources {
option: option.iter().map(|s| (*s).to_string()).collect(),
metadata: metadata.iter().map(|s| (*s).to_string()).collect(),
}
}
#[test]
fn empty_context_reports_no_currencies() {
let out = render(&DisplayContext::new());
assert!(out.contains("No currencies observed."));
assert!(out.contains("Display Context (test)"));
assert!(out.contains("Inference policy: MostCommon"));
assert!(!out.contains("fixed via"));
}
#[test]
fn single_currency_shows_effective_and_distribution() {
let mut ctx = DisplayContext::new();
for _ in 0..5 {
ctx.update(dec!(1.23), "USD");
}
let out = render(&ctx);
assert!(out.contains("USD:"));
assert!(out.contains("effective: 2 dp"));
assert!(out.contains("distribution: dp=2: 5"));
assert!(!out.contains("fixed via"));
assert!(!out.contains("mode (MostCommon)"));
assert!(!out.contains("max (Maximum)"));
}
#[test]
fn mode_and_max_shown_when_they_differ() {
let mut ctx = DisplayContext::new();
for _ in 0..5 {
ctx.update(dec!(1.23), "USD");
}
ctx.update(dec!(1.2345), "USD");
let out = render(&ctx);
assert!(out.contains("mode (MostCommon): 2"));
assert!(out.contains("max (Maximum): 4"));
}
#[test]
fn programmatic_fixed_override_falls_back_to_programmatic_label() {
let mut ctx = DisplayContext::new();
ctx.update(dec!(1.234), "USD");
ctx.set_fixed_precision("USD", 2);
let out = render(&ctx);
assert!(out.contains("effective: 2 dp (fixed via programmatic source)"));
assert!(out.contains("distribution: dp=3: 1"));
}
#[test]
fn fixed_via_option_only() {
let mut ctx = DisplayContext::new();
ctx.update(dec!(1.234), "USD");
ctx.set_fixed_precision("USD", 2);
let srcs = sources(&["USD"], &[]);
let out = render_with(&ctx, &srcs);
assert!(out.contains("effective: 2 dp (fixed via option \"display_precision\")"));
}
#[test]
fn fixed_via_commodity_metadata_only() {
let mut ctx = DisplayContext::new();
ctx.update(dec!(1.234), "USD");
ctx.set_fixed_precision("USD", 2);
let srcs = sources(&[], &["USD"]);
let out = render_with(&ctx, &srcs);
assert!(out.contains("effective: 2 dp (fixed via commodity metadata)"));
}
#[test]
fn fixed_via_both_metadata_overrides_option() {
let mut ctx = DisplayContext::new();
ctx.update(dec!(1.234), "USD");
ctx.set_fixed_precision("USD", 4);
let srcs = sources(&["USD"], &["USD"]);
let out = render_with(&ctx, &srcs);
assert!(out.contains(
"effective: 4 dp (fixed via commodity metadata, overrides option \"display_precision\")"
));
}
#[test]
fn fixed_with_sources_but_currency_not_in_either_falls_back_to_programmatic() {
let mut ctx = DisplayContext::new();
ctx.update(dec!(1.234), "USD");
ctx.set_fixed_precision("USD", 2);
let srcs = sources(&[], &[]);
let out = render_with(&ctx, &srcs);
assert!(out.contains("effective: 2 dp (fixed via programmatic source)"));
}
#[test]
fn render_commas_flag_surfaced() {
let mut ctx = DisplayContext::new();
ctx.update(dec!(1.23), "USD");
ctx.set_render_commas(true);
let out = render(&ctx);
assert!(out.contains("Render commas: enabled"));
}
#[test]
fn render_commas_off_does_not_emit_line() {
let mut ctx = DisplayContext::new();
ctx.update(dec!(1.23), "USD");
let out = render(&ctx);
assert!(!out.contains("Render commas:"));
}
#[test]
fn fixed_only_currency_appears_with_no_distribution() {
let mut ctx = DisplayContext::new();
ctx.set_fixed_precision("BTC", 8);
let srcs = sources(&["BTC"], &[]);
let out = render_with(&ctx, &srcs);
assert!(out.contains("BTC:"));
assert!(out.contains("effective: 8 dp (fixed via option \"display_precision\")"));
let btc_section = out.split("BTC:").nth(1).unwrap_or("");
assert!(!btc_section.contains("distribution:"));
}
#[test]
fn currencies_listed_in_sorted_order() {
let mut ctx = DisplayContext::new();
ctx.update(dec!(1.23), "USD");
ctx.update(dec!(1.5), "EUR");
ctx.update(dec!(0.001), "BTC");
let out = render(&ctx);
let usd_pos = out.find("USD:").expect("USD shown");
let eur_pos = out.find("EUR:").expect("EUR shown");
let btc_pos = out.find("BTC:").expect("BTC shown");
assert!(btc_pos < eur_pos && eur_pos < usd_pos);
}
#[test]
fn fixed_label_suffix_returns_empty_for_unfixed() {
assert_eq!(fixed_label_suffix("USD", false, None), "");
let srcs = sources(&["USD"], &["USD"]);
assert_eq!(fixed_label_suffix("USD", false, Some(&srcs)), "");
}
#[test]
fn e2e_cmd_display_context_labels_each_source_correctly() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("e2e.beancount");
std::fs::write(
&path,
r#"option "display_precision" "GBP:0.01"
option "display_precision" "USD:0.001"
2024-01-01 commodity USD
precision: 4
2024-01-01 commodity BTC
precision: 8
2024-01-01 open Assets:USD
2024-01-01 open Assets:GBP
2024-01-01 open Assets:BTC
2024-01-01 open Equity:Opening
2024-01-15 * "USD"
Assets:USD 100.00 USD
Equity:Opening
2024-01-15 * "GBP"
Assets:GBP 50.00 GBP
Equity:Opening
2024-01-15 * "BTC"
Assets:BTC 0.50000000 BTC
Equity:Opening
"#,
)
.unwrap();
let mut buf: Vec<u8> = Vec::new();
cmd_display_context(&path, &mut buf).expect("cmd should succeed");
let out = String::from_utf8(buf).unwrap();
assert_eq!(
currency_section(&out, "GBP").trim_end(),
" effective: 2 dp (fixed via option \"display_precision\")\n \
distribution: dp=2: 1",
"GBP block; full output:\n{out}"
);
assert_eq!(
currency_section(&out, "BTC").trim_end(),
" effective: 8 dp (fixed via commodity metadata)\n \
distribution: dp=8: 1",
"BTC block; full output:\n{out}"
);
let usd_section = currency_section(&out, "USD");
assert!(
usd_section
.contains("(fixed via commodity metadata, overrides option \"display_precision\")"),
"USD section should carry the override label; section was:\n{usd_section}"
);
assert!(
!usd_section.contains("(fixed via option \"display_precision\")\n"),
"USD section must NOT carry the option-only label; section was:\n{usd_section}"
);
assert!(
!out.contains("(fixed override)"),
"legacy source-agnostic label should be gone; got:\n{out}"
);
}
#[test]
fn e2e_invalid_metadata_with_option_labels_as_option() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("e2e_invalid.beancount");
std::fs::write(
&path,
r#"option "display_precision" "USD:0.01"
2024-01-01 commodity USD
precision: -1
2024-01-01 open Assets:USD
2024-01-01 open Equity:Opening
2024-01-15 * "USD"
Assets:USD 100.00 USD
Equity:Opening
"#,
)
.unwrap();
let mut buf: Vec<u8> = Vec::new();
cmd_display_context(&path, &mut buf).expect("cmd should succeed");
let out = String::from_utf8(buf).unwrap();
let usd = currency_section(&out, "USD");
assert!(
usd.contains("(fixed via option \"display_precision\")"),
"USD should be option-sourced (invalid metadata is skipped); section:\n{usd}"
);
assert!(
!usd.contains("commodity metadata"),
"invalid metadata must NOT be labeled metadata-sourced; section:\n{usd}"
);
}
fn currency_section<'a>(out: &'a str, currency: &str) -> &'a str {
let header = format!("\n{currency}:\n");
let start = out
.find(&header)
.unwrap_or_else(|| panic!("currency {currency:?} not found in output:\n{out}"))
+ header.len();
let rest = &out[start..];
let end = rest.find("\n\n").unwrap_or(rest.len());
&rest[..end]
}
}