improvise-711n: a trailing conversion letter no longer erases the precision

`parse_number_format` read the precision as `fmt[i+1..].parse::<u8>()` over
everything after the last `.`, so `,.2f` — the printf spelling the repo-map's
own `.improv` example carries — failed to parse and fell through to zero
decimals, silently, in the TUI, the browser projection and CSV export.

The precision is now the leading digit run after the last `.`, with any
conversion letter ignored. The spellings that are legal are stated once, in
`context/code/core.md`, along with why the trailing `f` is accepted rather than
refused (the grammar takes the format line as an opaque `rest_of_line`, so
refusing it rejects documents this repo already writes) and why the reading is
printf's rather than Excel's (`.00` is the spelling the two disagree on).

`export_csv_quotes_commas_per_rfc4180` asserted the value `starts_with("1,200")`
against a `,.2f` fixture, so it could not tell two decimals from none. It is now
an exact equality, and reverting the fix fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
Edward Langley
2026-09-21 10:14:27 -07:00
parent ce895ff560
commit 3775d4ea1a
5 changed files with 135 additions and 11 deletions
+41 -6
View File
@@ -10,14 +10,17 @@ pub fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String
}
}
/// Parse a number format string like ",.0" into (use_commas, decimal_places).
/// The digits a precision is named by: everything up to the first non-digit.
fn leading_digits(s: &str) -> &str {
&s[..s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len())]
}
/// Parse a printf-style number format like ",.2f" into (use_commas, decimals).
/// Which spellings are legal: `context/code/core.md`, "The number format".
pub fn parse_number_format(fmt: &str) -> (bool, u8) {
let comma = fmt.contains(',');
let decimals = fmt
.rfind('.')
.and_then(|i| fmt[i + 1..].parse::<u8>().ok())
.unwrap_or(0);
(comma, decimals)
let precision = fmt.rsplit_once('.').map_or("", |(_, tail)| leading_digits(tail));
(comma, precision.parse::<u8>().unwrap_or(0))
}
/// Round half away from zero (the "normal" rounding people expect).
@@ -100,6 +103,38 @@ mod tests {
assert_eq!(parse_number_format(",.1.3"), (true, 3));
}
/// Bug improvise-711n: a trailing conversion letter made the whole suffix
/// unparseable. Evidence: `context/test-evidence/core.md`.
#[test]
fn a_trailing_conversion_letter_does_not_erase_the_precision() {
assert_eq!(parse_number_format(",.2f"), (true, 2));
assert_eq!(parse_number_format(".2f"), (false, 2));
assert_eq!(parse_number_format("%.2f"), (false, 2));
// The precision is the LEADING digit run, not the tail with its
// non-digits trimmed off — these two readings differ here.
assert_eq!(parse_number_format(".2f3"), (false, 2));
}
/// One convention, not two: `.00` is printf's "precision zero", never
/// Excel's "two places". Evidence: `context/test-evidence/core.md`.
#[test]
fn a_zero_padded_picture_string_is_read_as_printf_not_as_excel() {
assert_eq!(parse_number_format("0.00"), (false, 0));
assert_eq!(parse_number_format(",.0f"), (true, 0));
}
#[test]
fn a_precision_too_large_to_name_reads_as_none() {
assert_eq!(parse_number_format(".300"), (false, 0));
assert_eq!(parse_number_format(",.999f"), (true, 0));
}
#[test]
fn a_format_naming_no_precision_at_all_reads_as_zero() {
assert_eq!(parse_number_format(",f"), (true, 0));
assert_eq!(parse_number_format(".f"), (false, 0));
}
// ── format_f64 basic ───────────────────────────────────────────────
#[test]
@@ -139,11 +139,9 @@ mod tests {
assert_eq!(r.len(), 2, "comma split a field: {r:?}");
}
assert_eq!(&records[1][0], "Smith, Jr.");
assert!(
records[1][1].starts_with("1,200"),
"expected comma-grouped value, got: {:?}",
&records[1][1]
);
// Exact, not `starts_with`: the fixture asks for `.2f`, and a prefix
// check cannot tell two decimals from none (improvise-711n).
assert_eq!(&records[1][1], "1,200.00");
}
/// Bug improvise-iot6: `render_csv` built its layout with `GridLayout::new`,