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)"); +}