1#[derive(Debug, Clone)]
23pub enum Alignment {
24 Auto {
32 prefix_width: Option<usize>,
34 num_width: Option<usize>,
36 },
37 CurrencyColumn(usize),
40}
41
42impl Default for Alignment {
43 fn default() -> Self {
44 Self::Auto {
45 prefix_width: None,
46 num_width: None,
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum FormatLine {
54 Plain(String),
57 Aligned {
63 prefix: String,
65 number: String,
67 suffix: String,
69 },
70}
71
72#[derive(Debug, Clone, Copy)]
74struct ResolvedWidths {
75 prefix_width: usize,
76 num_width: usize,
77}
78
79fn resolve_auto_widths(
82 lines: &[FormatLine],
83 forced_prefix: Option<usize>,
84 forced_num: Option<usize>,
85) -> ResolvedWidths {
86 let mut prefix_width = 0;
87 let mut num_width = 0;
88 for line in lines {
89 if let FormatLine::Aligned { prefix, number, .. } = line {
90 prefix_width = prefix_width.max(prefix.chars().count());
91 num_width = num_width.max(number.chars().count());
92 }
93 }
94 ResolvedWidths {
95 prefix_width: forced_prefix.unwrap_or(prefix_width),
96 num_width: forced_num.unwrap_or(num_width),
97 }
98}
99
100fn render_auto(prefix: &str, number: &str, suffix: &str, widths: ResolvedWidths) -> String {
103 let prefix_pad = widths.prefix_width.saturating_sub(prefix.chars().count());
104 let num_pad = widths.num_width.saturating_sub(number.chars().count());
105 let mut out = String::with_capacity(
106 prefix.len() + prefix_pad + 2 + num_pad + number.len() + 1 + suffix.len(),
107 );
108 out.push_str(prefix);
109 out.extend(std::iter::repeat_n(' ', prefix_pad));
110 out.push_str(" ");
111 out.extend(std::iter::repeat_n(' ', num_pad));
112 out.push_str(number);
113 out.push(' ');
114 out.push_str(suffix);
115 let trimmed = out.trim_end();
116 out.truncate(trimmed.len());
117 out
118}
119
120fn render_currency_column(prefix: &str, number: &str, suffix: &str, column: usize) -> String {
124 let spaces = column
127 .saturating_sub(prefix.chars().count())
128 .saturating_sub(number.chars().count())
129 .saturating_sub(4);
130 let mut out =
131 String::with_capacity(prefix.len() + spaces + 2 + number.len() + 1 + suffix.len());
132 out.push_str(prefix);
133 out.extend(std::iter::repeat_n(' ', spaces));
134 out.push_str(" ");
135 out.push_str(number);
136 out.push(' ');
137 out.push_str(suffix);
138 let trimmed = out.trim_end();
139 out.truncate(trimmed.len());
140 out
141}
142
143#[must_use]
157pub fn resolve_alignment(lines: &[FormatLine], alignment: &Alignment) -> Alignment {
158 match *alignment {
159 Alignment::Auto {
160 prefix_width,
161 num_width,
162 } => {
163 let widths = resolve_auto_widths(lines, prefix_width, num_width);
164 Alignment::Auto {
165 prefix_width: Some(widths.prefix_width),
166 num_width: Some(widths.num_width),
167 }
168 }
169 Alignment::CurrencyColumn(column) => Alignment::CurrencyColumn(column),
170 }
171}
172
173#[must_use]
177pub fn render_lines(lines: &[FormatLine], alignment: &Alignment) -> String {
178 let mut out = String::new();
179 match *alignment {
180 Alignment::Auto {
181 prefix_width,
182 num_width,
183 } => {
184 let widths = resolve_auto_widths(lines, prefix_width, num_width);
185 for line in lines {
186 match line {
187 FormatLine::Plain(s) => out.push_str(s),
188 FormatLine::Aligned {
189 prefix,
190 number,
191 suffix,
192 } => out.push_str(&render_auto(prefix, number, suffix, widths)),
193 }
194 out.push('\n');
195 }
196 }
197 Alignment::CurrencyColumn(column) => {
198 for line in lines {
199 match line {
200 FormatLine::Plain(s) => out.push_str(s),
201 FormatLine::Aligned {
202 prefix,
203 number,
204 suffix,
205 } => out.push_str(&render_currency_column(prefix, number, suffix, column)),
206 }
207 out.push('\n');
208 }
209 }
210 }
211 out
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 fn aligned(prefix: &str, number: &str, suffix: &str) -> FormatLine {
219 FormatLine::Aligned {
220 prefix: prefix.to_string(),
221 number: number.to_string(),
222 suffix: suffix.to_string(),
223 }
224 }
225
226 #[test]
227 fn auto_aligns_numbers_to_widest_prefix() {
228 let lines = vec![
229 aligned(" Assets:Bank:Checking", "5000", "USD"),
230 aligned(" Income:Salary", "-5000", "USD"),
231 ];
232 let out = render_lines(&lines, &Alignment::default());
233 let rows: Vec<&str> = out.lines().collect();
237 assert_eq!(rows[0], " Assets:Bank:Checking 5000 USD");
238 for row in &rows {
239 assert_eq!(row.find("USD").unwrap(), 30, "row: {row:?}");
240 }
241 }
242
243 #[test]
244 fn auto_includes_balance_prefix_in_width() {
245 let lines = vec![
247 aligned(" Assets:Bank:Checking", "5000", "USD"),
248 aligned("2024-01-16 balance Assets:Bank:Checking", "5000", "USD"),
249 ];
250 let out = render_lines(&lines, &Alignment::default());
251 let widest = "2024-01-16 balance Assets:Bank:Checking";
252 for line in out.lines() {
253 let num_pos = line.find("5000").unwrap();
254 assert_eq!(num_pos, widest.chars().count() + 2, "line: {line:?}");
255 }
256 }
257
258 #[test]
259 fn plain_lines_pass_through_verbatim() {
260 let lines = vec![
261 FormatLine::Plain("; a comment".to_string()),
262 FormatLine::Plain("2024-01-01 open Assets:Bank USD".to_string()),
263 ];
264 let out = render_lines(&lines, &Alignment::default());
265 assert_eq!(out, "; a comment\n2024-01-01 open Assets:Bank USD\n");
266 }
267
268 #[test]
269 fn currency_column_places_currency_at_column() {
270 let lines = vec![aligned(" Assets:Bank", "5000", "USD")];
271 let out = render_lines(&lines, &Alignment::CurrencyColumn(60));
272 let line = out.trim_end();
273 assert_eq!(line.find("USD").unwrap(), 59, "line: {line:?}");
275 }
276
277 #[test]
278 fn currency_column_keeps_min_two_spaces_when_overflowing() {
279 let lines = vec![aligned(
281 " Assets:Very:Long:Account:Name:That:Overflows",
282 "5000",
283 "USD",
284 )];
285 let out = render_lines(&lines, &Alignment::CurrencyColumn(10));
286 assert_eq!(
287 out,
288 " Assets:Very:Long:Account:Name:That:Overflows 5000 USD\n"
289 );
290 }
291
292 #[test]
293 fn auto_trims_trailing_space_when_suffix_empty() {
294 let lines = vec![aligned(" Assets:Bank", "5000", "")];
295 let out = render_lines(&lines, &Alignment::default());
296 assert_eq!(out, " Assets:Bank 5000\n");
297 }
298
299 #[test]
300 fn resolve_alignment_fixes_auto_widths_for_per_line_render() {
301 let lines = vec![
305 aligned(" Assets:Bank:Checking", "5000", "USD"),
306 aligned(" Income:Salary", "-5000", "USD"),
307 ];
308 let resolved = resolve_alignment(&lines, &Alignment::default());
309 match resolved {
310 Alignment::Auto {
311 prefix_width,
312 num_width,
313 } => {
314 assert_eq!(prefix_width, Some(" Assets:Bank:Checking".chars().count()));
315 assert_eq!(num_width, Some("-5000".chars().count()));
316 }
317 Alignment::CurrencyColumn(_) => panic!("expected Auto"),
318 }
319 let whole = render_lines(&lines, &Alignment::default());
322 let single = render_lines(&lines[1..2], &resolved);
323 assert_eq!(whole.lines().nth(1).unwrap(), single.trim_end_matches('\n'));
324 }
325
326 #[test]
327 fn resolve_alignment_passes_currency_column_through() {
328 let lines = vec![aligned(" Assets:Bank", "5000", "USD")];
329 let resolved = resolve_alignment(&lines, &Alignment::CurrencyColumn(60));
330 assert!(matches!(resolved, Alignment::CurrencyColumn(60)));
331 }
332
333 #[test]
334 fn forced_widths_override_auto() {
335 let lines = vec![aligned(" A", "5", "USD")];
336 let out = render_lines(
337 &lines,
338 &Alignment::Auto {
339 prefix_width: Some(10),
340 num_width: Some(4),
341 },
342 );
343 let line = out.trim_end();
345 assert_eq!(line.find('5').unwrap(), 15, "line: {line:?}");
346 assert!(line.ends_with("5 USD"));
347 }
348}