From 30b1e426e0ef07e0606e0ab09ec540eca5665bb6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:49:46 +0000 Subject: [PATCH] feat(figcraft-format): stream native document JSON --- crates/figcraft-format/src/lib.rs | 57 +++++++++++++++++++++++++++++++ crates/zip/src/write.rs | 18 +++++----- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index fd2c6ecf2..08b9bcb9a 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -188,3 +188,60 @@ mod archive_tests { assert!(small_total.read("document.json", 100).is_err()); } } + +/// A native archive writer that keeps the streaming document serializer out of the consumer. +pub struct ArchiveWriter { + inner: craft_zip::Writer, +} + +impl ArchiveWriter { + pub fn new() -> Self { + Self { inner: craft_zip::Writer::new() } + } + + pub fn add(&mut self, name: &str, bytes: &[u8], compression: craft_zip::Compression) -> Result<(), craft_zip::Error> { + self.inner.add(name, bytes, compression) + } + + pub fn add_document_json(&mut self, name: &str, document: &D, images: &[ImageIndexEntry]) -> Result<(), ArchiveWriteError> { + self.inner + .add_with(name, craft_zip::Compression::Level(3), |w| write_document_json(document, images, w).map_err(std::io::Error::other)) + .map_err(ArchiveWriteError::Archive) + } + + pub fn finish(self) -> Result, craft_zip::Error> { + self.inner.finish() + } +} + +impl Default for ArchiveWriter { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ArchiveWriteError { + #[error("could not serialize native document JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("could not write native archive: {0}")] + Archive(#[from] craft_zip::Error), +} + +#[cfg(test)] +mod streaming_writer_tests { + use super::*; + + #[test] + fn native_writer_streams_flat_document_into_archive() { + let document = serde_json::json!({"name":"streamed"}); + let mut writer = ArchiveWriter::new(); + writer.add_document_json("document.json", &document, &[]).unwrap(); + let bytes = writer.finish().unwrap(); + let mut archive = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 4096 }).unwrap(); + let json = archive.read("document.json", 1024).unwrap().unwrap(); + let value: Value = serde_json::from_slice(&json).unwrap(); + assert_eq!(value["name"], "streamed"); + assert_eq!(value["format"], FORMAT); + } +} diff --git a/crates/zip/src/write.rs b/crates/zip/src/write.rs index 2f5d7ca47..12838a3b8 100644 --- a/crates/zip/src/write.rs +++ b/crates/zip/src/write.rs @@ -33,22 +33,24 @@ impl Writer { Writer::default() } - /// Adds an entry. A name the archive already has is an error. + /// Adds an entry from a byte slice. A name the archive already has is an error. pub fn add(&mut self, name: &str, data: &[u8], compression: Compression) -> Result<(), Error> { + self.add_with(name, compression, |writer| writer.write_all(data)) + } + + /// Adds an entry by streaming it into the compressor, so large generated entries need not + /// first be buffered in memory. A name the archive already has is an error. + pub fn add_with(&mut self, name: &str, compression: Compression, write: impl FnOnce(&mut dyn Write) -> std::io::Result<()>) -> Result<(), Error> { let (method, level) = match compression { Compression::Stored => (zip::CompressionMethod::Stored, None), Compression::Deflated => (zip::CompressionMethod::Deflated, Some(6)), Compression::Level(l) => (zip::CompressionMethod::Deflated, Some(i64::from(l.min(9)))), }; - let options = zip::write::SimpleFileOptions::default() - .compression_method(method) - .compression_level(level) - .last_modified_time(zip::DateTime::default()) - // Deflate can grow incompressible data a little, so ZIP64 well before 4 GiB. - .large_file(data.len() as u64 >= u64::from(u32::MAX) / 2); + let options = + zip::write::SimpleFileOptions::default().compression_method(method).compression_level(level).last_modified_time(zip::DateTime::default()); let fail = |e: &dyn std::fmt::Display| Error::Write(format!("{name}: {e}")); self.zip.start_file(name, options).map_err(|e| fail(&e))?; - self.zip.write_all(data).map_err(|e| fail(&e)) + write(&mut self.zip).map_err(|e| fail(&e)) } /// The finished archive.