From ca1b5e2edb8568335c6c14f390307bd5beae8238 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Sun, 1 Nov 2026 16:56:26 -0800 Subject: [PATCH] Add craft-ecproj-format: the envelope of EffectCraft project files (#409) * Add craft-ecproj-format: the envelope of EffectCraft project files An .ecproj file is one JSON object: savedBy, then the saved view's members, then the project's members, written with two-space indentation. The crate defines that envelope with the view and the project as type parameters, so the project model stays in EffectCraft: - SCHEMA_VERSION (2) and check_schema, which refuses a newer schema with EffectCraft's own message. - File { saved_by, view, project }, serialized with both parts flattened, and File::to_json. - Head, which reads savedBy and schema from a JSON object, and saved_by, which reads savedBy alone. The tests write synthetic projects and compare the text with JSON written by hand in the shape EffectCraft's writer gives. The workflow runs fmt, clippy, the tests and a wasm32-unknown-unknown check. * ci: do not keep the checkout token in the ecproj-format workflow The checkout step sets persist-credentials: false, as jpeg.yml and media.yml do. No later step in the job uses the token. * ecproj-format: write compact files, read savedBy as EffectCraft opens it EffectCraft's Save Compact Project Files setting (effectcraft 07639b5f) writes .ecproj files on one line with serde_json::to_string. File::to_json_compact writes the same text, and the docs say the crate writes and reads both forms. Head now reads every file EffectCraft's Project::from_json opens: a savedBy that is not a string, or appears more than once, reads as None, and a project written as a JSON array reads with its first element as the schema. saved_by also gives None for a repeated savedBy, as EffectCraft's saved_by does. File's docs say the crate does not check values and that serde_json writes NaN and infinity as null. * ecproj-format: skip a non-string savedBy without keeping it Head::read and saved_by read savedBy into a serde_json::Value before dropping any value that is not a string, so a wide or deeply nested savedBy was held in memory, and one nested past serde_json's recursion limit made Head::read fail. A non-string savedBy, and every repeated savedBy, is now skipped element by element with IgnoredAny, as other members are. It still reads as None. * ecproj-format: Head::read refuses a newer schema and reads objects only Head::read is the one reader of a project file's envelope. It returns ReadError: Json when the text is not one JSON object with a u32 schema (the array form included), or TooNew when the schema is newer than SCHEMA_VERSION, holding the file's savedBy so a message can name the version that wrote it. TooNew reads as before. saved_by() is removed, and Head no longer implements Deserialize. TooNew, ReadError and ReadError::TooNew are #[non_exhaustive]. --- .github/workflows/ecproj-format.yml | 34 +++ Cargo.lock | 9 + Cargo.toml | 1 + README.md | 3 + crates/ecproj-format/Cargo.toml | 16 ++ crates/ecproj-format/README.md | 49 ++++ crates/ecproj-format/src/lib.rs | 311 +++++++++++++++++++++++++ crates/ecproj-format/tests/envelope.rs | 209 +++++++++++++++++ 8 files changed, 632 insertions(+) create mode 100644 .github/workflows/ecproj-format.yml create mode 100644 crates/ecproj-format/Cargo.toml create mode 100644 crates/ecproj-format/README.md create mode 100644 crates/ecproj-format/src/lib.rs create mode 100644 crates/ecproj-format/tests/envelope.rs diff --git a/.github/workflows/ecproj-format.yml b/.github/workflows/ecproj-format.yml new file mode 100644 index 00000000..bd01cbfa --- /dev/null +++ b/.github/workflows/ecproj-format.yml @@ -0,0 +1,34 @@ +name: EffectCraft project format + +on: + workflow_dispatch: + push: + branches: [main] + paths: ['crates/ecproj-format/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/ecproj-format.yml'] + pull_request: + paths: ['crates/ecproj-format/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/ecproj-format.yml'] + +permissions: + contents: read + +jobs: + ecproj-format: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # macOS runners are kept for releases: macOS runs only when started by hand. + os: ${{ github.event_name == 'workflow_dispatch' && fromJSON('["ubuntu-latest","windows-latest","macos-latest"]') || fromJSON('["ubuntu-latest","windows-latest"]') }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + components: rustfmt, clippy + targets: wasm32-unknown-unknown + - run: cargo fmt --check + - run: cargo clippy -p craft-ecproj-format --all-targets --locked -- -D warnings + - run: cargo test -p craft-ecproj-format --locked -- --nocapture + - run: cargo check -p craft-ecproj-format --locked --target wasm32-unknown-unknown diff --git a/Cargo.lock b/Cargo.lock index 4fcd223b..0ef6a5f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -802,6 +802,15 @@ dependencies = [ name = "craft-docpath" version = "0.1.0" +[[package]] +name = "craft-ecproj-format" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-font-catalog" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a98e231b..524f1086 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ craft-cms = { path = "crates/cms" } craft-crw = { path = "crates/crw" } craft-dialogs = { path = "crates/dialogs" } craft-docpath = { path = "crates/docpath" } +craft-ecproj-format = { path = "crates/ecproj-format" } craft-font-catalog = { path = "crates/font-catalog" } craft-font-manager = { path = "crates/font-manager" } craft-font-store = { path = "crates/font-store" } diff --git a/README.md b/README.md index 30d08db6..b7257038 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Result<(), TooNew>` refuses a schema newer than `SCHEMA_VERSION`. + `TooNew` reads "project schema 3 is newer than this version supports (2)". It is + `#[non_exhaustive]`: other crates read its `schema` and `supported` fields. +- `File { saved_by, view, project }` serializes as one object, with `view` and `project` + flattened into it. `to_json()` gives the file's text indented with two spaces, and + `to_json_compact()` gives it on one line. Neither checks the values: `serde_json` writes NaN and + infinity as `null`, so the caller checks its values before saving. +- `Head::read(text) -> Result` reads `savedBy` and `schema` from one JSON + object, and skips other members. `savedBy` is `None` when it is absent, is not a string, or + appears more than once. Text that is not a JSON object, has no `schema` that is a `u32`, or has + two `schema` members gives `ReadError::Json`. A schema newer than `SCHEMA_VERSION` gives + `ReadError::TooNew { too_new, saved_by, .. }`, which reads as `TooNew` does and holds the file's + `savedBy`, so a message can name the version that wrote it. `ReadError` and its `TooNew` + variant are `#[non_exhaustive]`: match them with a wildcard arm and `..`. + +`.ecprojx` files hold the same object encoded as XML, which this crate does not read or write. diff --git a/crates/ecproj-format/src/lib.rs b/crates/ecproj-format/src/lib.rs new file mode 100644 index 00000000..344d14ed --- /dev/null +++ b/crates/ecproj-format/src/lib.rs @@ -0,0 +1,311 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! The envelope of EffectCraft `.ecproj` project files, for EffectCraft, which writes them, and +//! for the apps that read them. +//! +//! A project file is one UTF-8 JSON object. EffectCraft indents it with two spaces, or writes it +//! on one line when Settings ▸ Project ▸ Save Compact Project Files is on: +//! +//! ```text +//! { +//! "savedBy": "0.12.0", +//! "activeComp": 1, +//! "openComps": [1], +//! "schema": 2, +//! "settings": { ... }, +//! "items": { "1": { ... } }, +//! "next_id": 2 +//! } +//! ``` +//! +//! - `savedBy` is the version of EffectCraft that wrote the file. Projects saved before it was +//! added start with `schema`. +//! - The saved view (`activeComp`, `openComps`, `compTimes`) and the project's own members follow +//! in the same object. Both are type parameters of [`File`]; their types stay in EffectCraft. +//! - `schema` is the project schema. [`check_schema`] refuses a schema newer than +//! [`SCHEMA_VERSION`]. +//! +//! [`File`] writes the object: [`File::to_json`] indented, [`File::to_json_compact`] on one line. +//! [`Head::read`] reads `savedBy` and `schema` from either, and refuses a schema newer than +//! [`SCHEMA_VERSION`]. +//! +//! ``` +//! use std::collections::BTreeMap; +//! +//! #[derive(serde::Serialize)] +//! struct Project { +//! schema: u32, +//! items: BTreeMap, +//! } +//! +//! let project = Project { schema: craft_ecproj_format::SCHEMA_VERSION, items: BTreeMap::from([(1, "Comp 1".to_string())]) }; +//! craft_ecproj_format::check_schema(project.schema)?; +//! let view = BTreeMap::::new(); +//! let file = craft_ecproj_format::File::new("0.12.0", &view, &project); +//! let text = file.to_json()?; +//! assert_eq!(text, "{\n \"savedBy\": \"0.12.0\",\n \"schema\": 2,\n \"items\": {\n \"1\": \"Comp 1\"\n }\n}"); +//! assert_eq!(file.to_json_compact()?, r#"{"savedBy":"0.12.0","schema":2,"items":{"1":"Comp 1"}}"#); +//! +//! let head = craft_ecproj_format::Head::read(&text)?; +//! assert_eq!((head.saved_by.as_deref(), head.schema), (Some("0.12.0"), 2)); +//! # Ok::<(), Box>(()) +//! ``` +//! +//! An `.ecprojx` file holds the same object encoded as XML. This crate reads and writes the JSON +//! form only. + +use std::borrow::Cow; +use std::fmt; + +use serde::de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; + +/// The newest project schema. Schema 2 measures the positions of effects on layers without a +/// source rectangle (shape, text) from the top-left of their comp-sized bounds. EffectCraft +/// converts schema 1 projects when it opens them. +pub const SCHEMA_VERSION: u32 = 2; + +/// A project's schema is newer than [`SCHEMA_VERSION`]. +/// +/// Other crates read its fields but do not build one: +/// +/// ```compile_fail +/// let _ = craft_ecproj_format::TooNew { schema: 3, supported: 2 }; +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +#[error("project schema {schema} is newer than this version supports ({supported})")] +#[non_exhaustive] +pub struct TooNew { + /// The schema the project declares. + pub schema: u32, + /// [`SCHEMA_VERSION`]. + pub supported: u32, +} + +/// Refuses a project whose `schema` is newer than [`SCHEMA_VERSION`], before it is read or saved. +pub fn check_schema(schema: u32) -> Result<(), TooNew> { + if schema > SCHEMA_VERSION { Err(TooNew { schema, supported: SCHEMA_VERSION }) } else { Ok(()) } +} + +/// The object a project file holds: `savedBy`, then the members of `view`, then the members of +/// `project`. +/// +/// `view` and `project` must each serialize as a map or a struct, and their member names must not +/// repeat `savedBy` or each other. +/// +/// The values are written as they are, without a check: JSON has no NaN or infinity, and +/// `serde_json` writes either as `null`, which may not read back as the project. The caller +/// checks its values before saving. +#[derive(Debug, Serialize)] +pub struct File<'a, V, P> { + /// The version of the app that writes the file. + #[serde(rename = "savedBy")] + pub saved_by: &'a str, + /// The compositions that were open, and their times. + #[serde(flatten)] + pub view: &'a V, + /// The project. + #[serde(flatten)] + pub project: &'a P, +} + +impl<'a, V: Serialize, P: Serialize> File<'a, V, P> { + pub fn new(saved_by: &'a str, view: &'a V, project: &'a P) -> Self { + File { saved_by, view, project } + } + + /// The file's text, indented with two spaces, with no newline at the end. + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self) + } + + /// The file's text on one line, with no spaces between tokens and no newline at the end. + pub fn to_json_compact(&self) -> Result { + serde_json::to_string(self) + } +} + +/// `savedBy` and `schema`, read from a project file by [`Head::read`]. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Head { + /// The version of the app that wrote the file. `None` for projects saved before `savedBy` was + /// added, and when `savedBy` is not a string or appears more than once. + pub saved_by: Option, + /// The project schema, no newer than [`SCHEMA_VERSION`]. + pub schema: u32, +} + +impl Head { + /// Reads `text`, a project file: one JSON object with a `schema` that is a `u32`. Other + /// members are skipped, and two `schema` members are refused. A schema newer than + /// [`SCHEMA_VERSION`] is refused with [`ReadError::TooNew`], which holds the file's `savedBy`. + pub fn read(text: &str) -> Result { + let mut deserializer = serde_json::Deserializer::from_str(text); + let head = deserializer.deserialize_map(HeadVisitor)?; + deserializer.end()?; + if let Err(too_new) = check_schema(head.schema) { + return Err(ReadError::TooNew { too_new, saved_by: head.saved_by }); + } + Ok(head) + } +} + +/// [`Head::read`] could not read a project file. +/// +/// Other crates match it with a wildcard arm, and match [`ReadError::TooNew`] with `..`: +/// +/// ``` +/// use craft_ecproj_format::{Head, ReadError}; +/// +/// let text = r#"{"savedBy": "0.13.0", "schema": 3}"#; +/// let message = match Head::read(text) { +/// Ok(_) => String::new(), +/// Err(ReadError::TooNew { too_new, saved_by, .. }) => format!("saved by {}: {too_new}", saved_by.unwrap_or_default()), +/// Err(e) => e.to_string(), +/// }; +/// assert_eq!(message, "saved by 0.13.0: project schema 3 is newer than this version supports (2)"); +/// ``` +/// +/// ```compile_fail +/// fn name(e: craft_ecproj_format::ReadError) -> &'static str { +/// match e { +/// craft_ecproj_format::ReadError::Json(_) => "json", +/// craft_ecproj_format::ReadError::TooNew { .. } => "too new", +/// } +/// } +/// ``` +/// +/// ```compile_fail +/// fn saved_by(e: craft_ecproj_format::ReadError) -> Option { +/// match e { +/// craft_ecproj_format::ReadError::TooNew { too_new: _, saved_by } => saved_by, +/// _ => None, +/// } +/// } +/// ``` +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ReadError { + /// The text is not one JSON object with a `schema` that is a `u32`. + #[error(transparent)] + Json(#[from] serde_json::Error), + /// The schema is newer than [`SCHEMA_VERSION`]. Reads as [`TooNew`] does. + #[error("{too_new}")] + #[non_exhaustive] + TooNew { + /// The schema the file declares, and [`SCHEMA_VERSION`]. + too_new: TooNew, + /// The file's `savedBy`, read as [`Head::saved_by`] is. + saved_by: Option, + }, +} + +/// Reads a [`Head`] from a JSON object. Other members are skipped. +struct HeadVisitor; + +impl<'de> Visitor<'de> for HeadVisitor { + type Value = Head; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a project file object") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut saved_by = None; + let mut seen_saved_by = false; + let mut schema = None; + while let Some(key) = map.next_key::>()? { + match &*key { + "savedBy" if seen_saved_by => { + map.next_value::()?; + saved_by = None; + } + "savedBy" => { + saved_by = map.next_value::()?.0; + seen_saved_by = true; + } + "schema" if schema.is_some() => return Err(de::Error::duplicate_field("schema")), + "schema" => schema = Some(map.next_value()?), + _ => { + map.next_value::()?; + } + } + } + let schema = schema.ok_or_else(|| de::Error::missing_field("schema"))?; + Ok(Head { saved_by, schema }) + } +} + +/// A `savedBy` value: the string, or `None` for any other value. Arrays and objects are skipped +/// element by element with [`IgnoredAny`], so a large or deeply nested value is not kept. +struct SavedBy(Option); + +impl<'de> Deserialize<'de> for SavedBy { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_any(SavedByVisitor) + } +} + +struct SavedByVisitor; + +impl<'de> Visitor<'de> for SavedByVisitor { + type Value = SavedBy; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("any JSON value") + } + + fn visit_str(self, v: &str) -> Result { + Ok(SavedBy(Some(v.to_owned()))) + } + + fn visit_string(self, v: String) -> Result { + Ok(SavedBy(Some(v))) + } + + fn visit_bool(self, _: bool) -> Result { + Ok(SavedBy(None)) + } + + fn visit_i64(self, _: i64) -> Result { + Ok(SavedBy(None)) + } + + fn visit_i128(self, _: i128) -> Result { + Ok(SavedBy(None)) + } + + fn visit_u64(self, _: u64) -> Result { + Ok(SavedBy(None)) + } + + fn visit_u128(self, _: u128) -> Result { + Ok(SavedBy(None)) + } + + fn visit_f64(self, _: f64) -> Result { + Ok(SavedBy(None)) + } + + fn visit_unit(self) -> Result { + Ok(SavedBy(None)) + } + + fn visit_none(self) -> Result { + Ok(SavedBy(None)) + } + + fn visit_some>(self, deserializer: D) -> Result { + deserializer.deserialize_ignored_any(IgnoredAny)?; + Ok(SavedBy(None)) + } + + fn visit_seq>(self, mut seq: A) -> Result { + while seq.next_element::()?.is_some() {} + Ok(SavedBy(None)) + } + + fn visit_map>(self, mut map: A) -> Result { + while map.next_entry::()?.is_some() {} + Ok(SavedBy(None)) + } +} diff --git a/crates/ecproj-format/tests/envelope.rs b/crates/ecproj-format/tests/envelope.rs new file mode 100644 index 00000000..b6443d4c --- /dev/null +++ b/crates/ecproj-format/tests/envelope.rs @@ -0,0 +1,209 @@ +use std::collections::BTreeMap; + +use craft_ecproj_format::{File, Head, ReadError, SCHEMA_VERSION, check_schema}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct View { + #[serde(skip_serializing_if = "Option::is_none")] + active_comp: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + open_comps: Vec, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + comp_times: BTreeMap, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +struct Settings { + bit_depth: String, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +struct Item { + id: u64, + name: String, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +struct Project { + schema: u32, + settings: Settings, + items: BTreeMap, + next_id: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + render_queue: Vec, +} + +fn project() -> Project { + Project { + schema: 2, + settings: Settings { bit_depth: "Bpc8".into() }, + items: BTreeMap::from([(1, Item { id: 1, name: "Comp 1".into() }), (2, Item { id: 2, name: "clip.mov".into() })]), + next_id: 3, + render_queue: vec![], + } +} + +const PROJECT_MEMBERS: &str = r#" "schema": 2, + "settings": { + "bit_depth": "Bpc8" + }, + "items": { + "1": { + "id": 1, + "name": "Comp 1" + }, + "2": { + "id": 2, + "name": "clip.mov" + } + }, + "next_id": 3 +}"#; + +#[test] +fn a_file_with_a_saved_view_holds_saved_by_then_the_view_then_the_project() { + let view = View { active_comp: Some(1), open_comps: vec![1, 2], comp_times: BTreeMap::from([(1, 254_016_000_000)]) }; + let p = project(); + let text = File::new("0.12.0", &view, &p).to_json().unwrap(); + let expected = format!( + r#"{{ + "savedBy": "0.12.0", + "activeComp": 1, + "openComps": [ + 1, + 2 + ], + "compTimes": {{ + "1": 254016000000 + }}, +{PROJECT_MEMBERS}"# + ); + assert_eq!(text, expected); +} + +#[test] +fn a_file_with_an_empty_view_holds_saved_by_then_the_project() { + let p = project(); + let text = File::new("0.12.0", &View::default(), &p).to_json().unwrap(); + assert_eq!(text, format!("{{\n \"savedBy\": \"0.12.0\",\n{PROJECT_MEMBERS}")); +} + +#[test] +fn a_compact_file_holds_the_same_members_on_one_line() { + let view = View { active_comp: Some(1), open_comps: vec![1, 2], comp_times: BTreeMap::from([(1, 254_016_000_000)]) }; + let p = project(); + let text = File::new("0.12.0", &view, &p).to_json_compact().unwrap(); + assert_eq!( + text, + r#"{"savedBy":"0.12.0","activeComp":1,"openComps":[1,2],"compTimes":{"1":254016000000},"schema":2,"settings":{"bit_depth":"Bpc8"},"items":{"1":{"id":1,"name":"Comp 1"},"2":{"id":2,"name":"clip.mov"}},"next_id":3}"# + ); + assert_eq!(serde_json::from_str::(&text).unwrap(), p); + assert_eq!(Head::read(&text).unwrap(), Head { saved_by: Some("0.12.0".into()), schema: 2 }); + let pretty = File::new("0.12.0", &view, &p).to_json().unwrap(); + assert_eq!(serde_json::from_str::(&text).unwrap(), serde_json::from_str::(&pretty).unwrap()); +} + +#[test] +fn a_written_file_reads_back_as_the_project_and_its_head() { + let p = project(); + let view = View { active_comp: Some(1), ..View::default() }; + let text = File::new("0.12.0", &view, &p).to_json().unwrap(); + assert_eq!(serde_json::from_str::(&text).unwrap(), p); + assert_eq!(Head::read(&text).unwrap(), Head { saved_by: Some("0.12.0".into()), schema: 2 }); +} + +#[test] +fn a_project_saved_before_saved_by_has_a_head_without_it() { + let text = r#"{"schema": 1, "settings": {}, "items": {}, "next_id": 1}"#; + assert_eq!(Head::read(text).unwrap(), Head { saved_by: None, schema: 1 }); +} + +#[test] +fn a_head_needs_a_schema() { + assert!(Head::read(r#"{"savedBy": "0.12.0", "items": {}}"#).is_err()); + assert!(Head::read(r#"{"savedBy": "0.12.0", "schema": "2"}"#).is_err()); + assert!(Head::read(r#"{"savedBy": "0.13.0", "schema": null, "schema": "3"}"#).is_err()); + assert!(Head::read("[]").is_err()); + assert!(Head::read(r#"["0.12.0", 2]"#).is_err()); + assert!(Head::read(r#"{"schema": 2, "schema": 2}"#).is_err()); + assert!(Head::read(r#"{"schema": 2} {}"#).is_err()); + assert!(Head::read(r#"{"savedBy": "0.12.0", "schema": 2"#).is_err()); + assert!(Head::read("").is_err()); + assert!(Head::read("2").is_err()); +} + +#[test] +fn a_head_reads_a_saved_by_that_is_not_one_string_as_none() { + assert_eq!(Head::read(r#"{"savedBy": 12, "schema": 2}"#).unwrap(), Head { saved_by: None, schema: 2 }); + assert_eq!(Head::read(r#"{"savedBy": ["0.12.0"], "schema": 2}"#).unwrap(), Head { saved_by: None, schema: 2 }); + assert_eq!(Head::read(r#"{"savedBy": {"v": "0.12.0"}, "schema": 2}"#).unwrap(), Head { saved_by: None, schema: 2 }); + assert_eq!(Head::read(r#"{"savedBy": "a", "savedBy": "b", "schema": 2}"#).unwrap(), Head { saved_by: None, schema: 2 }); + assert_eq!(Head::read(r#"{"savedBy": "a", "schema": 2, "savedBy": 1, "savedBy": "c"}"#).unwrap(), Head { saved_by: None, schema: 2 }); + assert_eq!(Head::read(r#"{"schema": 2, "savedBy": "0.13.0-dev"}"#).unwrap(), Head { saved_by: Some("0.13.0-dev".into()), schema: 2 }); +} + +#[test] +fn a_head_reads_the_object_form_only() { + assert!(matches!(Head::read(r#"[2, {}, {}, 1]"#), Err(ReadError::Json(_)))); + assert!(matches!(Head::read("[1]"), Err(ReadError::Json(_)))); + assert!(matches!(Head::read(r#"["savedBy", "0.12.0"]"#), Err(ReadError::Json(_)))); +} + +#[test] +fn a_head_refuses_a_newer_schema() { + let Err(ReadError::TooNew { too_new, saved_by, .. }) = Head::read(r#"{"savedBy": "0.13.0", "schema": 3}"#) else { + panic!("schema 3 is newer than 2") + }; + assert_eq!((too_new.schema, too_new.supported), (3, 2)); + assert_eq!(saved_by.as_deref(), Some("0.13.0")); + let Err(e) = Head::read(r#"{"schema": 4294967295, "savedBy": 1}"#) else { panic!("schema u32::MAX is newer than 2") }; + assert_eq!(e.to_string(), "project schema 4294967295 is newer than this version supports (2)"); + let ReadError::TooNew { saved_by, .. } = e else { panic!("{e:?} is not TooNew") }; + assert_eq!(saved_by, None); + assert_eq!(Head::read(r#"{"savedBy": "0.12.0", "schema": 2}"#).unwrap(), Head { saved_by: Some("0.12.0".into()), schema: 2 }); +} + +#[test] +fn a_head_skips_other_members_of_any_type() { + let text = r#"{"items": [1, {"schema": 9}], "savedBy": null, "schema": 2, "next_id": 1.5}"#; + assert_eq!(Head::read(text).unwrap(), Head { saved_by: None, schema: 2 }); +} + +#[test] +fn a_deeply_nested_saved_by_is_skipped_like_other_members() { + let deep = format!("{}{}", "[".repeat(10_000), "]".repeat(10_000)); + let text = format!(r#"{{"savedBy": {deep}, "schema": 2, "items": {deep}}}"#); + assert_eq!(Head::read(&text).unwrap(), Head { saved_by: None, schema: 2 }); + let deep_map = format!("{}0{}", r#"{"v": "#.repeat(10_000), "}".repeat(10_000)); + let text = format!(r#"{{"savedBy": "0.12.0", "savedBy": {deep_map}, "schema": 2}}"#); + assert_eq!(Head::read(&text).unwrap(), Head { saved_by: None, schema: 2 }); +} + +#[test] +fn a_wide_saved_by_array_reads_as_none() { + let wide = format!("[{}0]", "0,".repeat(1_000_000)); + let text = format!(r#"{{"savedBy": {wide}, "schema": 2, "savedBy": {wide}}}"#); + assert_eq!(Head::read(&text).unwrap(), Head { saved_by: None, schema: 2 }); + let text = format!(r#"{{"schema": 2, "savedBy": "0.12.0", "savedBy": "{}"}}"#, "9".repeat(1_000_000)); + assert_eq!(Head::read(&text).unwrap(), Head { saved_by: None, schema: 2 }); +} + +#[test] +fn check_schema_refuses_only_a_newer_schema() { + assert_eq!(SCHEMA_VERSION, 2); + assert_eq!(check_schema(0), Ok(())); + assert_eq!(check_schema(1), Ok(())); + assert_eq!(check_schema(2), Ok(())); + for schema in [3, u32::MAX] { + let Err(e) = check_schema(schema) else { panic!("schema {schema} is newer than 2") }; + assert_eq!((e.schema, e.supported), (schema, 2)); + } +} + +#[test] +fn too_new_reads_as_effectcraft_words_it() { + let Err(e) = check_schema(3) else { panic!("schema 3 is newer than 2") }; + assert_eq!(e.to_string(), "project schema 3 is newer than this version supports (2)"); +}