diff --git a/crates/improvise-io/src/persistence/mod.rs b/crates/improvise-io/src/persistence/mod.rs index 505dfa2c..ee8d579a 100644 --- a/crates/improvise-io/src/persistence/mod.rs +++ b/crates/improvise-io/src/persistence/mod.rs @@ -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 { }; 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 ────────────────────────────────────