P2 slice 6: the viewer

`wanderbooks gui [BOOK]` finds the data root, reads settings from the
journal, the vault of the book, presence through ops/, Save a copy /
Open on every document, Save the W-9, the Export menu, and colour
schemes. One builder for the CPA package and the 1099 worksheet
(ledger-package), shared by the server and the viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:21 -06:00
1 parent b26c099a68
commit 5bc0633cdd
77 files changed
+5833 -2427

No files matched your search

+153
View File
@@ -0,0 +1,153 @@
//! How the books' amounts, shares, dates, and CSV cells are written and
//! read: one spelling for the server's tools, the package, and the viewer.
//!
//! ```text
//! GOAL: an amount, a share, or a date reads the same in every file and
//! tool the books make, and a date in is checked one way.
//! SOURCE: ledger-mcp server.rs INVARIANT 3 (amounts out are decimal
//! strings with at least two places, never rounded); RFC 4180
//! (a CSV field holding a comma, quote, or line break is quoted,
//! its quotes doubled); P0 review round 3, S1 (dates).
//! INVARIANTS:
//! 1. money pads to two places and never rounds; it never prints "-0".
//! 2. share_text depends on a share's value, never its spelling.
//! 3. csv_escape quotes exactly the fields that need it.
//! 4. parse_date takes exactly YYYY-MM-DD, a day the calendar keeps.
//! ```
use chrono::NaiveDate;
use rust_decimal::Decimal;
/// A money amount as a decimal string with at least two places: "0.00",
/// "207.68", "1000.00". Padding only, never rounding: an exact amount with
/// more places (an FX-converted line) keeps them. Never "-0".
pub fn money(n: Decimal) -> String {
let mut n = if n.is_zero() { Decimal::ZERO } else { n };
if n.scale() < 2 {
n.rescale(2);
}
n.to_string()
}
/// A member's share as the K-1 worksheet prints it: at least two places,
/// however it was written ("50%", "0.5" and "0.500" all print "0.50"; a
/// share of a third keeps its places). The printed text depends on the
/// share, never on its spelling.
pub fn share_text(n: Decimal) -> String {
let mut n = n.normalize();
if n.scale() < 2 {
n.rescale(2);
}
n.to_string()
}
/// A CSV field: quoted, with its quotes doubled, when it holds a comma, a
/// quote, or a line feed; else as it is.
pub fn csv_escape(s: &str) -> String {
if s.contains([',', '"', '\n']) {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
/// A date: exactly YYYY-MM-DD, a real day in the calendar the books keep
/// (0001-01-01 through 9999-12-31, `ledger_core::calendar`). No sign, no
/// space, no five-digit year: every date past here is one date arithmetic
/// reaches (P0 review round 3, S1).
pub fn parse_date(s: &str) -> Result<NaiveDate, String> {
let shaped = s.len() == 10
&& s.bytes().enumerate().all(|(i, b)| {
if i == 4 || i == 7 {
b == b'-'
} else {
b.is_ascii_digit()
}
});
shaped
.then(|| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
.flatten()
.filter(|d| ledger_core::calendar::in_calendar(*d))
.ok_or_else(|| {
format!("bad date {s:?}: expected YYYY-MM-DD, a real day from 0001-01-01 to 9999-12-31")
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
#[test]
fn money_always_has_cents_and_never_rounds() {
let m = |s: &str| money(Decimal::from_str(s).unwrap());
assert_eq!(money(Decimal::ZERO), "0.00");
assert_eq!(m("-0"), "0.00");
assert_eq!(m("1000"), "1000.00");
assert_eq!(m("5.8"), "5.80");
assert_eq!(m("-207.68"), "-207.68");
assert_eq!(m("12.3456"), "12.3456");
}
/// A share prints by its value, never its spelling: at least two
/// places, a third keeping its own.
#[test]
fn a_share_prints_the_same_however_it_was_written() {
let t = |s: &str| share_text(Decimal::from_str(s).unwrap());
for half in ["0.5", "0.50", "0.500000"] {
assert_eq!(t(half), "0.50", "{half}");
}
assert_eq!(t("1"), "1.00");
assert_eq!(t("1.0"), "1.00");
assert_eq!(t("0.25"), "0.25");
assert_eq!(t("0.3333"), "0.3333");
assert_eq!(t("0.125"), "0.125");
}
#[test]
fn csv_fields_are_quoted_only_when_they_must_be() {
assert_eq!(csv_escape("plain"), "plain");
assert_eq!(csv_escape("a,b"), "\"a,b\"");
assert_eq!(csv_escape("say \"hi\""), "\"say \"\"hi\"\"\"");
assert_eq!(csv_escape("two\nlines"), "\"two\nlines\"");
assert_eq!(csv_escape(""), "");
}
/// A date is exactly YYYY-MM-DD, a real day from 0001-01-01 to
/// 9999-12-31 (ledger_core::calendar): no sign, no space, no fifth
/// year digit, no year 0 (P0 review round 3, S1).
#[test]
fn dates_are_four_digit_years_and_real_days() {
assert_eq!(parse_date("2026-02-28"), Ok(d(2026, 2, 28)));
assert_eq!(parse_date("2028-02-29"), Ok(d(2028, 2, 29)));
assert_eq!(parse_date("0001-01-01"), Ok(d(1, 1, 1)));
assert_eq!(parse_date("9999-12-31"), Ok(d(9999, 12, 31)));
for bad in [
"0000-01-01",
"0000-00-00",
"2026-02-29",
"2026-02-30",
"2026-13-01",
"2026-1-05",
"2026-01-5",
" 2026-01-05",
"2026-01-05 ",
"+2026-01-05",
"-0001-01-01",
"+262142-12-31",
"-262143-01-01",
"99999-12-31",
"2026/01/05",
"2026-01-05T00:00",
"2026-01-05",
"",
] {
let err = parse_date(bad).expect_err(bad);
assert!(err.contains("0001-01-01 to 9999-12-31"), "{bad:?}: {err}");
}
}
}