hugr_core/
envelope.rs

1//! Envelope format for HUGR packages.
2//!
3//! The format is designed to be extensible and backwards-compatible. It
4//! consists of a header declaring the format used to encode the HUGR, followed
5//! by the encoded HUGR itself.
6//!
7//! Use [`read_envelope`] and [`write_envelope`] for reading and writing
8//! envelopes from/to readers and writers, or call [`Package::load`] and
9//! [`Package::store`] directly.
10//!
11//! ## Payload formats
12//!
13//! The envelope may encode the HUGR in different formats, listed in
14//! [`EnvelopeFormat`]. The payload may also be compressed with zstd.
15//!
16//! Some formats can be represented as ASCII, as indicated by the
17//! [`EnvelopeFormat::ascii_printable`] method. When this is the case, the
18//! whole envelope can be stored in a string.
19//!
20//! ## Envelope header
21//!
22//! The binary header format is 10 bytes, with the following fields:
23//!
24//! | Field  | Size (bytes) | Description |
25//! |--------|--------------|-------------|
26//! | Magic  | 8            | [`MAGIC_NUMBERS`] constant identifying the envelope format. |
27//! | Format | 1            | [`EnvelopeFormat`] describing the payload format. |
28//! | Flags  | 1            | Additional configuration flags. |
29//!
30//! Flags:
31//!
32//! - Bit 0: Whether the payload is compressed with zstd.
33//! - Bits 1-5: Reserved for future use.
34//! - Bit 7,6: Constant "01" to make some headers ascii-printable.
35//!
36
37#![allow(deprecated)]
38// TODO: Due to a bug in `derive_more`
39// (https://github.com/JelteF/derive_more/issues/419) we need to deactivate
40// deprecation warnings here. We can reactivate them once the bug is fixed by
41// https://github.com/JelteF/derive_more/pull/454.
42
43mod header;
44mod package_json;
45pub mod serde_with;
46
47pub use header::{EnvelopeConfig, EnvelopeFormat, MAGIC_NUMBERS, ZstdConfig};
48pub use package_json::PackageEncodingError;
49
50use crate::{Hugr, HugrView};
51use crate::{
52    extension::{ExtensionRegistry, Version},
53    package::Package,
54};
55use header::EnvelopeHeader;
56use std::io::BufRead;
57use std::io::Write;
58use std::str::FromStr;
59use thiserror::Error;
60
61#[allow(unused_imports)]
62use itertools::Itertools as _;
63
64use crate::import::ImportError;
65use crate::{Extension, import::import_package};
66
67/// Key used to store the name of the generator that produced the envelope.
68pub const GENERATOR_KEY: &str = "core.generator";
69/// Key used to store the list of used extensions in the metadata of a HUGR.
70pub const USED_EXTENSIONS_KEY: &str = "core.used_extensions";
71
72/// Get the name of the generator from the metadata of the HUGR modules.
73///
74/// If multiple modules have different generators, a comma-separated list is returned in
75/// module order.
76/// If no generator is found, `None` is returned.
77pub fn get_generator<H: HugrView>(modules: &[H]) -> Option<String> {
78    let generators: Vec<String> = modules
79        .iter()
80        .filter_map(|hugr| hugr.get_metadata(hugr.module_root(), GENERATOR_KEY))
81        .map(format_generator)
82        .collect();
83    if generators.is_empty() {
84        return None;
85    }
86
87    Some(generators.join(", "))
88}
89
90/// Format a generator value from the metadata.
91pub fn format_generator(json_val: &serde_json::Value) -> String {
92    match json_val {
93        serde_json::Value::String(s) => s.clone(),
94        serde_json::Value::Object(obj) => {
95            if let (Some(name), version) = (
96                obj.get("name").and_then(|v| v.as_str()),
97                obj.get("version").and_then(|v| v.as_str()),
98            ) {
99                if let Some(version) = version {
100                    // Expected format: {"name": "generator", "version": "1.0.0"}
101                    format!("{name}-v{version}")
102                } else {
103                    name.to_string()
104                }
105            } else {
106                // just print the whole object as a string
107                json_val.to_string()
108            }
109        }
110        // Raw JSON string fallback
111        _ => json_val.to_string(),
112    }
113}
114
115fn gen_str(generator: &Option<String>) -> String {
116    match generator {
117        Some(g) => format!("\ngenerated by {g}"),
118        None => String::new(),
119    }
120}
121
122/// Wrap an error with a generator string.
123#[derive(Error, Debug)]
124#[error("{inner}{}", gen_str(&self.generator))]
125pub struct WithGenerator<E: std::fmt::Display> {
126    inner: Box<E>,
127    /// The name of the generator that produced the envelope, if any.
128    generator: Option<String>,
129}
130
131impl<E: std::fmt::Display> WithGenerator<E> {
132    fn new(err: E, modules: &[impl HugrView]) -> Self {
133        Self {
134            inner: Box::new(err),
135            generator: get_generator(modules),
136        }
137    }
138
139    /// Get a reference to the inner error.
140    pub fn inner(&self) -> &E {
141        &self.inner
142    }
143
144    /// Get the name of the generator that produced the envelope, if any.
145    pub fn generator(&self) -> Option<&String> {
146        self.generator.as_ref()
147    }
148}
149
150/// Read a HUGR envelope from a reader.
151///
152/// Returns the deserialized package and the configuration used to encode it.
153///
154/// Parameters:
155/// - `reader`: The reader to read the envelope from.
156/// - `registry`: An extension registry with additional extensions to use when
157///   decoding the HUGR, if they are not already included in the package.
158pub fn read_envelope(
159    mut reader: impl BufRead,
160    registry: &ExtensionRegistry,
161) -> Result<(EnvelopeConfig, Package), EnvelopeError> {
162    let header = EnvelopeHeader::read(&mut reader)?;
163
164    let package = match header.zstd {
165        #[cfg(feature = "zstd")]
166        true => read_impl(
167            std::io::BufReader::new(zstd::Decoder::new(reader)?),
168            header,
169            registry,
170        ),
171        #[cfg(not(feature = "zstd"))]
172        true => Err(EnvelopeError::ZstdUnsupported),
173        false => read_impl(reader, header, registry),
174    }?;
175    Ok((header.config(), package))
176}
177
178/// Write a HUGR package into an envelope, using the specified configuration.
179///
180/// It is recommended to use a buffered writer for better performance.
181/// See [`std::io::BufWriter`] for more information.
182pub fn write_envelope(
183    writer: impl Write,
184    package: &Package,
185    config: EnvelopeConfig,
186) -> Result<(), EnvelopeError> {
187    write_envelope_impl(writer, &package.modules, &package.extensions, config)
188}
189
190/// Write a deconstructed HUGR package into an envelope, using the specified configuration.
191///
192/// It is recommended to use a buffered writer for better performance.
193/// See [`std::io::BufWriter`] for more information.
194pub(crate) fn write_envelope_impl<'h>(
195    mut writer: impl Write,
196    hugrs: impl IntoIterator<Item = &'h Hugr>,
197    extensions: &ExtensionRegistry,
198    config: EnvelopeConfig,
199) -> Result<(), EnvelopeError> {
200    let header = config.make_header();
201    header.write(&mut writer)?;
202
203    match config.zstd {
204        #[cfg(feature = "zstd")]
205        Some(zstd) => {
206            let writer = zstd::Encoder::new(writer, zstd.level())?.auto_finish();
207            write_impl(writer, hugrs, extensions, config)?;
208        }
209        #[cfg(not(feature = "zstd"))]
210        Some(_) => return Err(EnvelopeError::ZstdUnsupported),
211        None => write_impl(writer, hugrs, extensions, config)?,
212    }
213
214    Ok(())
215}
216
217/// Error type for envelope operations.
218#[derive(Debug, Error)]
219#[non_exhaustive]
220pub enum EnvelopeError {
221    /// Bad magic number.
222    #[error(
223        "Bad magic number. expected 0x{:X} found 0x{:X}",
224        u64::from_be_bytes(*expected),
225        u64::from_be_bytes(*found)
226    )]
227    MagicNumber {
228        /// The expected magic number.
229        ///
230        /// See [`MAGIC_NUMBERS`].
231        expected: [u8; 8],
232        /// The magic number in the envelope.
233        found: [u8; 8],
234    },
235    /// The specified payload format is invalid.
236    #[error("Format descriptor {descriptor} is invalid.")]
237    InvalidFormatDescriptor {
238        /// The unsupported format.
239        descriptor: usize,
240    },
241    /// The specified payload format is not supported.
242    #[error("Payload format {format} is not supported.{}",
243        match feature {
244            Some(f) => format!(" This requires the '{f}' feature for `hugr`."),
245            None => String::new()
246        },
247    )]
248    FormatUnsupported {
249        /// The unsupported format.
250        format: EnvelopeFormat,
251        /// Optionally, the feature required to support this format.
252        feature: Option<&'static str>,
253    },
254    /// Not all envelope formats can be represented as ASCII.
255    ///
256    /// This error is used when trying to store the envelope into a string.
257    #[error("Envelope format {format} cannot be represented as ASCII.")]
258    NonASCIIFormat {
259        /// The unsupported format.
260        format: EnvelopeFormat,
261    },
262    /// Envelope encoding required zstd compression, but the feature is not enabled.
263    #[error("Zstd compression is not supported. This requires the 'zstd' feature for `hugr`.")]
264    ZstdUnsupported,
265    /// Expected the envelope to contain a single HUGR.
266    #[error("Expected an envelope containing a single hugr, but it contained {}.", if *count == 0 {
267        "none".to_string()
268    } else {
269        count.to_string()
270    })]
271    ExpectedSingleHugr {
272        /// The number of HUGRs in the package.
273        count: usize,
274    },
275    /// JSON serialization error.
276    #[error(transparent)]
277    SerdeError {
278        /// The source error.
279        #[from]
280        source: serde_json::Error,
281    },
282    /// IO read/write error.
283    #[error(transparent)]
284    IO {
285        /// The source error.
286        #[from]
287        source: std::io::Error,
288    },
289    /// Error writing a json package to the payload.
290    #[error(transparent)]
291    PackageEncoding {
292        /// The source error.
293        #[from]
294        source: PackageEncodingError,
295    },
296    /// Error importing a HUGR from a hugr-model payload.
297    #[error(transparent)]
298    ModelImport {
299        /// The source error.
300        #[from]
301        source: ImportError,
302        // TODO add generator to model import errors
303    },
304    /// Error reading a HUGR model payload.
305    #[error(transparent)]
306    ModelRead {
307        /// The source error.
308        #[from]
309        source: hugr_model::v0::binary::ReadError,
310    },
311    /// Error writing a HUGR model payload.
312    #[error(transparent)]
313    ModelWrite {
314        /// The source error.
315        #[from]
316        source: hugr_model::v0::binary::WriteError,
317    },
318    /// Error reading a HUGR model payload.
319    #[error("Model text parsing error")]
320    ModelTextRead {
321        /// The source error.
322        #[from]
323        source: hugr_model::v0::ast::ParseError,
324    },
325    /// Error reading a HUGR model payload.
326    #[error(transparent)]
327    ModelTextResolve {
328        /// The source error.
329        #[from]
330        source: hugr_model::v0::ast::ResolveError,
331    },
332    /// Error reading a list of extensions from the envelope.
333    #[error(transparent)]
334    ExtensionLoad {
335        /// The source error.
336        #[from]
337        source: crate::extension::ExtensionRegistryLoadError,
338    },
339    /// The specified payload format is not supported.
340    #[error(
341        "The envelope configuration has unknown {}. Please update your HUGR version.",
342        if flag_ids.len() == 1 {format!("flag #{}", flag_ids[0])} else {format!("flags {}", flag_ids.iter().join(", "))}
343    )]
344    FlagUnsupported {
345        /// The unrecognized flag bits.
346        flag_ids: Vec<usize>,
347    },
348}
349
350/// Internal implementation of [`read_envelope`] to call with/without the zstd decompression wrapper.
351fn read_impl(
352    payload: impl BufRead,
353    header: EnvelopeHeader,
354    registry: &ExtensionRegistry,
355) -> Result<Package, EnvelopeError> {
356    match header.format {
357        #[allow(deprecated)]
358        EnvelopeFormat::PackageJson => Ok(package_json::from_json_reader(payload, registry)?),
359        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
360            decode_model(payload, registry, header.format)
361        }
362        EnvelopeFormat::ModelText | EnvelopeFormat::ModelTextWithExtensions => {
363            decode_model_ast(payload, registry, header.format)
364        }
365    }
366}
367
368/// Read a HUGR model payload from a reader.
369///
370/// Parameters:
371/// - `stream`: The reader to read the envelope from.
372/// - `extension_registry`: An extension registry with additional extensions to use when
373///   decoding the HUGR, if they are not already included in the package.
374/// - `format`: The format of the payload.
375fn decode_model(
376    mut stream: impl BufRead,
377    extension_registry: &ExtensionRegistry,
378    format: EnvelopeFormat,
379) -> Result<Package, EnvelopeError> {
380    use hugr_model::v0::bumpalo::Bump;
381
382    if format.model_version() != Some(0) {
383        return Err(EnvelopeError::FormatUnsupported {
384            format,
385            feature: None,
386        });
387    }
388
389    let bump = Bump::default();
390    let model_package = hugr_model::v0::binary::read_from_reader(&mut stream, &bump)?;
391
392    let mut extension_registry = extension_registry.clone();
393    if format == EnvelopeFormat::ModelWithExtensions {
394        let extra_extensions = ExtensionRegistry::load_json(stream, &extension_registry)?;
395        extension_registry.extend(extra_extensions);
396    }
397
398    Ok(import_package(&model_package, &extension_registry)?)
399}
400
401/// Read a HUGR model text payload from a reader.
402///
403/// Parameters:
404/// - `stream`: The reader to read the envelope from.
405/// - `extension_registry`: An extension registry with additional extensions to use when
406///   decoding the HUGR, if they are not already included in the package.
407/// - `format`: The format of the payload.
408fn decode_model_ast(
409    mut stream: impl BufRead,
410    extension_registry: &ExtensionRegistry,
411    format: EnvelopeFormat,
412) -> Result<Package, EnvelopeError> {
413    use crate::import::import_package;
414    use hugr_model::v0::bumpalo::Bump;
415
416    if format.model_version() != Some(0) {
417        return Err(EnvelopeError::FormatUnsupported {
418            format,
419            feature: None,
420        });
421    }
422
423    let mut extension_registry = extension_registry.clone();
424    if format == EnvelopeFormat::ModelTextWithExtensions {
425        let deserializer = serde_json::Deserializer::from_reader(&mut stream);
426        // Deserialize the first json object, leaving the rest of the reader unconsumed.
427        let extra_extensions = deserializer
428            .into_iter::<Vec<Extension>>()
429            .next()
430            .unwrap_or(Ok(vec![]))?;
431        for ext in extra_extensions {
432            extension_registry.register_updated(ext);
433        }
434    }
435
436    // Read the package into a string, then parse it.
437    //
438    // Due to how `to_string` works, we cannot append extensions after the package.
439    let mut buffer = String::new();
440    stream.read_to_string(&mut buffer)?;
441    let ast_package = hugr_model::v0::ast::Package::from_str(&buffer)?;
442
443    let bump = Bump::default();
444    let model_package = ast_package.resolve(&bump)?;
445
446    let package = import_package(&model_package, &extension_registry)?;
447    for module in &package.modules {
448        check_breaking_extensions(module, &extension_registry).map_err(|err| {
449            PackageEncodingError::ExtensionVersion(WithGenerator::new(err, &package.modules))
450        })?;
451    }
452    Ok(package)
453}
454
455/// Internal implementation of [`write_envelope`] to call with/without the zstd compression wrapper.
456fn write_impl<'h>(
457    writer: impl Write,
458    hugrs: impl IntoIterator<Item = &'h Hugr>,
459    extensions: &ExtensionRegistry,
460    config: EnvelopeConfig,
461) -> Result<(), EnvelopeError> {
462    match config.format {
463        #[allow(deprecated)]
464        EnvelopeFormat::PackageJson => package_json::to_json_writer(hugrs, extensions, writer)?,
465        EnvelopeFormat::Model
466        | EnvelopeFormat::ModelWithExtensions
467        | EnvelopeFormat::ModelText
468        | EnvelopeFormat::ModelTextWithExtensions => {
469            encode_model(writer, hugrs, extensions, config.format)?;
470        }
471    }
472    Ok(())
473}
474
475fn encode_model<'h>(
476    mut writer: impl Write,
477    hugrs: impl IntoIterator<Item = &'h Hugr>,
478    extensions: &ExtensionRegistry,
479    format: EnvelopeFormat,
480) -> Result<(), EnvelopeError> {
481    use hugr_model::v0::{binary::write_to_writer, bumpalo::Bump};
482
483    use crate::export::export_package;
484
485    if format.model_version() != Some(0) {
486        return Err(EnvelopeError::FormatUnsupported {
487            format,
488            feature: None,
489        });
490    }
491
492    // Prepend extensions for binary model.
493    if format == EnvelopeFormat::ModelTextWithExtensions {
494        serde_json::to_writer(&mut writer, &extensions.iter().collect_vec())?;
495    }
496
497    let bump = Bump::default();
498    let model_package = export_package(hugrs, extensions, &bump);
499
500    match format {
501        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
502            write_to_writer(&model_package, &mut writer)?;
503        }
504        EnvelopeFormat::ModelText | EnvelopeFormat::ModelTextWithExtensions => {
505            let model_package = model_package.as_ast().unwrap();
506            writeln!(writer, "{model_package}")?;
507        }
508        _ => unreachable!(),
509    }
510
511    // Append extensions for binary model.
512    if format == EnvelopeFormat::ModelWithExtensions {
513        serde_json::to_writer(writer, &extensions.iter().collect_vec())?;
514    }
515
516    Ok(())
517}
518
519#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
520struct UsedExtension {
521    name: String,
522    version: Version,
523}
524
525#[derive(Debug, Error)]
526#[error(
527    "Extension '{name}' version mismatch: registered version is {registered}, but used version is {used}"
528)]
529/// Error raised when the reported used version of an extension
530/// does not match the registered version in the extension registry.
531pub struct ExtensionVersionMismatch {
532    /// The name of the extension.
533    pub name: String,
534    /// The registered version of the extension in the loaded registry.
535    pub registered: Version,
536    /// The version of the extension reported as used in the HUGR metadata.
537    pub used: Version,
538}
539
540#[derive(Debug, Error)]
541#[non_exhaustive]
542/// Error raised when checking for breaking changes in used extensions.
543pub enum ExtensionBreakingError {
544    /// The extension version in the metadata does not match the registered version.
545    #[error("{0}")]
546    ExtensionVersionMismatch(ExtensionVersionMismatch),
547
548    /// Error deserializing the used extensions metadata.
549    #[error("Failed to deserialize used extensions metadata")]
550    Deserialization(#[from] serde_json::Error),
551}
552/// If HUGR metadata contains a list of used extensions, under the key [`USED_EXTENSIONS_KEY`],
553/// and extension is registered in the given registry, check that the
554/// version of the extension in the metadata matches the registered version (up to
555/// MAJOR.MINOR).
556fn check_breaking_extensions(
557    hugr: impl crate::HugrView,
558    registry: &ExtensionRegistry,
559) -> Result<(), ExtensionBreakingError> {
560    let Some(exts) = hugr.get_metadata(hugr.module_root(), USED_EXTENSIONS_KEY) else {
561        return Ok(()); // No used extensions metadata, nothing to check
562    };
563    let used_exts: Vec<UsedExtension> = serde_json::from_value(exts.clone())?; // TODO handle errors properly
564
565    for ext in used_exts {
566        let Some(registered) = registry.get(ext.name.as_str()) else {
567            continue; // Extension not registered, ignore
568        };
569        if !compatible_versions(registered.version(), &ext.version) {
570            // This is a breaking change, raise an error.
571
572            return Err(ExtensionBreakingError::ExtensionVersionMismatch(
573                ExtensionVersionMismatch {
574                    name: ext.name,
575                    registered: registered.version().clone(),
576                    used: ext.version,
577                },
578            ));
579        }
580    }
581
582    Ok(())
583}
584
585/// Check if two versions are compatible according to:
586/// - Major version must match.
587/// - If major version is 0, minor version must match.
588fn compatible_versions(v1: &Version, v2: &Version) -> bool {
589    if v1.major != v2.major {
590        return false; // Major version mismatch
591    }
592
593    if v1.major == 0 {
594        // For major version 0, we only allow minor version matches
595        return v1.minor == v2.minor;
596    }
597
598    true
599}
600
601#[cfg(test)]
602pub(crate) mod test {
603    use super::*;
604    use cool_asserts::assert_matches;
605    use rstest::rstest;
606    use std::borrow::Cow;
607    use std::io::BufReader;
608
609    use crate::HugrView;
610    use crate::builder::test::{multi_module_package, simple_package};
611    use crate::extension::{Extension, ExtensionRegistry, Version};
612    use crate::extension::{ExtensionId, PRELUDE_REGISTRY};
613    use crate::hugr::HugrMut;
614    use crate::hugr::test::check_hugr_equality;
615    use crate::std_extensions::STD_REG;
616    use serde_json::json;
617    use std::sync::Arc;
618
619    /// Returns an `ExtensionRegistry` with the extensions from both
620    /// sets. Avoids cloning if the first one already contains all
621    /// extensions from the second one.
622    fn join_extensions<'a>(
623        extensions: &'a ExtensionRegistry,
624        other: &ExtensionRegistry,
625    ) -> Cow<'a, ExtensionRegistry> {
626        if other.iter().all(|e| extensions.contains(e.name())) {
627            Cow::Borrowed(extensions)
628        } else {
629            let mut extensions = extensions.clone();
630            extensions.extend(other);
631            Cow::Owned(extensions)
632        }
633    }
634
635    /// Serialize and deserialize a HUGR into an envelope with the given config,
636    /// and check that the result is the same as the original.
637    ///
638    /// We do not compare the before and after `Hugr`s for equality directly,
639    /// because impls of `CustomConst` are not required to implement equality
640    /// checking.
641    ///
642    /// Returns the deserialized HUGR.
643    pub(crate) fn check_hugr_roundtrip(hugr: &Hugr, config: EnvelopeConfig) -> Hugr {
644        let mut buffer = Vec::new();
645        hugr.store(&mut buffer, config).unwrap();
646
647        let extensions = join_extensions(&STD_REG, hugr.extensions());
648
649        let reader = BufReader::new(buffer.as_slice());
650        let extracted = Hugr::load(reader, Some(&extensions)).unwrap();
651
652        check_hugr_equality(&extracted, hugr);
653        extracted
654    }
655
656    #[rstest]
657    fn errors() {
658        let package = simple_package();
659        assert_matches!(
660            package.store_str(EnvelopeConfig::binary()),
661            Err(EnvelopeError::NonASCIIFormat { .. })
662        );
663    }
664
665    #[rstest]
666    #[case::empty(Package::default())]
667    #[case::simple(simple_package())]
668    #[case::multi(multi_module_package())]
669    fn text_roundtrip(#[case] package: Package) {
670        let envelope = package.store_str(EnvelopeConfig::text()).unwrap();
671        let new_package = Package::load_str(&envelope, None).unwrap();
672        assert_eq!(package, new_package);
673    }
674
675    #[rstest]
676    #[case::empty(Package::default())]
677    #[case::simple(simple_package())]
678    #[case::multi(multi_module_package())]
679    #[cfg_attr(all(miri, feature = "zstd"), ignore)] // FFI calls (required to compress with zstd) are not supported in miri
680    fn compressed_roundtrip(#[case] package: Package) {
681        let mut buffer = Vec::new();
682        let config = EnvelopeConfig {
683            format: EnvelopeFormat::PackageJson,
684            zstd: Some(ZstdConfig::default()),
685        };
686        let res = package.store(&mut buffer, config);
687
688        match cfg!(feature = "zstd") {
689            true => res.unwrap(),
690            false => {
691                assert_matches!(res, Err(EnvelopeError::ZstdUnsupported));
692                return;
693            }
694        }
695
696        let (decoded_config, new_package) =
697            read_envelope(BufReader::new(buffer.as_slice()), &PRELUDE_REGISTRY).unwrap();
698
699        assert_eq!(config.format, decoded_config.format);
700        assert_eq!(config.zstd.is_some(), decoded_config.zstd.is_some());
701        assert_eq!(package, new_package);
702    }
703
704    #[rstest]
705    // Empty packages
706    #[case::empty_model(Package::default(), EnvelopeFormat::Model)]
707    #[case::empty_model_exts(Package::default(), EnvelopeFormat::ModelWithExtensions)]
708    #[case::empty_text(Package::default(), EnvelopeFormat::ModelText)]
709    #[case::empty_text_exts(Package::default(), EnvelopeFormat::ModelTextWithExtensions)]
710    // Single hugrs
711    #[case::simple_bin(simple_package(), EnvelopeFormat::Model)]
712    #[case::simple_bin_exts(simple_package(), EnvelopeFormat::ModelWithExtensions)]
713    #[case::simple_text(simple_package(), EnvelopeFormat::ModelText)]
714    #[case::simple_text_exts(simple_package(), EnvelopeFormat::ModelTextWithExtensions)]
715    // Multiple hugrs
716    #[case::multi_bin(multi_module_package(), EnvelopeFormat::Model)]
717    #[case::multi_bin_exts(multi_module_package(), EnvelopeFormat::ModelWithExtensions)]
718    #[case::multi_text(multi_module_package(), EnvelopeFormat::ModelText)]
719    #[case::multi_text_exts(multi_module_package(), EnvelopeFormat::ModelTextWithExtensions)]
720    fn model_roundtrip(#[case] package: Package, #[case] format: EnvelopeFormat) {
721        let mut buffer = Vec::new();
722        let config = EnvelopeConfig { format, zstd: None };
723        package.store(&mut buffer, config).unwrap();
724
725        let (decoded_config, new_package) =
726            read_envelope(BufReader::new(buffer.as_slice()), &PRELUDE_REGISTRY).unwrap();
727
728        assert_eq!(config.format, decoded_config.format);
729        assert_eq!(config.zstd.is_some(), decoded_config.zstd.is_some());
730
731        assert_eq!(package, new_package);
732    }
733
734    #[rstest]
735    #[case::simple(simple_package())]
736    fn test_check_breaking_extensions(#[case] mut package: Package) {
737        // extension with major version 0
738        let test_ext_v0 =
739            Extension::new(ExtensionId::new_unchecked("test-v0"), Version::new(0, 2, 3));
740        //  extension with major version > 0
741        let test_ext_v1 =
742            Extension::new(ExtensionId::new_unchecked("test-v1"), Version::new(1, 2, 3));
743
744        // Create a registry with the test extensions
745        let registry =
746            ExtensionRegistry::new([Arc::new(test_ext_v0.clone()), Arc::new(test_ext_v1.clone())]);
747        let mut hugr = package.modules.remove(0);
748
749        // No metadata - should pass
750        assert_matches!(check_breaking_extensions(&hugr, &registry), Ok(()));
751
752        // Matching version for v0 - should pass
753        let used_exts = json!([{ "name": "test-v0", "version": "0.2.3" }]);
754        hugr.set_metadata(hugr.module_root(), USED_EXTENSIONS_KEY, used_exts);
755        assert_matches!(check_breaking_extensions(&hugr, &registry), Ok(()));
756
757        // Matching major/minor but different patch for v0 - should pass
758        let used_exts = json!([{ "name": "test-v0", "version": "0.2.4" }]);
759        hugr.set_metadata(hugr.module_root(), USED_EXTENSIONS_KEY, used_exts);
760        assert_matches!(check_breaking_extensions(&hugr, &registry), Ok(()));
761
762        //Different minor version for v0 - should fail
763        let used_exts = json!([{ "name": "test-v0", "version": "0.3.3" }]);
764        hugr.set_metadata(hugr.module_root(), USED_EXTENSIONS_KEY, used_exts);
765        assert_matches!(
766            check_breaking_extensions(&hugr, &registry),
767            Err(ExtensionBreakingError::ExtensionVersionMismatch(ExtensionVersionMismatch {
768                name,
769                registered,
770                used
771            })) if name == "test-v0" && registered == Version::new(0, 2, 3) && used == Version::new(0, 3, 3)
772        );
773
774        // Different major version for v0 - should fail
775        let used_exts = json!([{ "name": "test-v0", "version": "1.2.3" }]);
776        hugr.set_metadata(hugr.module_root(), USED_EXTENSIONS_KEY, used_exts);
777        assert_matches!(
778            check_breaking_extensions(&hugr, &registry),
779            Err(ExtensionBreakingError::ExtensionVersionMismatch(ExtensionVersionMismatch {
780                name,
781                registered,
782                used
783            })) if name == "test-v0" && registered == Version::new(0, 2, 3) && used == Version::new(1, 2, 3)
784        );
785
786        // Matching version for v1 - should pass
787        let used_exts = json!([{ "name": "test-v1", "version": "1.2.3" }]);
788        hugr.set_metadata(hugr.module_root(), USED_EXTENSIONS_KEY, used_exts);
789        assert_matches!(check_breaking_extensions(&hugr, &registry), Ok(()));
790
791        // Different minor version for v1 - should pass
792        let used_exts = json!([{ "name": "test-v1", "version": "1.3.0" }]);
793        hugr.set_metadata(hugr.module_root(), USED_EXTENSIONS_KEY, used_exts);
794        assert_matches!(check_breaking_extensions(&hugr, &registry), Ok(()));
795
796        // Different patch for v1 - should pass
797        let used_exts = json!([{ "name": "test-v1", "version": "1.2.4" }]);
798        hugr.set_metadata(hugr.module_root(), USED_EXTENSIONS_KEY, used_exts);
799        assert_matches!(check_breaking_extensions(&hugr, &registry), Ok(()));
800
801        // Different major version for v1 - should fail
802        let used_exts = json!([{ "name": "test-v1", "version": "2.2.3" }]);
803        hugr.set_metadata(hugr.module_root(), USED_EXTENSIONS_KEY, used_exts);
804        assert_matches!(
805            check_breaking_extensions(&hugr, &registry),
806            Err(ExtensionBreakingError::ExtensionVersionMismatch(ExtensionVersionMismatch {
807                name,
808                registered,
809                used
810            })) if name == "test-v1" && registered == Version::new(1, 2, 3) && used == Version::new(2, 2, 3)
811        );
812
813        // Non-registered extension - should pass
814        let used_exts = json!([{ "name": "unknown", "version": "1.0.0" }]);
815        hugr.set_metadata(hugr.module_root(), USED_EXTENSIONS_KEY, used_exts);
816        assert_matches!(check_breaking_extensions(&hugr, &registry), Ok(()));
817
818        // Multiple extensions - one mismatch should fail
819        let used_exts = json!([
820            { "name": "unknown", "version": "1.0.0" },
821            { "name": "test-v1", "version": "2.0.0" }
822        ]);
823        hugr.set_metadata(hugr.module_root(), USED_EXTENSIONS_KEY, used_exts);
824        assert_matches!(
825            check_breaking_extensions(&hugr, &registry),
826            Err(ExtensionBreakingError::ExtensionVersionMismatch(ExtensionVersionMismatch {
827                name,
828                registered,
829                used
830            })) if name == "test-v1" && registered == Version::new(1, 2, 3) && used == Version::new(2, 0, 0)
831        );
832
833        // Invalid metadata format - should fail with deserialization error
834        hugr.set_metadata(
835            hugr.module_root(),
836            USED_EXTENSIONS_KEY,
837            json!("not an array"),
838        );
839        assert_matches!(
840            check_breaking_extensions(&hugr, &registry),
841            Err(ExtensionBreakingError::Deserialization(_))
842        );
843
844        //  Multiple extensions with all compatible versions - should pass
845        let used_exts = json!([
846            { "name": "test-v0", "version": "0.2.5" },
847            { "name": "test-v1", "version": "1.9.9" }
848        ]);
849        hugr.set_metadata(hugr.module_root(), USED_EXTENSIONS_KEY, used_exts);
850        assert_matches!(check_breaking_extensions(&hugr, &registry), Ok(()));
851    }
852
853    #[test]
854    fn test_with_generator_error_message() {
855        let test_ext = Extension::new(ExtensionId::new_unchecked("test"), Version::new(1, 0, 0));
856        let registry = ExtensionRegistry::new([Arc::new(test_ext)]);
857
858        let mut hugr = simple_package().modules.remove(0);
859
860        // Set a generator name in the metadata
861        let generator_name = json!({ "name": "TestGenerator", "version": "1.2.3" });
862        hugr.set_metadata(hugr.module_root(), GENERATOR_KEY, generator_name.clone());
863
864        // Set incompatible extension version in metadata
865        let used_exts = json!([{ "name": "test", "version": "2.0.0" }]);
866        hugr.set_metadata(hugr.module_root(), USED_EXTENSIONS_KEY, used_exts);
867
868        // Create the error and wrap it with WithGenerator
869        let err = check_breaking_extensions(&hugr, &registry).unwrap_err();
870        let with_gen = WithGenerator::new(err, &[&hugr]);
871
872        let err_msg = with_gen.to_string();
873        assert!(err_msg.contains("Extension 'test' version mismatch"));
874        assert!(err_msg.contains("TestGenerator-v1.2.3"));
875    }
876}