From 8cdd25ce0a307bbe073e2af217d68068ec558f95 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:38:38 +0000 Subject: [PATCH] feat(figcraft-format): add native document header contract --- Cargo.lock | 9 ++++ Cargo.toml | 1 + crates/figcraft-format/Cargo.toml | 16 +++++++ crates/figcraft-format/src/lib.rs | 77 +++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 crates/figcraft-format/Cargo.toml create mode 100644 crates/figcraft-format/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 266217a1a..1bbfe7965 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -899,6 +899,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-figcraft-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 a32e1df27..b07dac4be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ craft-tiff = { path = "crates/tiff" } craft-ui = { path = "crates/ui" } craft-update = { path = "crates/update" } craft-vectorcraft-format = { path = "crates/vectorcraft-format" } +craft-figcraft-format = { path = "crates/figcraft-format" } craft-xml = { path = "crates/xml" } craft-video = { path = "crates/video" } craft-web-api = { path = "crates/web-api" } diff --git a/crates/figcraft-format/Cargo.toml b/crates/figcraft-format/Cargo.toml new file mode 100644 index 000000000..3275fbcd6 --- /dev/null +++ b/crates/figcraft-format/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "craft-figcraft-format" +description = "The native FigCraft document JSON and archive contract." +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 } diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs new file mode 100644 index 000000000..5df23cf24 --- /dev/null +++ b/crates/figcraft-format/src/lib.rs @@ -0,0 +1,77 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! The neutral JSON header and version contract for FigCraft's native project format. +//! App-specific document fields remain a generic flattened payload. +use serde::de::{IgnoredAny, MapAccess, Visitor}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const EXTENSION: &str = "figcraft"; +pub const FORMAT: &str = "figcraft"; +pub const VERSION: u32 = 1; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Header { pub version: Option, pub images: Option> } + +pub fn read_header(text: &str) -> Result { serde_json::from_str(text) } + +impl<'de> Deserialize<'de> for Header { + fn deserialize>(d: D) -> Result { + struct V; + impl<'de> Visitor<'de> for V { + type Value = Header; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("a FigCraft document object") } + fn visit_map>(self, mut m: A) -> Result { + let mut h = Header::default(); + while let Some(k) = m.next_key::()? { + match k.as_str() { + "version" => h.version = m.next_value::()?.as_u64(), + "imageIndex" => h.images = m.next_value::()?.as_array().map(|a| a.iter().filter_map(|v| v.get("hash").and_then(Value::as_str).map(str::to_owned)).collect()), + _ => { m.next_value::()?; } + } + } + Ok(h) + } + } + d.deserialize_map(V) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +#[error("this file was saved by a newer FigCraft (format version {0})")] +pub struct NewerVersion(pub u32); + +pub fn check_version(h: &Header) -> Result<(), NewerVersion> { + if let Some(v) = h.version && v > u64::from(VERSION) { return Err(NewerVersion(u32::try_from(v).unwrap_or(u32::MAX))); } + Ok(()) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ImageIndexEntry { pub hash: String, pub width: u32, pub height: u32 } + +pub fn write_document_json(doc: &D, images: &[ImageIndexEntry], w: W) -> Result<(), serde_json::Error> { + #[derive(Serialize)] + struct Flat<'a, D: ?Sized> { format: &'static str, version: u32, #[serde(rename="imageIndex")] images: &'a [ImageIndexEntry], #[serde(flatten)] document: &'a D } + Flat { format: FORMAT, version: VERSION, images, document: doc }.serialize(&mut serde_json::Serializer::new(w)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn header_skips_app_fields() { + let h = read_header(r#"{"version":1,"imageIndex":[{"hash":"abc"}],"nested":{"unused":true}}"#).unwrap(); + assert_eq!(h.version, Some(1)); assert_eq!(h.images, Some(vec!["abc".into()])); + } + #[test] + fn newer_version_is_reported_before_document_decode() { + assert_eq!(check_version(&Header { version: Some(2), images: None }), Err(NewerVersion(2))); + assert_eq!(check_version(&Header { version: Some(u64::MAX), images: None }), Err(NewerVersion(u32::MAX))); + } + #[test] + fn writer_flattens_document_fields() { + let doc = serde_json::json!({"name":"example"}); let mut bytes=Vec::new(); + write_document_json(&doc, &[], &mut bytes).unwrap(); + let v: Value=serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["format"], FORMAT); assert_eq!(v["version"], VERSION); assert_eq!(v["name"], "example"); + } +}