From 5df4b49f8c7b9c9c909d1aa0c0b28e85f063e7f1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 02:46:10 +0000 Subject: [PATCH 1/3] feat(anicraft-format): extract versioned JSON envelope --- Cargo.lock | 8 + Cargo.toml | 1 + crates/anicraft-format/Cargo.toml | 16 ++ crates/anicraft-format/src/lib.rs | 252 ++++++++++++++++++++++++++++++ 4 files changed, 277 insertions(+) create mode 100644 crates/anicraft-format/Cargo.toml create mode 100644 crates/anicraft-format/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index e3503e90f..1df74b0b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1211,6 +1211,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-anicraft-format" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "craft-assuan" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 033e961b3..75211126d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ craft-platform = { path = "crates/platform" } craft-aep = { path = "crates/aep" } craft-affinity = { path = "crates/affinity" } craft-ai-core = { path = "crates/ai-core" } +craft-anicraft-format = { path = "crates/anicraft-format" } craft-pdf = { path = "crates/pdf", version = "=0.1.0" } craft-pdn = { path = "crates/pdn" } craft-plate-test = { path = "crates/plate-test" } diff --git a/crates/anicraft-format/Cargo.toml b/crates/anicraft-format/Cargo.toml new file mode 100644 index 000000000..8dad7af33 --- /dev/null +++ b/crates/anicraft-format/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "craft-anicraft-format" +description = "Versioned JSON envelope codec for AniCraft native documents" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + diff --git a/crates/anicraft-format/src/lib.rs b/crates/anicraft-format/src/lib.rs new file mode 100644 index 000000000..6741c322c --- /dev/null +++ b/crates/anicraft-format/src/lib.rs @@ -0,0 +1,252 @@ +//! The versioned JSON envelope used by AniCraft's native project file. +//! +//! The app owns its document model and the path-table scope used while serializing it. This +//! crate owns the format envelope: field order, duplicate-field handling, and the point at which +//! the caller decodes the document relative to the `paths` field. +//! +//! [`read`] passes the actual nested Serde deserializer to the caller's [`PayloadDecoder`]. That +//! keeps payload parsing in the app's model crate while preserving the original stream order, +//! global JSON error locations, and path-table guard lifetime. + +use serde::{ + Serialize, + de::{Deserialize, DeserializeSeed, MapAccess, Visitor}, +}; +use std::{fmt, marker::PhantomData}; + +/// The largest native project file AniCraft accepts. +pub const MAX_FILE_BYTES: u64 = 1024 * 1024 * 1024; +/// Files below this size are pretty-printed for diffs and manual inspection. +pub const PRETTY_LIMIT: usize = 1024 * 1024; + +#[derive(Serialize)] +struct EnvelopeOut<'a, P, T> { + format: &'a str, + version: u32, + generator: &'a str, + paths: &'a [P], + document: &'a T, +} + +/// Serializes the envelope in its stable wire order and uses AniCraft's compact/pretty threshold. +pub fn write( + format: &str, + version: u32, + generator: &str, + paths: &[P], + document: &T, +) -> Result { + let envelope = EnvelopeOut { format, version, generator, paths, document }; + let compact = serde_json::to_string(&envelope)?; + if compact.len() < PRETTY_LIMIT { serde_json::to_string_pretty(&envelope) } else { Ok(compact) } +} + +/// Decodes one native-document payload under the path table that preceded it in the envelope. +pub trait PayloadDecoder

{ + type Document; + + fn decode<'de, D>(&mut self, deserializer: D, paths: Option>) -> Result + where + D: serde::Deserializer<'de>; +} + +/// The parsed top-level format, version and document payload. +#[derive(Debug, PartialEq)] +pub struct Envelope { + pub format: String, + pub version: u32, + /// Missing is distinct from JSON `null`, which the payload decoder decides whether to accept. + pub document: Option, +} + +struct DocumentSeed<'a, P, D> { + decoder: &'a mut D, + paths: Option>, +} + +impl<'de, P, D> DeserializeSeed<'de> for DocumentSeed<'_, P, D> +where + D: PayloadDecoder

