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