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