Skip to main content

iac_forge/
morphism.rs

1//! Structure-preserving maps between types — morphisms in the categorical sense.
2//!
3//! This module lifts what `Backend` already does (take an `IacResource`, produce
4//! artifacts) into a composable, proof-bearing primitive. Every
5//! synthesizer in the pipeline is a morphism from one typed domain to
6//! another — `IacType → RubyType`, `RubyType → String`, `IacResource →
7//! GeneratedArtifact` — and when two morphisms compose, their proofs
8//! compose with them.
9//!
10//! # Why this exists
11//!
12//! The platform's 2,739-test proof engine attaches invariants to types
13//! (`IacType` is injective to `RubyType`, render is deterministic, etc.).
14//! But the *glue* between types — the actual transformation functions —
15//! has been plain Rust fns. A plain fn carries no first-class proof;
16//! proofs live in adjacent test files.
17//!
18//! `Morphism` makes the transformation itself a value you can compose,
19//! name, and attach invariants to. `ProvenMorphism` adds a
20//! `check_invariants` hook: the morphism declares what must hold about
21//! every (src, dst) pair it produces. Composition naturally inherits
22//! both participants' invariants.
23//!
24//! # Minimal API
25//!
26//! ```rust
27//! use iac_forge::morphism::{Morphism, Composed, ProvenMorphism};
28//!
29//! struct Double;
30//! impl Morphism<i64, i64> for Double {
31//!     fn name(&self) -> &'static str { "Double" }
32//!     fn apply(&self, x: &i64) -> i64 { x * 2 }
33//! }
34//!
35//! struct AddOne;
36//! impl Morphism<i64, i64> for AddOne {
37//!     fn name(&self) -> &'static str { "AddOne" }
38//!     fn apply(&self, x: &i64) -> i64 { x + 1 }
39//! }
40//!
41//! let composed = Composed::new(Double, AddOne);
42//! assert_eq!(composed.apply(&3), 7); // (3 * 2) + 1
43//! ```
44
45use std::marker::PhantomData;
46
47/// A structure-preserving map from `Src` to `Dst`.
48///
49/// Implementations should be **total** (every `Src` maps to exactly one
50/// `Dst`) and **deterministic** (same input → same output, always). These
51/// are the invariants composition depends on; `ProvenMorphism` lets you
52/// declare them explicitly.
53pub trait Morphism<Src, Dst> {
54    /// Human-readable name for diagnostics, traceability, attestation.
55    fn name(&self) -> &'static str;
56
57    /// Apply the morphism. Must be total and deterministic.
58    fn apply(&self, src: &Src) -> Dst;
59}
60
61/// A morphism that can verify its own invariants for a given (src, dst) pair.
62///
63/// The empty return vector means "all invariants hold." Non-empty is a
64/// list of human-readable violations, intended for test-time or debug-time
65/// assertion. Production code paths should not need to run checks at
66/// runtime — the point is that the morphism *would* satisfy them.
67///
68/// Composition: when you compose two `ProvenMorphism`s, the resulting
69/// morphism's invariants are the union of both, checked at the
70/// intermediate and final values.
71pub trait ProvenMorphism<Src, Dst>: Morphism<Src, Dst> {
72    /// Return violations (empty = all invariants hold).
73    fn check_invariants(&self, src: &Src, dst: &Dst) -> Vec<String>;
74}
75
76/// Sequential composition: `A -> B` followed by `B -> C` gives `A -> C`.
77///
78/// Requires `Mid: Clone` because the intermediate value must be
79/// available both to the second morphism and (optionally) to the
80/// composed invariant check.
81pub struct Composed<A, B, C, M1, M2> {
82    first: M1,
83    second: M2,
84    _src: PhantomData<fn(&A) -> A>,
85    _mid: PhantomData<fn(&B) -> B>,
86    _dst: PhantomData<fn(&C) -> C>,
87}
88
89impl<A, B, C, M1, M2> Composed<A, B, C, M1, M2>
90where
91    M1: Morphism<A, B>,
92    M2: Morphism<B, C>,
93{
94    /// Construct a composed morphism `first; second`.
95    pub fn new(first: M1, second: M2) -> Self {
96        Self {
97            first,
98            second,
99            _src: PhantomData,
100            _mid: PhantomData,
101            _dst: PhantomData,
102        }
103    }
104}
105
106impl<A, B, C, M1, M2> Morphism<A, C> for Composed<A, B, C, M1, M2>
107where
108    M1: Morphism<A, B>,
109    M2: Morphism<B, C>,
110{
111    fn name(&self) -> &'static str {
112        // A slight pragmatic compromise — we cannot format! a &'static str
113        // here. The concrete name isn't printable through the trait; use
114        // `named_composition` for labelled composition.
115        "Composed"
116    }
117
118    fn apply(&self, src: &A) -> C {
119        let mid = self.first.apply(src);
120        self.second.apply(&mid)
121    }
122}
123
124impl<A, B, C, M1, M2> ProvenMorphism<A, C> for Composed<A, B, C, M1, M2>
125where
126    M1: ProvenMorphism<A, B>,
127    M2: ProvenMorphism<B, C>,
128{
129    fn check_invariants(&self, src: &A, dst: &C) -> Vec<String> {
130        // Recompute the intermediate; both participants check their own
131        // invariants against their respective (src, dst) pair. Violations
132        // are prefixed with the originating morphism's name for
133        // traceability.
134        let mid = self.first.apply(src);
135        let mut out = Vec::new();
136        for v in self.first.check_invariants(src, &mid) {
137            out.push(format!("[{}] {}", self.first.name(), v));
138        }
139        for v in self.second.check_invariants(&mid, dst) {
140            out.push(format!("[{}] {}", self.second.name(), v));
141        }
142        out
143    }
144}
145
146/// Identity morphism — useful as a unit in composition chains and as
147/// the default transform in pipelines.
148pub struct Identity<T>(PhantomData<fn(&T) -> T>);
149
150impl<T> Default for Identity<T> {
151    fn default() -> Self {
152        Self(PhantomData)
153    }
154}
155
156impl<T: Clone> Morphism<T, T> for Identity<T> {
157    fn name(&self) -> &'static str {
158        "Identity"
159    }
160    fn apply(&self, src: &T) -> T {
161        src.clone()
162    }
163}
164
165impl<T: Clone + PartialEq> ProvenMorphism<T, T> for Identity<T> {
166    fn check_invariants(&self, src: &T, dst: &T) -> Vec<String> {
167        if src == dst {
168            Vec::new()
169        } else {
170            vec!["identity: src != dst".to_string()]
171        }
172    }
173}
174
175// ── Backend → ProvenMorphism blanket impl ────────────────────────────
176//
177// Every `Backend` is, by definition, a morphism from (resource, provider)
178// to a list of artifacts. Rather than ask each of the 7 backends to
179// impl `ProvenMorphism` by hand, we provide a blanket impl so they all
180// get it for free. The invariants checked are what every backend must
181// satisfy in any case:
182//
183// 1. Determinism: calling `generate_resource` twice with the same inputs
184//    returns artifacts with identical (path, content, kind) triples.
185// 2. No empty artifacts: every produced artifact has a non-empty path
186//    and non-empty content.
187// 3. No duplicate paths: an artifact list never contains two entries
188//    with the same output path.
189//
190// These are the *same* invariants iac-forge's existing backend tests
191// already enforce test-by-test; the blanket impl lifts them into the
192// type system so composition (and cross-backend analysis) can rely on
193// them structurally.
194
195/// Wrapper identifying the (resource, provider) pair as a source value.
196///
197/// Needed because `ProvenMorphism<Src, Dst>` requires a single Src type,
198/// and `Backend::generate_resource` takes two references.
199#[derive(Debug, Clone)]
200pub struct ResourceInput<'a> {
201    pub resource: &'a crate::ir::IacResource,
202    pub provider: &'a crate::ir::IacProvider,
203}
204
205impl<B: crate::backend::Backend> Morphism<ResourceInput<'_>, Vec<crate::backend::GeneratedArtifact>>
206    for B
207{
208    fn name(&self) -> &'static str {
209        "Backend::generate_resource"
210    }
211
212    fn apply(&self, src: &ResourceInput<'_>) -> Vec<crate::backend::GeneratedArtifact> {
213        use crate::sexpr::ToSExpr;
214        // A `Morphism` is total by contract. Backend errors collapse to
215        // an empty artifact list — the invariant check below will flag
216        // that as a violation so the caller sees the failure via
217        // `check_invariants` rather than a panic here.
218        let mut artifacts =
219            <B as crate::backend::Backend>::generate_resource(self, src.resource, src.provider)
220                .unwrap_or_default();
221
222        // Populate provenance on each artifact: content hash of the
223        // source IR + the morphism chain that produced it. Backends
224        // that set their own provenance are respected (we only touch
225        // artifacts whose source_hash is still empty).
226        let source_hash = src.resource.content_hash().to_hex();
227        let platform = <B as crate::backend::Backend>::platform(self);
228        let chain = vec![
229            format!("Backend::{platform}"),
230            "generate_resource".to_string(),
231        ];
232        for a in &mut artifacts {
233            if a.source_hash.is_empty() {
234                a.source_hash.clone_from(&source_hash);
235            }
236            if a.morphism_chain.is_empty() {
237                a.morphism_chain = chain.clone();
238            }
239        }
240        artifacts
241    }
242}
243
244impl<B: crate::backend::Backend>
245    ProvenMorphism<ResourceInput<'_>, Vec<crate::backend::GeneratedArtifact>> for B
246{
247    fn check_invariants(
248        &self,
249        src: &ResourceInput<'_>,
250        dst: &Vec<crate::backend::GeneratedArtifact>,
251    ) -> Vec<String> {
252        let mut violations = Vec::new();
253
254        // Determinism: re-run (through apply, which also populates
255        // provenance so the comparison is apples-to-apples) must match.
256        let rerun = <B as Morphism<_, _>>::apply(self, src);
257        if &rerun != dst {
258            violations.push(format!(
259                "backend {}: non-deterministic — re-run differs",
260                <B as crate::backend::Backend>::platform(self),
261            ));
262        }
263
264        // Empty outputs are always a violation: every backend must emit
265        // something for a well-formed resource.
266        if dst.is_empty() {
267            violations.push(format!(
268                "backend {}: empty artifact list",
269                <B as crate::backend::Backend>::platform(self),
270            ));
271        }
272
273        // No empty paths or contents.
274        for a in dst {
275            if a.path.is_empty() {
276                violations.push(format!(
277                    "backend {}: artifact with empty path",
278                    <B as crate::backend::Backend>::platform(self),
279                ));
280            }
281            if a.content.is_empty() {
282                violations.push(format!(
283                    "backend {}: artifact '{}' has empty content",
284                    <B as crate::backend::Backend>::platform(self),
285                    a.path,
286                ));
287            }
288        }
289
290        // No duplicate paths.
291        let mut paths: Vec<&str> = dst.iter().map(|a| a.path.as_str()).collect();
292        paths.sort_unstable();
293        for pair in paths.windows(2) {
294            if pair[0] == pair[1] {
295                violations.push(format!(
296                    "backend {}: duplicate artifact path '{}'",
297                    <B as crate::backend::Backend>::platform(self),
298                    pair[0],
299                ));
300            }
301        }
302
303        violations
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    struct Double;
312    impl Morphism<i64, i64> for Double {
313        fn name(&self) -> &'static str {
314            "Double"
315        }
316        fn apply(&self, x: &i64) -> i64 {
317            x * 2
318        }
319    }
320    impl ProvenMorphism<i64, i64> for Double {
321        fn check_invariants(&self, src: &i64, dst: &i64) -> Vec<String> {
322            if *dst == src * 2 {
323                Vec::new()
324            } else {
325                vec!["double: dst != 2 * src".into()]
326            }
327        }
328    }
329
330    struct AddOne;
331    impl Morphism<i64, i64> for AddOne {
332        fn name(&self) -> &'static str {
333            "AddOne"
334        }
335        fn apply(&self, x: &i64) -> i64 {
336            x + 1
337        }
338    }
339    impl ProvenMorphism<i64, i64> for AddOne {
340        fn check_invariants(&self, src: &i64, dst: &i64) -> Vec<String> {
341            if *dst == src + 1 {
342                Vec::new()
343            } else {
344                vec!["addone: dst != src + 1".into()]
345            }
346        }
347    }
348
349    #[test]
350    fn apply_composes_left_to_right() {
351        let c = Composed::new(Double, AddOne);
352        assert_eq!(c.apply(&3), 7); // (3 * 2) + 1
353    }
354
355    #[test]
356    fn proof_composes() {
357        let c = Composed::new(Double, AddOne);
358        let src = 5_i64;
359        let dst = c.apply(&src);
360        let violations = c.check_invariants(&src, &dst);
361        assert!(
362            violations.is_empty(),
363            "valid composition should yield zero violations: {violations:?}",
364        );
365    }
366
367    #[test]
368    fn proof_composes_identifies_source_of_violation() {
369        struct BadDouble;
370        impl Morphism<i64, i64> for BadDouble {
371            fn name(&self) -> &'static str {
372                "BadDouble"
373            }
374            fn apply(&self, x: &i64) -> i64 {
375                x * 3 // wrong
376            }
377        }
378        impl ProvenMorphism<i64, i64> for BadDouble {
379            fn check_invariants(&self, src: &i64, dst: &i64) -> Vec<String> {
380                if *dst == src * 2 {
381                    Vec::new()
382                } else {
383                    vec!["double: dst != 2 * src".into()]
384                }
385            }
386        }
387
388        let c = Composed::new(BadDouble, AddOne);
389        let src = 5_i64;
390        let dst = c.apply(&src);
391        let violations = c.check_invariants(&src, &dst);
392        assert_eq!(violations.len(), 1);
393        assert!(violations[0].starts_with("[BadDouble]"));
394    }
395
396    #[test]
397    fn identity_name_and_apply() {
398        let id = Identity::<i64>::default();
399        assert_eq!(id.name(), "Identity");
400        assert_eq!(id.apply(&42), 42);
401    }
402
403    #[test]
404    fn identity_proves_itself() {
405        let id = Identity::<i64>::default();
406        assert!(id.check_invariants(&42, &42).is_empty());
407    }
408
409    #[test]
410    fn identity_is_right_unit_of_composition() {
411        let c = Composed::new(Double, Identity::<i64>::default());
412        assert_eq!(c.apply(&5), 10);
413        assert!(c.check_invariants(&5, &10).is_empty());
414    }
415
416    #[test]
417    fn identity_is_left_unit_of_composition() {
418        let c = Composed::new(Identity::<i64>::default(), Double);
419        assert_eq!(c.apply(&5), 10);
420        assert!(c.check_invariants(&5, &10).is_empty());
421    }
422
423    #[test]
424    fn triple_composition_proof_chain() {
425        let c = Composed::new(Composed::new(Double, AddOne), Double);
426        let src = 3_i64;
427        let dst = c.apply(&src);
428        assert_eq!(dst, 14); // ((3 * 2) + 1) * 2
429        assert!(c.check_invariants(&src, &dst).is_empty());
430    }
431
432    #[test]
433    fn composition_apply_is_deterministic() {
434        let c = Composed::new(Double, AddOne);
435        let a = c.apply(&7);
436        let b = c.apply(&7);
437        assert_eq!(a, b);
438    }
439
440    // ── Blanket Backend → ProvenMorphism tests ────────────────────
441
442    use crate::backend::{ArtifactKind, Backend, GeneratedArtifact, NamingConvention};
443    use crate::error::IacForgeError;
444    use crate::ir::{IacDataSource, IacProvider, IacResource};
445
446    struct GoodBackend;
447    struct GoodNaming;
448    impl NamingConvention for GoodNaming {
449        fn resource_type_name(&self, r: &str, p: &str) -> String {
450            format!("{p}_{r}")
451        }
452        fn file_name(&self, r: &str, _k: &ArtifactKind) -> String {
453            format!("{r}.out")
454        }
455        fn field_name(&self, n: &str) -> String {
456            n.to_string()
457        }
458    }
459    impl Backend for GoodBackend {
460        fn platform(&self) -> &str {
461            "good"
462        }
463        fn naming(&self) -> &dyn NamingConvention {
464            &GoodNaming
465        }
466        fn generate_resource(
467            &self,
468            _r: &IacResource,
469            _p: &IacProvider,
470        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
471            Ok(vec![GeneratedArtifact {
472                path: "main.out".into(),
473                content: "body".into(),
474                kind: ArtifactKind::Resource,
475                source_hash: String::new(),
476                morphism_chain: Vec::new(),
477            }])
478        }
479        fn generate_data_source(
480            &self,
481            _d: &IacDataSource,
482            _p: &IacProvider,
483        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
484            Ok(vec![])
485        }
486        fn generate_provider(
487            &self,
488            _p: &IacProvider,
489            _r: &[IacResource],
490            _d: &[IacDataSource],
491        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
492            Ok(vec![])
493        }
494        fn generate_test(
495            &self,
496            _r: &IacResource,
497            _p: &IacProvider,
498        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
499            Ok(vec![])
500        }
501    }
502
503    fn sample_input() -> (crate::ir::IacResource, crate::ir::IacProvider) {
504        (
505            crate::testing::test_resource("widget"),
506            crate::testing::test_provider("acme"),
507        )
508    }
509
510    #[test]
511    fn backend_is_a_morphism() {
512        let (r, p) = sample_input();
513        let input = ResourceInput {
514            resource: &r,
515            provider: &p,
516        };
517        let out = <GoodBackend as Morphism<_, _>>::apply(&GoodBackend, &input);
518        assert_eq!(out.len(), 1);
519        assert_eq!(out[0].path, "main.out");
520    }
521
522    #[test]
523    fn backend_morphism_proofs_hold_on_good_backend() {
524        let (r, p) = sample_input();
525        let input = ResourceInput {
526            resource: &r,
527            provider: &p,
528        };
529        let out = <GoodBackend as Morphism<_, _>>::apply(&GoodBackend, &input);
530        let violations =
531            <GoodBackend as ProvenMorphism<_, _>>::check_invariants(&GoodBackend, &input, &out);
532        assert!(violations.is_empty(), "violations: {violations:?}");
533    }
534
535    // A backend that returns duplicate paths — must be caught.
536    struct DupBackend;
537    impl Backend for DupBackend {
538        fn platform(&self) -> &str {
539            "dup"
540        }
541        fn naming(&self) -> &dyn NamingConvention {
542            &GoodNaming
543        }
544        fn generate_resource(
545            &self,
546            _r: &IacResource,
547            _p: &IacProvider,
548        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
549            Ok(vec![
550                GeneratedArtifact {
551                    path: "dup.out".into(),
552                    content: "a".into(),
553                    kind: ArtifactKind::Resource,
554                    source_hash: String::new(),
555                    morphism_chain: Vec::new(),
556                },
557                GeneratedArtifact {
558                    path: "dup.out".into(),
559                    content: "b".into(),
560                    kind: ArtifactKind::Resource,
561                    source_hash: String::new(),
562                    morphism_chain: Vec::new(),
563                },
564            ])
565        }
566        fn generate_data_source(
567            &self,
568            _d: &IacDataSource,
569            _p: &IacProvider,
570        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
571            Ok(vec![])
572        }
573        fn generate_provider(
574            &self,
575            _p: &IacProvider,
576            _r: &[IacResource],
577            _d: &[IacDataSource],
578        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
579            Ok(vec![])
580        }
581        fn generate_test(
582            &self,
583            _r: &IacResource,
584            _p: &IacProvider,
585        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
586            Ok(vec![])
587        }
588    }
589
590    #[test]
591    fn backend_morphism_catches_duplicate_paths() {
592        let (r, p) = sample_input();
593        let input = ResourceInput {
594            resource: &r,
595            provider: &p,
596        };
597        let out = <DupBackend as Morphism<_, _>>::apply(&DupBackend, &input);
598        let violations =
599            <DupBackend as ProvenMorphism<_, _>>::check_invariants(&DupBackend, &input, &out);
600        assert!(
601            violations
602                .iter()
603                .any(|v| v.contains("duplicate artifact path")),
604            "expected duplicate-path violation: {violations:?}",
605        );
606    }
607
608    // A backend that returns an empty artifact list — must be caught.
609    struct EmptyBackend;
610    impl Backend for EmptyBackend {
611        fn platform(&self) -> &str {
612            "empty"
613        }
614        fn naming(&self) -> &dyn NamingConvention {
615            &GoodNaming
616        }
617        fn generate_resource(
618            &self,
619            _r: &IacResource,
620            _p: &IacProvider,
621        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
622            Ok(vec![])
623        }
624        fn generate_data_source(
625            &self,
626            _d: &IacDataSource,
627            _p: &IacProvider,
628        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
629            Ok(vec![])
630        }
631        fn generate_provider(
632            &self,
633            _p: &IacProvider,
634            _r: &[IacResource],
635            _d: &[IacDataSource],
636        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
637            Ok(vec![])
638        }
639        fn generate_test(
640            &self,
641            _r: &IacResource,
642            _p: &IacProvider,
643        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
644            Ok(vec![])
645        }
646    }
647
648    // ── Provenance population ────────────────────────────────
649
650    #[test]
651    fn backend_morphism_populates_source_hash() {
652        use crate::sexpr::ToSExpr;
653        let (r, p) = sample_input();
654        let input = ResourceInput {
655            resource: &r,
656            provider: &p,
657        };
658        let out = <GoodBackend as Morphism<_, _>>::apply(&GoodBackend, &input);
659        assert_eq!(out.len(), 1);
660        let expected_hash = r.content_hash().to_hex();
661        assert_eq!(out[0].source_hash, expected_hash);
662        assert!(out[0].has_provenance());
663    }
664
665    #[test]
666    fn backend_morphism_populates_morphism_chain() {
667        let (r, p) = sample_input();
668        let input = ResourceInput {
669            resource: &r,
670            provider: &p,
671        };
672        let out = <GoodBackend as Morphism<_, _>>::apply(&GoodBackend, &input);
673        assert_eq!(out[0].morphism_chain.len(), 2);
674        assert_eq!(out[0].morphism_chain[0], "Backend::good");
675        assert_eq!(out[0].morphism_chain[1], "generate_resource");
676    }
677
678    #[test]
679    fn backend_morphism_respects_pre_set_provenance() {
680        // If a backend proactively sets provenance, the blanket impl
681        // must not overwrite it.
682        struct PreSetBackend;
683        impl Backend for PreSetBackend {
684            fn platform(&self) -> &str {
685                "preset"
686            }
687            fn naming(&self) -> &dyn NamingConvention {
688                &GoodNaming
689            }
690            fn generate_resource(
691                &self,
692                _r: &IacResource,
693                _p: &IacProvider,
694            ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
695                Ok(vec![GeneratedArtifact {
696                    path: "main.out".into(),
697                    content: "body".into(),
698                    kind: ArtifactKind::Resource,
699                    source_hash: "manually-set".into(),
700                    morphism_chain: vec!["custom".into()],
701                }])
702            }
703            fn generate_data_source(
704                &self,
705                _d: &IacDataSource,
706                _p: &IacProvider,
707            ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
708                Ok(vec![])
709            }
710            fn generate_provider(
711                &self,
712                _p: &IacProvider,
713                _r: &[IacResource],
714                _d: &[IacDataSource],
715            ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
716                Ok(vec![])
717            }
718            fn generate_test(
719                &self,
720                _r: &IacResource,
721                _p: &IacProvider,
722            ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
723                Ok(vec![])
724            }
725        }
726
727        let (r, p) = sample_input();
728        let input = ResourceInput {
729            resource: &r,
730            provider: &p,
731        };
732        let out = <PreSetBackend as Morphism<_, _>>::apply(&PreSetBackend, &input);
733        assert_eq!(out[0].source_hash, "manually-set");
734        assert_eq!(out[0].morphism_chain, vec!["custom".to_string()]);
735    }
736
737    #[test]
738    fn backend_morphism_source_hash_is_deterministic() {
739        // Same IR should always yield the same source hash.
740        let (r, p) = sample_input();
741        let input = ResourceInput {
742            resource: &r,
743            provider: &p,
744        };
745        let a = <GoodBackend as Morphism<_, _>>::apply(&GoodBackend, &input);
746        let b = <GoodBackend as Morphism<_, _>>::apply(&GoodBackend, &input);
747        assert_eq!(a[0].source_hash, b[0].source_hash);
748    }
749
750    #[test]
751    fn backend_morphism_different_resources_different_hashes() {
752        let p = crate::testing::test_provider("acme");
753        let r1 = crate::testing::test_resource("widget");
754        let r2 = crate::testing::test_resource("gadget");
755        let out1 = <GoodBackend as Morphism<_, _>>::apply(
756            &GoodBackend,
757            &ResourceInput {
758                resource: &r1,
759                provider: &p,
760            },
761        );
762        let out2 = <GoodBackend as Morphism<_, _>>::apply(
763            &GoodBackend,
764            &ResourceInput {
765                resource: &r2,
766                provider: &p,
767            },
768        );
769        assert_ne!(out1[0].source_hash, out2[0].source_hash);
770    }
771
772    #[test]
773    fn backend_morphism_catches_empty_artifact_list() {
774        let (r, p) = sample_input();
775        let input = ResourceInput {
776            resource: &r,
777            provider: &p,
778        };
779        let out = <EmptyBackend as Morphism<_, _>>::apply(&EmptyBackend, &input);
780        let violations =
781            <EmptyBackend as ProvenMorphism<_, _>>::check_invariants(&EmptyBackend, &input, &out);
782        assert!(
783            violations.iter().any(|v| v.contains("empty artifact list")),
784            "expected empty-list violation: {violations:?}",
785        );
786    }
787}