crates/kernel/ents-model/src/effect.rs
effect.rshistorycomment on this file
| 1 | //! The Effect entity: a declarative subscription to a commit-set query. |
| 2 | //! |
| 3 | //! Spec coverage: `model.effect-definition`. |
| 4 | |
| 5 | use ents_attrs as ents; |
| 6 | use facet::Facet; |
| 7 | |
| 8 | /// A declarative effect definition, living at `refs/meta/effects/<name>` |
| 9 | /// (`namespace::effect_ref`). |
| 10 | /// |
| 11 | /// `trigger` is the raw `CommitQuery` text (`query.grammar`): the algebra |
| 12 | /// itself — parsing, footprint extraction, incremental evaluation — is |
| 13 | /// `ents-query`'s domain (phase 3). Storing it as `String` here rather than |
| 14 | /// a parsed AST keeps the dependency edge the crate graph already states: |
| 15 | /// `ents-query` depends on `ents-model`, never the reverse. |
| 16 | /// |
| 17 | /// `model.effect-definition` explicitly forbids executor, sandbox, or |
| 18 | /// retry fields — how an effect runs is a deployment property |
| 19 | /// (`effect.deployment-property`), decided by `ents-effect` (phase 5) and |
| 20 | /// the composition root, never stored on the entity itself. |
| 21 | /// |
| 22 | /// # Examples |
| 23 | /// |
| 24 | /// ``` |
| 25 | /// use ents_model::Effect; |
| 26 | /// |
| 27 | /// let effect = Effect { |
| 28 | /// name: "unit".to_owned(), |
| 29 | /// trigger: "rev(refs/heads/main)".to_owned(), |
| 30 | /// toolchains: vec!["rust-stable".to_owned()], |
| 31 | /// run: "cargo nextest run".to_owned(), |
| 32 | /// }; |
| 33 | /// let (id, store) = facet_git_tree::serialize(&effect).expect("serialize"); |
| 34 | /// let back: Effect = facet_git_tree::deserialize(&id, &store).expect("deserialize"); |
| 35 | /// assert_eq!(back, effect); |
| 36 | /// ``` |
| 37 | // @relation(model.effect-definition, effect.definition, effect.deployment-property, meta-ref.identity-binding, meta-ref.typed-tree, model.extensibility, scope=file) |
| 38 | #[derive(Debug, Clone, PartialEq, Eq, Facet)] |
| 39 | pub struct Effect { |
| 40 | /// The effect's own name — the natural key the refname's final segment |
| 41 | /// binds to (`model.effect-definition`, `meta-ref.identity-binding`): |
| 42 | /// the gate recomputes `refs/meta/effects/<name>` from this field. |
| 43 | #[facet(ents::skip)] |
| 44 | pub name: String, |
| 45 | /// The raw `CommitQuery` text denoting the commit set this effect |
| 46 | /// fires for (`query.grammar`). |
| 47 | #[facet(ents::col)] |
| 48 | pub trigger: String, |
| 49 | /// The names of the toolchains this effect's run requires, each a |
| 50 | /// `refs/meta/toolchains/<name>` reference (`model.toolchain`). |
| 51 | #[facet(ents::skip_empty)] |
| 52 | pub toolchains: Vec<String>, |
| 53 | /// The run command. |
| 54 | pub run: String, |
| 55 | } |
| 56 | |
| 57 | #[cfg(test)] |
| 58 | mod tests { |
| 59 | #![allow( |
| 60 | clippy::expect_used, |
| 61 | clippy::panic, |
| 62 | reason = "unit test; the panic is an assertion the type reflects as a struct at all" |
| 63 | )] |
| 64 | |
| 65 | use facet::{Facet as _, Type, UserType}; |
| 66 | use facet_git_tree::{deserialize, serialize}; |
| 67 | use rstest::rstest; |
| 68 | |
| 69 | use super::*; |
| 70 | |
| 71 | #[rstest] |
| 72 | // @relation(model.effect-definition, effect.definition, meta-ref.typed-tree, scope=function, role=Verifies) |
| 73 | fn effect_round_trips_through_a_tree() { |
| 74 | let effect = Effect { |
| 75 | name: "unit".to_owned(), |
| 76 | trigger: "rev(refs/heads/main) & results(unit, pass)".to_owned(), |
| 77 | toolchains: vec!["rust-stable".to_owned(), "node-lts".to_owned()], |
| 78 | run: "cargo nextest run".to_owned(), |
| 79 | }; |
| 80 | let (id, store) = serialize(&effect).expect("serialize"); |
| 81 | let back: Effect = deserialize(&id, &store).expect("deserialize"); |
| 82 | assert_eq!(back, effect); |
| 83 | } |
| 84 | |
| 85 | #[rstest] |
| 86 | #[case::executor("executor")] |
| 87 | #[case::sandbox("sandbox")] |
| 88 | #[case::retry("retry")] |
| 89 | // @relation(model.effect-definition, effect.deployment-property, scope=function, role=Verifies) |
| 90 | fn effect_never_carries_a_deployment_field(#[case] forbidden: &str) { |
| 91 | let Type::User(UserType::Struct(struct_ty)) = Effect::SHAPE.ty else { |
| 92 | panic!("Effect must reflect as a struct"); |
| 93 | }; |
| 94 | assert!( |
| 95 | struct_ty.fields.iter().all(|f| f.name != forbidden), |
| 96 | "Effect must not carry a {forbidden:?} field: how it runs is a deployment property" |
| 97 | ); |
| 98 | } |
| 99 | } |