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