#[derive(Debug, Clone)]
pub enum Alignment {
Auto {
prefix_width: Option<usize>,
num_width: Option<usize>,
},
CurrencyColumn(usize),
}
impl Default for Alignment {
fn default() -> Self {
Self::Auto {
prefix_width: None,
num_width: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FormatLine {
Plain(String),
Aligned {
prefix: String,
number: String,
suffix: String,
},
}
#[derive(Debug, Clone, Copy)]
struct ResolvedWidths {
prefix_width: usize,
num_width: usize,
}
fn resolve_auto_widths(
lines: &[FormatLine],
forced_prefix: Option<usize>,
forced_num: Option<usize>,
) -> ResolvedWidths {
let mut prefix_width = 0;
let mut num_width = 0;
for line in lines {
if let FormatLine::Aligned { prefix, number, .. } = line {
prefix_width = prefix_width.max(prefix.chars().count());
num_width = num_width.max(number.chars().count());
}
}
ResolvedWidths {
prefix_width: forced_prefix.unwrap_or(prefix_width),
num_width: forced_num.unwrap_or(num_width),
}
}
fn render_auto(prefix: &str, number: &str, suffix: &str, widths: ResolvedWidths) -> String {
let prefix_pad = widths.prefix_width.saturating_sub(prefix.chars().count());
let num_pad = widths.num_width.saturating_sub(number.chars().count());
let mut out = String::with_capacity(
prefix.len() + prefix_pad + 2 + num_pad + number.len() + 1 + suffix.len(),
);
out.push_str(prefix);
out.extend(std::iter::repeat_n(' ', prefix_pad));
out.push_str(" ");
out.extend(std::iter::repeat_n(' ', num_pad));
out.push_str(number);
out.push(' ');
out.push_str(suffix);
let trimmed = out.trim_end();
out.truncate(trimmed.len());
out
}
fn render_currency_column(prefix: &str, number: &str, suffix: &str, column: usize) -> String {
let spaces = column
.saturating_sub(prefix.chars().count())
.saturating_sub(number.chars().count())
.saturating_sub(4);
let mut out =
String::with_capacity(prefix.len() + spaces + 2 + number.len() + 1 + suffix.len());
out.push_str(prefix);
out.extend(std::iter::repeat_n(' ', spaces));
out.push_str(" ");
out.push_str(number);
out.push(' ');
out.push_str(suffix);
let trimmed = out.trim_end();
out.truncate(trimmed.len());
out
}
#[must_use]
pub fn resolve_alignment(lines: &[FormatLine], alignment: &Alignment) -> Alignment {
match *alignment {
Alignment::Auto {
prefix_width,
num_width,
} => {
let widths = resolve_auto_widths(lines, prefix_width, num_width);
Alignment::Auto {
prefix_width: Some(widths.prefix_width),
num_width: Some(widths.num_width),
}
}
Alignment::CurrencyColumn(column) => Alignment::CurrencyColumn(column),
}
}
#[must_use]
pub fn render_lines(lines: &[FormatLine], alignment: &Alignment) -> String {
let mut out = String::new();
match *alignment {
Alignment::Auto {
prefix_width,
num_width,
} => {
let widths = resolve_auto_widths(lines, prefix_width, num_width);
for line in lines {
match line {
FormatLine::Plain(s) => out.push_str(s),
FormatLine::Aligned {
prefix,
number,
suffix,
} => out.push_str(&render_auto(prefix, number, suffix, widths)),
}
out.push('\n');
}
}
Alignment::CurrencyColumn(column) => {
for line in lines {
match line {
FormatLine::Plain(s) => out.push_str(s),
FormatLine::Aligned {
prefix,
number,
suffix,
} => out.push_str(&render_currency_column(prefix, number, suffix, column)),
}
out.push('\n');
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn aligned(prefix: &str, number: &str, suffix: &str) -> FormatLine {
FormatLine::Aligned {
prefix: prefix.to_string(),
number: number.to_string(),
suffix: suffix.to_string(),
}
}
#[test]
fn auto_aligns_numbers_to_widest_prefix() {
let lines = vec![
aligned(" Assets:Bank:Checking", "5000", "USD"),
aligned(" Income:Salary", "-5000", "USD"),
];
let out = render_lines(&lines, &Alignment::default());
let rows: Vec<&str> = out.lines().collect();
assert_eq!(rows[0], " Assets:Bank:Checking 5000 USD");
for row in &rows {
assert_eq!(row.find("USD").unwrap(), 30, "row: {row:?}");
}
}
#[test]
fn auto_includes_balance_prefix_in_width() {
let lines = vec![
aligned(" Assets:Bank:Checking", "5000", "USD"),
aligned("2024-01-16 balance Assets:Bank:Checking", "5000", "USD"),
];
let out = render_lines(&lines, &Alignment::default());
let widest = "2024-01-16 balance Assets:Bank:Checking";
for line in out.lines() {
let num_pos = line.find("5000").unwrap();
assert_eq!(num_pos, widest.chars().count() + 2, "line: {line:?}");
}
}
#[test]
fn plain_lines_pass_through_verbatim() {
let lines = vec![
FormatLine::Plain("; a comment".to_string()),
FormatLine::Plain("2024-01-01 open Assets:Bank USD".to_string()),
];
let out = render_lines(&lines, &Alignment::default());
assert_eq!(out, "; a comment\n2024-01-01 open Assets:Bank USD\n");
}
#[test]
fn currency_column_places_currency_at_column() {
let lines = vec![aligned(" Assets:Bank", "5000", "USD")];
let out = render_lines(&lines, &Alignment::CurrencyColumn(60));
let line = out.trim_end();
assert_eq!(line.find("USD").unwrap(), 59, "line: {line:?}");
}
#[test]
fn currency_column_keeps_min_two_spaces_when_overflowing() {
let lines = vec![aligned(
" Assets:Very:Long:Account:Name:That:Overflows",
"5000",
"USD",
)];
let out = render_lines(&lines, &Alignment::CurrencyColumn(10));
assert_eq!(
out,
" Assets:Very:Long:Account:Name:That:Overflows 5000 USD\n"
);
}
#[test]
fn auto_trims_trailing_space_when_suffix_empty() {
let lines = vec![aligned(" Assets:Bank", "5000", "")];
let out = render_lines(&lines, &Alignment::default());
assert_eq!(out, " Assets:Bank 5000\n");
}
#[test]
fn resolve_alignment_fixes_auto_widths_for_per_line_render() {
let lines = vec![
aligned(" Assets:Bank:Checking", "5000", "USD"),
aligned(" Income:Salary", "-5000", "USD"),
];
let resolved = resolve_alignment(&lines, &Alignment::default());
match resolved {
Alignment::Auto {
prefix_width,
num_width,
} => {
assert_eq!(prefix_width, Some(" Assets:Bank:Checking".chars().count()));
assert_eq!(num_width, Some("-5000".chars().count()));
}
Alignment::CurrencyColumn(_) => panic!("expected Auto"),
}
let whole = render_lines(&lines, &Alignment::default());
let single = render_lines(&lines[1..2], &resolved);
assert_eq!(whole.lines().nth(1).unwrap(), single.trim_end_matches('\n'));
}
#[test]
fn resolve_alignment_passes_currency_column_through() {
let lines = vec![aligned(" Assets:Bank", "5000", "USD")];
let resolved = resolve_alignment(&lines, &Alignment::CurrencyColumn(60));
assert!(matches!(resolved, Alignment::CurrencyColumn(60)));
}
#[test]
fn forced_widths_override_auto() {
let lines = vec![aligned(" A", "5", "USD")];
let out = render_lines(
&lines,
&Alignment::Auto {
prefix_width: Some(10),
num_width: Some(4),
},
);
let line = out.trim_end();
assert_eq!(line.find('5').unwrap(), 15, "line: {line:?}");
assert!(line.ends_with("5 USD"));
}
}