WIP: a legacy JSON workbook is refused by name (improvise-tt22)

This commit is contained in:
Edward Langley
2026-09-23 07:43:13 -07:00
parent 52c6a05369
commit f176c2b8ca
+35 -33
View File
@@ -1,4 +1,4 @@
//! `.improv` document lifecycle — save, load, gzip, legacy JSON — over a
//! `.improv` document lifecycle — save, load, gzip — over a
//! [`Storage`] backend. The text format is in `format`, `parse` and `export`.
mod export;
@@ -96,11 +96,16 @@ pub fn load_from(storage: &dyn Storage, path: &Path) -> Result<Workbook> {
};
let text =
String::from_utf8(bytes).with_context(|| format!("{} is not UTF-8", path.display()))?;
// `.improv` text is the only native format; a JSON workbook is named, not
// handed to the grammar to fail on its first byte (improvise-tt22).
if text.trim_start().starts_with('{') {
serde_json::from_str(&text).context("Failed to deserialize workbook")
} else {
parse_md(&text)
anyhow::bail!(
"{} is a legacy JSON workbook, which is no longer supported: \
only .improv text can be opened",
path.display()
);
}
parse_md(&text)
}
pub fn autosave_path(path: &Path) -> std::path::PathBuf {
@@ -194,37 +199,34 @@ mod tests {
);
}
/// improvise-tt22: `.improv` text is the only native format, so a legacy
/// JSON workbook is refused by name. Evidence: `context/test-evidence/formula-and-io.md`.
#[test]
fn load_dispatcher_detects_legacy_json_by_brace() {
// The load() function routes to JSON deserializer when text starts with '{'
let m = two_cat_model();
let json = serde_json::to_string_pretty(&m).unwrap();
assert!(json.trim_start().starts_with('{'), "sanity check");
// Deserialise via the JSON path
let m2: Workbook = serde_json::from_str(&json).unwrap();
assert_eq!(m2.model.name, "Budget");
}
#[test]
fn a_legacy_json_workbook_loads_its_numbers_bit_for_bit() {
fn a_legacy_json_workbook_is_refused_by_name() {
use crate::storage::{MemoryFs, Storage};
const LOSSY: f64 = 0.248_157_142_857_142_85;
let key = coord(&[("Type", "Food"), ("Month", "Jan")]);
let mut wb = two_cat_model();
wb.model.set_cell(key.clone(), CellValue::Number(LOSSY));
let json = serde_json::to_string(&wb).unwrap();
assert!(
json.contains("0.24815714285714285"),
"the file holds the exact text"
);
let fs = MemoryFs::new();
let path = std::path::Path::new("legacy.improv");
fs.write(path, json.as_bytes()).unwrap();
let loaded = super::load_from(&fs, path).unwrap();
let Some(CellValue::Number(n)) = loaded.model.get_cell(&key) else {
panic!("the cell must load as a number");
};
assert_eq!(n.to_bits(), LOSSY.to_bits(), "loaded {n:?}, not {LOSSY:?}");
// The shape the removed loader read: `Workbook`'s serde form.
let legacy = r#"
{"model": {"name": "Budget", "categories": {}, "data": [], "formulas": []},
"views": {}, "active_view": "Default"}"#;
for name in ["legacy.improv", "legacy.improv.gz"] {
let fs = MemoryFs::new();
let path = std::path::Path::new(name);
let bytes = if name.ends_with(".gz") {
use std::io::Write;
let mut enc =
flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(legacy.as_bytes()).unwrap();
enc.finish().unwrap()
} else {
legacy.as_bytes().to_vec()
};
fs.write(path, &bytes).unwrap();
let err = format!("{:#}", super::load_from(&fs, path).unwrap_err());
assert!(
err.contains("legacy JSON") && err.contains("no longer supported"),
"{name}: expected the format named and refused, got {err:?}"
);
}
}
// ── save/load roundtrip via file ────────────────────────────────────