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