, +{ + type Value = D::Document; + + fn deserialize(self, deserializer: De) -> Result + where + De: serde::Deserializer<'de>, + { + self.decoder.decode(deserializer, self.paths) + } +} + +struct EnvelopeSeed<'a, P, D> { + decoder: &'a mut D, + marker: PhantomData P>, +} + +impl<'de, P, D> DeserializeSeed<'de> for EnvelopeSeed<'_, P, D> +where + P: Deserialize<'de> + Clone, + D: PayloadDecoder

, +{ + type Value = Envelope; + + fn deserialize(self, deserializer: De) -> Result + where + De: serde::Deserializer<'de>, + { + struct EnvelopeVisitor<'a, P, D> { + decoder: &'a mut D, + marker: PhantomData P>, + } + + impl<'de, P, D> Visitor<'de> for EnvelopeVisitor<'_, P, D> + where + P: Deserialize<'de> + Clone, + D: PayloadDecoder

, + { + type Value = Envelope; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an AniCraft native project envelope") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let (mut format, mut version, mut paths, mut document) = (None, None, None, None); + while let Some(key) = map.next_key::()? { + match key.as_str() { + "format" => format = Some(map.next_value::()?), + "version" => version = Some(map.next_value::()?), + "paths" => paths = Some(map.next_value::>()?), + "document" => { + document = Some(map.next_value_seed(DocumentSeed { decoder: self.decoder, paths: paths.clone() })?); + } + _ => { + map.next_value::()?; + } + } + } + Ok(Envelope { + format: format.ok_or_else(|| serde::de::Error::missing_field("format"))?, + version: version.ok_or_else(|| serde::de::Error::missing_field("version"))?, + document, + }) + } + } + + deserializer.deserialize_map(EnvelopeVisitor { decoder: self.decoder, marker: PhantomData }) + } +} + +/// Reads the envelope while delegating its typed document payload to `decoder`. +/// +/// Unknown top-level fields are ignored. Repeated recognized keys use the last successful value, +/// and each `document` is decoded immediately with the latest preceding `paths` field. A +/// malformed earlier `document` therefore errors before a later duplicate is considered, just as +/// it does with a streaming Serde visitor. +pub fn read<'de, P, D>(json: &'de str, decoder: &mut D) -> Result, serde_json::Error> +where + P: Deserialize<'de> + Clone, + D: PayloadDecoder

, +{ + let mut deserializer = serde_json::Deserializer::from_str(json); + let envelope = EnvelopeSeed { decoder, marker: PhantomData }.deserialize(&mut deserializer)?; + deserializer.end()?; + Ok(envelope) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + use serde_json::Value; + + #[derive(Debug, Deserialize, Serialize, PartialEq)] + struct Document { + value: u32, + } + + #[derive(Default)] + struct CaptureDocument { + calls: usize, + } + + impl PayloadDecoder for CaptureDocument { + type Document = (Document, Option>); + + fn decode<'de, D>(&mut self, deserializer: D, paths: Option>) -> Result + where + D: serde::Deserializer<'de>, + { + self.calls += 1; + Ok((Document::deserialize(deserializer)?, paths)) + } + } + + struct ValueDocument; + + impl PayloadDecoder for ValueDocument { + type Document = (Value, Option>); + + fn decode<'de, D>(&mut self, deserializer: D, paths: Option>) -> Result + where + D: serde::Deserializer<'de>, + { + Ok((Value::deserialize(deserializer)?, paths)) + } + } + + #[test] + fn writer_keeps_the_versioned_field_order_and_pretty_threshold() -> Result<(), Box> { + let paths = ["path-a", "path-b"]; + let document = Document { value: 3 }; + let json = write("anicraft", 3, "AniCraft 0.1", &paths, &document)?; + let keys = ["\"format\"", "\"version\"", "\"generator\"", "\"paths\"", "\"document\""]; + let offsets: Vec = keys.iter().map(|key| json.find(key).unwrap_or(usize::MAX)).collect(); + assert!(offsets.windows(2).all(|pair| pair[0] < pair[1])); + assert!(json.contains("\n \"format\""), "small envelopes stay human-readable"); + Ok(()) + } + + #[test] + fn repeated_fields_and_paths_before_document_keep_streaming_semantics() -> Result<(), Box> { + let json = r#"{"format":"old","format":"anicraft","version":1,"paths":["first"],"paths":["chosen"],"document":{"value":7},"ignored":true}"#; + let mut decoder = CaptureDocument::default(); + let envelope = read::(json, &mut decoder)?; + assert_eq!(envelope.format, "anicraft"); + assert_eq!(envelope.version, 1); + assert_eq!(envelope.document, Some((Document { value: 7 }, Some(vec!["chosen".to_string()])))); + assert_eq!(decoder.calls, 1); + Ok(()) + } + + #[test] + fn paths_after_document_do_not_retroactively_change_its_scope() -> Result<(), Box> { + let json = r#"{"format":"anicraft","version":1,"document":{"value":1},"paths":["later"]}"#; + let mut decoder = ValueDocument; + let envelope = read::(json, &mut decoder)?; + assert_eq!(envelope.document, Some((serde_json::json!({"value":1}), None))); + Ok(()) + } + + #[test] + fn malformed_earlier_duplicate_document_stops_before_later_document() { + let json = r#"{"format":"anicraft","version":1,"document":null,"document":{"value":2}}"#; + let mut decoder = CaptureDocument::default(); + let result = read::(json, &mut decoder); + assert!(result.is_err()); + assert_eq!(decoder.calls, 1); + } + + #[test] + fn duplicate_documents_use_the_last_successful_value() -> Result<(), Box> { + let json = r#"{"format":"anicraft","version":1,"document":{"value":1},"document":{"value":2}}"#; + let mut decoder = CaptureDocument::default(); + let envelope = read::(json, &mut decoder)?; + assert_eq!(envelope.document, Some((Document { value: 2 }, None))); + assert_eq!(decoder.calls, 2); + Ok(()) + } +} From 9a2dafbf7d3e52d40076aa7acd11e09e2f33777d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 02:47:40 +0000 Subject: [PATCH 2/3] Move native format identity into shared crate --- crates/anicraft-format/src/lib.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/anicraft-format/src/lib.rs b/crates/anicraft-format/src/lib.rs index 6741c322c..030356a59 100644 --- a/crates/anicraft-format/src/lib.rs +++ b/crates/anicraft-format/src/lib.rs @@ -14,6 +14,10 @@ use serde::{ }; use std::{fmt, marker::PhantomData}; +/// The native project envelope's identifying format string. +pub const FORMAT_NAME: &str = "anicraft"; +/// Current envelope version (v1 inline paths; v2 shared path table; v3 named scene timelines). +pub const FORMAT_VERSION: u32 = 3; /// The largest native project file AniCraft accepts. pub const MAX_FILE_BYTES: u64 = 1024 * 1024 * 1024; /// Files below this size are pretty-printed for diffs and manual inspection. @@ -29,14 +33,8 @@ struct EnvelopeOut<'a, P, T> { } /// Serializes the envelope in its stable wire order and uses AniCraft's compact/pretty threshold. -pub fn write( - format: &str, - version: u32, - generator: &str, - paths: &[P], - document: &T, -) -> Result { - let envelope = EnvelopeOut { format, version, generator, paths, document }; +pub fn write(generator: &str, paths: &[P], document: &T) -> Result { + let envelope = EnvelopeOut { format: FORMAT_NAME, version: FORMAT_VERSION, generator, paths, document }; let compact = serde_json::to_string(&envelope)?; if compact.len() < PRETTY_LIMIT { serde_json::to_string_pretty(&envelope) } else { Ok(compact) } } @@ -202,11 +200,13 @@ mod tests { fn writer_keeps_the_versioned_field_order_and_pretty_threshold() -> Result<(), Box> { let paths = ["path-a", "path-b"]; let document = Document { value: 3 }; - let json = write("anicraft", 3, "AniCraft 0.1", &paths, &document)?; + let json = write("AniCraft 0.1", &paths, &document)?; let keys = ["\"format\"", "\"version\"", "\"generator\"", "\"paths\"", "\"document\""]; let offsets: Vec = keys.iter().map(|key| json.find(key).unwrap_or(usize::MAX)).collect(); assert!(offsets.windows(2).all(|pair| pair[0] < pair[1])); assert!(json.contains("\n \"format\""), "small envelopes stay human-readable"); + assert!(json.contains("\"format\": \"anicraft\"")); + assert!(json.contains("\"version\": 3")); Ok(()) } From 0b1688098abcd27787adfb86d35b2e168f9d668d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 03:12:01 +0000 Subject: [PATCH 3/3] fix(anicraft): preserve invalid envelope diagnostic --- crates/anicraft-format/src/lib.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/anicraft-format/src/lib.rs b/crates/anicraft-format/src/lib.rs index 030356a59..7cc5dc261 100644 --- a/crates/anicraft-format/src/lib.rs +++ b/crates/anicraft-format/src/lib.rs @@ -105,7 +105,7 @@ where type Value = Envelope; fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("an AniCraft native project envelope") + formatter.write_str("an .anicraft envelope object") } fn visit_map(self, mut map: A) -> Result @@ -240,6 +240,14 @@ mod tests { assert_eq!(decoder.calls, 1); } + #[test] + fn malformed_top_level_keeps_the_native_envelope_diagnostic() { + let mut decoder = CaptureDocument::default(); + let error = read::("[]", &mut decoder).unwrap_err(); + assert!(error.to_string().contains("expected an .anicraft envelope object")); + assert_eq!(decoder.calls, 0); + } + #[test] fn duplicate_documents_use_the_last_successful_value() -> Result<(), Box> { let json = r#"{"format":"anicraft","version":1,"document":{"value":1},"document":{"value":2}}"#;