mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-07 12:13:20 +00:00
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].
This commit is contained in:
@@ -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
|
||||
Generated
+9
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -45,6 +45,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
- [`craft-docpath`](crates/docpath/src/lib.rs): decides whether a path or `file:` URL stored in a
|
||||
document may be opened. Paths stored in documents are followed only when they are local or on the
|
||||
document's own network share; other network paths, device paths and non-file URLs are refused.
|
||||
- [`craft-ecproj-format`](crates/ecproj-format/README.md): the envelope of EffectCraft `.ecproj`
|
||||
project files: `savedBy`, the schema version and its check, and the file object with the
|
||||
project and its saved view as type parameters. The project model stays in EffectCraft.
|
||||
- [`craft-font-catalog`](crates/font-catalog/README.md): the installed fonts on every platform
|
||||
(font folders, fontconfig and Flatpak host folders, CoreText, DirectWrite, the font store),
|
||||
lookup by family, style, localized, legacy, full and PostScript names, family loads shared
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "craft-ecproj-format"
|
||||
description = "The envelope of EffectCraft .ecproj project files: savedBy, the schema version and its check, and the file object with the project and its saved view as type parameters."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,49 @@
|
||||
# craft-ecproj-format
|
||||
|
||||
The envelope of EffectCraft `.ecproj` project files: the members around the project that
|
||||
EffectCraft writes and other apps read. The project model, the saved view and the schema 1
|
||||
conversion stay in EffectCraft. No `unsafe`, no panics, and it builds for
|
||||
`wasm32-unknown-unknown`. The dependencies are `serde`, `serde_json` and `thiserror`.
|
||||
|
||||
```toml
|
||||
craft-ecproj-format = { git = "https://github.com/storytold/craft-libs", rev = "<commit>" }
|
||||
```
|
||||
|
||||
## The file
|
||||
|
||||
A project file is one UTF-8 JSON object with no newline at the end: `savedBy`, then the saved
|
||||
view's members (`activeComp`, `openComps`, `compTimes`, each left out when empty), then the
|
||||
project's members, starting with `schema`. Projects saved before `savedBy` was added start with
|
||||
`schema`. EffectCraft indents the object with two spaces, or writes it on one line when Settings ▸
|
||||
Project ▸ Save Compact Project Files is on. The crate writes and reads both forms.
|
||||
|
||||
## Use
|
||||
|
||||
```rust
|
||||
use craft_ecproj_format::{File, Head, check_schema};
|
||||
|
||||
// Writing
|
||||
check_schema(project.schema)?;
|
||||
let text = File::new(APP_VERSION, &view, &project).to_json()?;
|
||||
|
||||
// Reading
|
||||
let head = Head::read(&text)?;
|
||||
```
|
||||
|
||||
- `SCHEMA_VERSION` is the newest project schema, 2.
|
||||
- `check_schema(schema) -> 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<Head, ReadError>` 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.
|
||||
@@ -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<u64, String>,
|
||||
//! }
|
||||
//!
|
||||
//! 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::<String, u64>::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<dyn std::error::Error>>(())
|
||||
//! ```
|
||||
//!
|
||||
//! 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<String, serde_json::Error> {
|
||||
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<String, serde_json::Error> {
|
||||
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<String>,
|
||||
/// 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<Head, ReadError> {
|
||||
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<String> {
|
||||
/// 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<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<A: MapAccess<'de>>(self, mut map: A) -> Result<Head, A::Error> {
|
||||
let mut saved_by = None;
|
||||
let mut seen_saved_by = false;
|
||||
let mut schema = None;
|
||||
while let Some(key) = map.next_key::<Cow<'de, str>>()? {
|
||||
match &*key {
|
||||
"savedBy" if seen_saved_by => {
|
||||
map.next_value::<IgnoredAny>()?;
|
||||
saved_by = None;
|
||||
}
|
||||
"savedBy" => {
|
||||
saved_by = map.next_value::<SavedBy>()?.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::<IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
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<String>);
|
||||
|
||||
impl<'de> Deserialize<'de> for SavedBy {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
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<E: de::Error>(self, v: &str) -> Result<SavedBy, E> {
|
||||
Ok(SavedBy(Some(v.to_owned())))
|
||||
}
|
||||
|
||||
fn visit_string<E: de::Error>(self, v: String) -> Result<SavedBy, E> {
|
||||
Ok(SavedBy(Some(v)))
|
||||
}
|
||||
|
||||
fn visit_bool<E: de::Error>(self, _: bool) -> Result<SavedBy, E> {
|
||||
Ok(SavedBy(None))
|
||||
}
|
||||
|
||||
fn visit_i64<E: de::Error>(self, _: i64) -> Result<SavedBy, E> {
|
||||
Ok(SavedBy(None))
|
||||
}
|
||||
|
||||
fn visit_i128<E: de::Error>(self, _: i128) -> Result<SavedBy, E> {
|
||||
Ok(SavedBy(None))
|
||||
}
|
||||
|
||||
fn visit_u64<E: de::Error>(self, _: u64) -> Result<SavedBy, E> {
|
||||
Ok(SavedBy(None))
|
||||
}
|
||||
|
||||
fn visit_u128<E: de::Error>(self, _: u128) -> Result<SavedBy, E> {
|
||||
Ok(SavedBy(None))
|
||||
}
|
||||
|
||||
fn visit_f64<E: de::Error>(self, _: f64) -> Result<SavedBy, E> {
|
||||
Ok(SavedBy(None))
|
||||
}
|
||||
|
||||
fn visit_unit<E: de::Error>(self) -> Result<SavedBy, E> {
|
||||
Ok(SavedBy(None))
|
||||
}
|
||||
|
||||
fn visit_none<E: de::Error>(self) -> Result<SavedBy, E> {
|
||||
Ok(SavedBy(None))
|
||||
}
|
||||
|
||||
fn visit_some<D: Deserializer<'de>>(self, deserializer: D) -> Result<SavedBy, D::Error> {
|
||||
deserializer.deserialize_ignored_any(IgnoredAny)?;
|
||||
Ok(SavedBy(None))
|
||||
}
|
||||
|
||||
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<SavedBy, A::Error> {
|
||||
while seq.next_element::<IgnoredAny>()?.is_some() {}
|
||||
Ok(SavedBy(None))
|
||||
}
|
||||
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<SavedBy, A::Error> {
|
||||
while map.next_entry::<IgnoredAny, IgnoredAny>()?.is_some() {}
|
||||
Ok(SavedBy(None))
|
||||
}
|
||||
}
|
||||
@@ -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<u64>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
open_comps: Vec<u64>,
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
comp_times: BTreeMap<u64, i64>,
|
||||
}
|
||||
|
||||
#[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<u64, Item>,
|
||||
next_id: u64,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
render_queue: Vec<u64>,
|
||||
}
|
||||
|
||||
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::<Project>(&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::<serde_json::Value>(&text).unwrap(), serde_json::from_str::<serde_json::Value>(&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::<Project>(&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)");
|
||||
}
|
||||
Reference in New Issue
Block a user