Skip to main content

tatara_process/
intent.rs

1//! Intent — where the rendered artifacts come from.
2//!
3//! Exactly one field on `Intent` must be set. The reconciler's RENDER phase
4//! selects a driver based on which variant is present:
5//!   - `nix`:        tatara-engine `nix_eval` → resources
6//!   - `flux`:       pass through an existing `GitRepository`
7//!   - `lisp`:       tatara-lisp reader + macroexpander → resources
8//!   - `container`:  emit Deployment/StatefulSet/etc directly (no Helm)
9//!   - `aplicacao`:  emit a FluxCD `HelmRelease` for a pleme-io typed
10//!                   Aplicacao chart (e.g. `lareira-demo-app`).
11//!                   This is the canonical handoff from caixa-shaped
12//!                   declarations to in-cluster reconciliation.
13//!   - `guest`:      tatara-hospedeiro supervises a Linux VM or WASM
14//!                   component. See `tatara/docs/declarative-guests.md`.
15//!                   The GuestSpec itself is type-erased here (JSON value)
16//!                   so tatara-process stays decoupled from tatara-vm;
17//!                   hospedeiro re-parses the value as GuestSpec on boot.
18
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use std::collections::BTreeMap;
22
23/// Intent — exactly one variant should be populated.
24#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
25#[serde(rename_all = "camelCase")]
26pub struct Intent {
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub nix: Option<NixIntent>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub flux: Option<FluxIntent>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub lisp: Option<LispIntent>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub container: Option<ContainerIntent>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub aplicacao: Option<AplicacaoIntent>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub guest: Option<GuestIntent>,
39}
40
41/// Enum view over the populated variant — convenience for the reconciler.
42#[derive(Clone, Debug)]
43pub enum IntentVariant<'a> {
44    Nix(&'a NixIntent),
45    Flux(&'a FluxIntent),
46    Lisp(&'a LispIntent),
47    Container(&'a ContainerIntent),
48    Aplicacao(&'a AplicacaoIntent),
49    Guest(&'a GuestIntent),
50}
51
52impl IntentVariant<'_> {
53    /// Reverse projection — every borrowed variant knows its
54    /// `IntentKind` discriminator. Pairs with `IntentKind::select`
55    /// so `IntentKind::select(intent).map(|v| v.kind())` round-trips
56    /// the closed set; pinned by the substrate testkit
57    /// [`crate::tagged_union::assert_variant_round_trip`] shared
58    /// across every `<T: TaggedUnion>` implementor. The inherent
59    /// method stays load-bearing (the `.kind()` calling convention
60    /// pre-dates the trait lift; no consumer needs `use
61    /// crate::tagged_union::VariantKind` to reach the reverse
62    /// projection) while the trait impl below delegates to this body
63    /// as the ground-truth arm-to-Kind mapping.
64    pub fn kind(&self) -> IntentKind {
65        match self {
66            Self::Nix(_) => IntentKind::Nix,
67            Self::Flux(_) => IntentKind::Flux,
68            Self::Lisp(_) => IntentKind::Lisp,
69            Self::Container(_) => IntentKind::Container,
70            Self::Aplicacao(_) => IntentKind::Aplicacao,
71            Self::Guest(_) => IntentKind::Guest,
72        }
73    }
74
75    /// Canonical attestation-pillar bytes for the populated variant —
76    /// `serde_json::to_vec` on the inner reference, with an empty
77    /// fallback that matches the pre-lift Observe-mode shape in
78    /// `tatara-reconciler::render`. ONE site owns the per-variant
79    /// serialization so adding a 7th variant requires only the
80    /// arm here, not the parallel match the pre-lift Observe arm
81    /// carried.
82    pub fn canonical_bytes(&self) -> Vec<u8> {
83        match self {
84            Self::Nix(n) => serde_json::to_vec(n).unwrap_or_default(),
85            Self::Flux(f) => serde_json::to_vec(f).unwrap_or_default(),
86            Self::Lisp(l) => serde_json::to_vec(l).unwrap_or_default(),
87            Self::Container(c) => serde_json::to_vec(c).unwrap_or_default(),
88            Self::Aplicacao(a) => serde_json::to_vec(a).unwrap_or_default(),
89            Self::Guest(g) => serde_json::to_vec(g).unwrap_or_default(),
90        }
91    }
92}
93
94impl crate::tagged_union::VariantKind<IntentKind> for IntentVariant<'_> {
95    fn variant_kind(&self) -> IntentKind {
96        self.kind()
97    }
98}
99
100/// Closed-set discriminator over `Intent`'s six tagged-union slots.
101/// Single source of truth that drives `Intent::variant`'s ambiguity
102/// + emptiness resolver, the `IntentError::Empty` message, and the
103/// reverse `IntentVariant::kind` projection. Adding a 7th intent
104/// variant lands at one `ALL` entry + one `as_str` arm + one
105/// `select` arm + one `IntentVariant::kind` arm — exhaustively
106/// checked by the compiler.
107#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
108#[closed_set(via = "as_str", generate_unknown, display)]
109pub enum IntentKind {
110    Nix,
111    Flux,
112    Lisp,
113    Container,
114    Aplicacao,
115    Guest,
116}
117
118impl IntentKind {
119    /// The closed set of intent kinds — single source of truth that
120    /// drives `Intent::variant`'s sweep so a variant added without
121    /// an `ALL` entry never reaches the resolver.
122    pub const ALL: [Self; 6] = [
123        Self::Nix,
124        Self::Flux,
125        Self::Lisp,
126        Self::Container,
127        Self::Aplicacao,
128        Self::Guest,
129    ];
130
131    /// Canonical lower-case wire-format key — matches the serde
132    /// `rename_all = "camelCase"` field name on `Intent`. The
133    /// `IntentError::Empty` message composes the human-readable
134    /// list from this projection so a new variant lands in the
135    /// operator-facing diagnostic automatically via the `ALL`
136    /// sweep, not via hand-maintained error-string drift.
137    pub const fn as_str(self) -> &'static str {
138        match self {
139            Self::Nix => "nix",
140            Self::Flux => "flux",
141            Self::Lisp => "lisp",
142            Self::Container => "container",
143            Self::Aplicacao => "aplicacao",
144            Self::Guest => "guest",
145        }
146    }
147
148    /// Project an `Intent` borrow into the optional typed variant
149    /// view for this kind. Returns `None` iff the matching slot is
150    /// `None`. Composes the closed-set sweep `Intent::variant`
151    /// loops over.
152    pub fn select<'a>(self, intent: &'a Intent) -> Option<IntentVariant<'a>> {
153        match self {
154            Self::Nix => intent.nix.as_ref().map(IntentVariant::Nix),
155            Self::Flux => intent.flux.as_ref().map(IntentVariant::Flux),
156            Self::Lisp => intent.lisp.as_ref().map(IntentVariant::Lisp),
157            Self::Container => intent.container.as_ref().map(IntentVariant::Container),
158            Self::Aplicacao => intent.aplicacao.as_ref().map(IntentVariant::Aplicacao),
159            Self::Guest => intent.guest.as_ref().map(IntentVariant::Guest),
160        }
161    }
162}
163
164crate::declare_tagged_union_error! {
165    pub IntentError,
166    empty = "intent has no variant set (one of {0} required)",
167    ambiguous = "intent has multiple variants set; exactly one required",
168}
169
170/// Slash-joined list of every `IntentKind::as_str()` — composed once
171/// at compile time so `IntentError::Empty`'s diagnostic carries the
172/// closed-set summary without per-variant string drift. Pinned against
173/// the canonical [`tatara_lisp::ClosedSet::labels_joined`] projection
174/// by `intent_error_empty_lists_every_kind_in_canonical_order`, so a
175/// regression that drifts this `&'static str` constant from the
176/// `IntentKind::ALL × as_str` composition fails-loudly at the test
177/// site without per-variant inline materialization.
178pub(crate) const INTENT_KIND_LIST: &str = "nix/flux/lisp/container/aplicacao/guest";
179
180// `impl FromStr for IntentKind` +
181// `impl tatara_lisp::ClosedSet for IntentKind` +
182// `impl fmt::Display for IntentKind` +
183// `pub struct UnknownIntentKind(pub String)` are all generated by
184// `#[derive(tatara_closed_set::DeriveClosedSet)]` +
185// `#[closed_set(via = "as_str", generate_unknown, display)]` on the
186// enum declaration above. `label` delegates to the inherent
187// `IntentKind::as_str` — the camelCase wire-vocabulary projection
188// stays load-bearing (matches the serde `rename_all = "camelCase"`
189// field names on `Intent` AND the `IntentVariant::canonical_bytes`
190// per-variant arm), while generic `T: ClosedSet` consumers reach the
191// STABLE workspace-wide name (`label`). The auto-derived carrier
192// label "intent kind" matches the substrate-wide
193// `#[error("unknown intent kind: {0}")]` shape every sibling
194// closed-set carrier across `tatara-process` renders verbatim.
195// Symmetric to [`crate::intent::WorkloadKind`] (the workload-axis
196// sibling on the same `ProcessSpec` slice) and every other
197// `#[derive(DeriveClosedSet)]` implementor across the crate.
198
199crate::declare_tagged_union_impls! {
200    parent = Intent,
201    kind = IntentKind,
202    variant = IntentVariant,
203    error = IntentError,
204    kind_list = INTENT_KIND_LIST,
205}
206
207/// Nix-sourced intent — tatara-engine's nix_eval driver produces resources.
208#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
209#[serde(rename_all = "camelCase")]
210pub struct NixIntent {
211    /// Flake reference, e.g., `github:pleme-io/k8s?dir=shared/infrastructure`.
212    pub flake_ref: String,
213    /// Attribute path within the flake (e.g., `observability`).
214    pub attribute: String,
215    /// Target system. Defaults to the controller host's system.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub system: Option<String>,
218    /// Attic cache to push the resulting store path into.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub attic_cache: Option<String>,
221    /// Additional `nix build` arguments (e.g., `["--impure"]`).
222    #[serde(default)]
223    pub extra_args: Vec<String>,
224    /// Delegate the actual build to a sibling NixBuild CRD
225    /// (bridges to tatara-operator NATS bare-metal builder path).
226    #[serde(default)]
227    pub delegate_to_nix_build: bool,
228}
229
230/// FluxCD passthrough intent — reuse an existing GitRepository.
231#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
232#[serde(rename_all = "camelCase")]
233pub struct FluxIntent {
234    /// Name of an existing `GitRepository` (typically in `flux-system`).
235    pub git_repository: String,
236    /// Path inside the repository that the Kustomization will apply.
237    pub path: String,
238    /// Optional namespace of the GitRepository CR (defaults to `flux-system`).
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub git_repository_namespace: Option<String>,
241    /// Optional target namespace for the emitted Kustomization.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub target_namespace: Option<String>,
244    /// SOPS decryption — defaults to true to match pleme-io conventions.
245    #[serde(default = "default_true")]
246    pub decrypt_sops: bool,
247    /// If set, additionally emit a HelmRelease for this chart.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub helm_chart: Option<String>,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub helm_values: Option<BTreeMap<String, serde_json::Value>>,
252}
253
254fn default_true() -> bool {
255    true
256}
257
258/// Lisp-sourced intent — tatara-lisp reader + macroexpander produces resources.
259#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
260#[serde(rename_all = "camelCase")]
261pub struct LispIntent {
262    /// Raw S-expression source, OR `include:<path>` / `configmap:<name>/<key>` pointer.
263    pub source: String,
264    /// Reader dialect / version tag.
265    #[serde(default = "default_reader")]
266    pub reader: String,
267    /// Macro form version.
268    #[serde(default = "default_version")]
269    pub version: String,
270    /// Symbols injected into the reader env (e.g., `cluster`, `region`).
271    #[serde(default)]
272    pub bindings: BTreeMap<String, serde_json::Value>,
273}
274
275fn default_reader() -> String {
276    "tatara-lisp".to_string()
277}
278fn default_version() -> String {
279    "v1".to_string()
280}
281
282/// Aplicacao intent — emit a FluxCD `HelmRelease` for a pleme-io
283/// typed Aplicacao chart. The chart owns its own sub-chart DAG;
284/// the reconciler only watches `HelmRelease.status.conditions[type=Ready]`.
285///
286/// This is the canonical handoff from caixa `(defaplicacao …)` declarations
287/// (which the typescape renders to this Intent) into in-cluster
288/// reconciliation. Closed-loop ephemeral test environments use this
289/// variant with `:lifetime :ephemeral` on the surrounding ProcessSpec.
290///
291/// Example (Lisp):
292/// ```lisp
293/// :intent (:aplicacao
294///           (:chart-ref "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
295///            :version "0.5.5"
296///            :profile "all-in-one"
297///            :values-overlay (:cluster (:name "ephemeral-test-01")
298///                             :persistence false
299///                             :compliance (:overlays []))))
300/// ```
301#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
302#[serde(rename_all = "camelCase")]
303pub struct AplicacaoIntent {
304    /// Helm chart reference. OCI (`oci://…`) or repo-relative (`pleme-io/lareira-demo-app`).
305    pub chart_ref: String,
306    /// Chart version (Helm semver constraint; `">=0.5.5"` allowed).
307    pub version: String,
308    /// Architecture profile from the chart's `values/*.yaml` family
309    /// (e.g. `all-in-one`, `saas-internal`).
310    /// Leave empty to use chart defaults.
311    #[serde(default, skip_serializing_if = "String::is_empty")]
312    pub profile: String,
313    /// Typed values overlay merged on top of the profile.
314    /// Free-form JSON to keep tatara-process decoupled from chart schemas.
315    #[serde(default)]
316    #[schemars(schema_with = "crate::schema_helpers::preserve_unknown_object")]
317    pub values_overlay: serde_json::Value,
318    /// HelmRelease name override. Defaults to the Process's PID-derived name.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub release_name: Option<String>,
321    /// Target namespace for the chart. Defaults to the Process's namespace.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub target_namespace: Option<String>,
324    /// Install timeout (`humantime` duration). Empty = chart-controller default.
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub install_timeout: Option<String>,
327}
328
329/// Container intent — direct Deployment/StatefulSet/etc, no Helm.
330#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
331#[serde(rename_all = "camelCase")]
332pub struct ContainerIntent {
333    pub image: String,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub replicas: Option<i32>,
336    #[serde(default)]
337    pub command: Vec<String>,
338    #[serde(default)]
339    pub args: Vec<String>,
340    #[serde(default)]
341    pub env: BTreeMap<String, String>,
342    #[serde(default)]
343    pub workload_kind: WorkloadKind,
344}
345
346/// K8s workload kind the `container` intent renders into. PascalCase
347/// values match the K8s `kind:` field on the emitted manifest verbatim,
348/// so `as_str` doubles as the canonical `kind:` projection at render time.
349#[derive(
350    Clone,
351    Copy,
352    Debug,
353    PartialEq,
354    Eq,
355    Hash,
356    Serialize,
357    Deserialize,
358    JsonSchema,
359    Default,
360    tatara_closed_set::DeriveClosedSet,
361)]
362#[serde(rename_all = "PascalCase")]
363#[closed_set(via = "as_str", generate_unknown, display)]
364pub enum WorkloadKind {
365    #[default]
366    Deployment,
367    StatefulSet,
368    DaemonSet,
369    Job,
370    CronJob,
371}
372
373impl WorkloadKind {
374    /// The closed set of workload kinds — single source of truth that
375    /// drives the `as_str` / Display / `FromStr` triad and the typed
376    /// `api_version` / `is_batch` projections. Adding a sixth variant
377    /// lands at one `ALL` entry + one `as_str` arm + one arm in each
378    /// projection — exhaustively checked by the compiler (the `[Self; 5]`
379    /// array literal forces the arity).
380    ///
381    /// Sibling closed-set lifts on the same `ProcessSpec` axis:
382    /// [`crate::encapsulates::EncapsulationMode::ALL`],
383    /// [`crate::export::ExportTrigger::ALL`],
384    /// [`crate::export::ReportFormat::ALL`],
385    /// [`crate::lifetime::TeardownPolicy::ALL`],
386    /// [`crate::intent::IntentKind::ALL`],
387    /// [`crate::lifetime::LifetimeKind::ALL`],
388    /// [`crate::boundary::ConditionKind::ALL`],
389    /// [`crate::phase::ProcessPhase::ALL`],
390    /// [`crate::signal::ProcessSignal::ALL`].
391    pub const ALL: [Self; 5] = [
392        Self::Deployment,
393        Self::StatefulSet,
394        Self::DaemonSet,
395        Self::Job,
396        Self::CronJob,
397    ];
398
399    /// Canonical PascalCase wire-format projection — matches the serde
400    /// `rename_all = "PascalCase"` output verbatim AND the K8s manifest
401    /// `kind:` field the `container` intent's future renderer will emit.
402    /// Used by Display (single source of truth), by `FromStr` to identify
403    /// the variant from its annotation / status-field representation, and
404    /// by operator-facing reason strings without reaching for `{:?}` Debug
405    /// formatting. Pinned by `workload_kind_as_str_matches_serde`.
406    pub const fn as_str(self) -> &'static str {
407        match self {
408            Self::Deployment => "Deployment",
409            Self::StatefulSet => "StatefulSet",
410            Self::DaemonSet => "DaemonSet",
411            Self::Job => "Job",
412            Self::CronJob => "CronJob",
413        }
414    }
415
416    /// Canonical K8s `apiVersion:` projection — `apps/v1` for the
417    /// long-running workload trio, `batch/v1` for the batch pair.
418    /// Single source of truth for the apiVersion the `container` intent
419    /// renderer will stamp on the emitted manifest; pinned by
420    /// `workload_kind_projection_truth_table` so a future variant lands
421    /// at one arm here, not at every render site that previously
422    /// hand-rolled `match kind { Job | CronJob => "batch/v1", _ => … }`.
423    ///
424    /// Closed-set match (not `matches!`) so adding a sixth variant
425    /// triggers the compiler's exhaustiveness check at this site
426    /// rather than silently defaulting to either group.
427    pub const fn api_version(self) -> &'static str {
428        match self {
429            Self::Deployment | Self::StatefulSet | Self::DaemonSet => "apps/v1",
430            Self::Job | Self::CronJob => "batch/v1",
431        }
432    }
433
434    /// True iff the workload kind is a batch (terminating) workload —
435    /// `Job` or `CronJob`. Drives the future container renderer's
436    /// decision between persistent / one-shot retry semantics and lets
437    /// the lifetime clock distinguish "naturally terminates" from "runs
438    /// until SIGTERM" without re-deriving the partition from
439    /// `api_version() == "batch/v1"`.
440    ///
441    /// Closed-set match (not `matches!`) so adding a sixth variant
442    /// triggers the compiler's exhaustiveness check at this site.
443    pub const fn is_batch(self) -> bool {
444        match self {
445            Self::Job | Self::CronJob => true,
446            Self::Deployment | Self::StatefulSet | Self::DaemonSet => false,
447        }
448    }
449}
450
451// `impl FromStr for WorkloadKind` +
452// `impl tatara_lisp::ClosedSet for WorkloadKind` +
453// `impl fmt::Display for WorkloadKind` +
454// `pub struct UnknownWorkloadKind(pub String)` are all generated by
455// `#[derive(tatara_closed_set::DeriveClosedSet)]` +
456// `#[closed_set(via = "as_str", generate_unknown, display)]` on the
457// enum declaration above. `label` delegates to the inherent
458// `WorkloadKind::as_str` — the PascalCase wire-vocabulary projection
459// stays load-bearing (matches the serde `rename_all = "PascalCase"`
460// output AND the K8s manifest `kind:` field verbatim), while generic
461// `T: ClosedSet` consumers reach the STABLE workspace-wide name
462// (`label`). The auto-derived carrier label "workload kind" matches
463// the prior hand-rolled `#[error("unknown workload kind: {0}")]`
464// annotation byte-for-byte. Symmetric to every other
465// `#[derive(DeriveClosedSet)]` implementor across the crate.
466
467/// Guest intent — the Process is a Linux VM or WASM component supervised
468/// by `tatara-hospedeiro`. See `tatara/docs/declarative-guests.md`.
469///
470/// The actual `GuestSpec` is stored as a serde JSON value to keep
471/// `tatara-process` decoupled from `tatara-vm`. Hospedeiro re-parses
472/// the value as the concrete `tatara_vm::GuestSpec` at boot time; a
473/// round-trip test on the tatara-vm side guarantees the shape.
474#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
475#[serde(rename_all = "camelCase")]
476pub struct GuestIntent {
477    /// The (defguest …) spec as JSON. Shape matches `tatara_vm::GuestSpec`.
478    #[schemars(schema_with = "crate::schema_helpers::preserve_unknown_object")]
479    pub spec: serde_json::Value,
480
481    /// Where to write per-guest state on the host (logs, socket, PID file).
482    /// Defaults to `~/.local/state/tatara/guests/<name>/`.
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub state_dir: Option<String>,
485
486    /// Whether hospedeiro is allowed to pull guest artifacts from a remote
487    /// transport (Attic, ssh-ng) if not already present locally. The
488    /// default is taken from the GuestSpec's `buildOn` field; setting
489    /// this explicitly overrides at the intent layer.
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub allow_remote_build: Option<bool>,
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn empty_intent_errors() {
500        let i = Intent::default();
501        match i.variant().unwrap_err() {
502            IntentError::Empty(list) => assert_eq!(list, INTENT_KIND_LIST),
503            other => panic!("expected Empty, got {other:?}"),
504        }
505    }
506
507    #[test]
508    fn exactly_one_ok() {
509        let i = Intent {
510            nix: Some(NixIntent {
511                flake_ref: "github:a/b".into(),
512                attribute: "x".into(),
513                system: None,
514                attic_cache: None,
515                extra_args: vec![],
516                delegate_to_nix_build: false,
517            }),
518            ..Intent::default()
519        };
520        assert!(matches!(i.variant().unwrap(), IntentVariant::Nix(_)));
521    }
522
523    /// AMBIGUOUS-PATH CONTRACT: when two slots are populated the
524    /// resolver yields `Ambiguous`, exhaustively across every pair in
525    /// `ALL × ALL` (excluding the diagonal). Routes through the
526    /// substrate primitive
527    /// [`crate::tagged_union::assert_two_slots_ambiguous`] shared with
528    /// the sibling
529    /// `encapsulation_kind_two_slots_is_ambiguous_across_every_pair`
530    /// / `artifact_source_two_slots_is_ambiguous_across_every_pair`
531    /// / `vector_channel_two_slots_is_ambiguous_across_every_pair`
532    /// sites. Subsumes the pre-lift hand-authored two-pair probes
533    /// (`nix + flux`, `nix + guest`) with exhaustive `6 × 5 = 30`
534    /// coverage — every off-diagonal pair on `IntentKind` is pinned.
535    #[test]
536    fn intent_two_slots_is_ambiguous_across_every_pair() {
537        crate::tagged_union::assert_two_slots_ambiguous::<Intent, _>(two_slot_intent);
538    }
539
540    #[test]
541    fn guest_intent_selects_its_variant() {
542        let i = Intent {
543            guest: Some(GuestIntent {
544                spec: serde_json::json!({
545                    "name": "fast-fn",
546                    "kind": { "kind": "wasm", "runtime": "wasmtime",
547                              "wasiPreview": "p2",
548                              "component": { "kind": "flake",
549                                             "value": {"url":"github:x/y","attr":"wasi"} },
550                              "features": { "simd": true } },
551                    "cmdline": []
552                }),
553                state_dir: None,
554                allow_remote_build: Some(true),
555            }),
556            ..Intent::default()
557        };
558        match i.variant().unwrap() {
559            IntentVariant::Guest(g) => {
560                assert_eq!(g.spec["name"], "fast-fn");
561                assert_eq!(g.allow_remote_build, Some(true));
562            }
563            other => panic!("expected Guest, got {other:?}"),
564        }
565    }
566
567    #[test]
568    fn aplicacao_intent_selects_its_variant() {
569        let i = Intent {
570            aplicacao: Some(AplicacaoIntent {
571                chart_ref: "oci://ghcr.io/pleme-io/charts/lareira-demo-app".into(),
572                version: "0.5.5".into(),
573                profile: "all-in-one".into(),
574                values_overlay: serde_json::json!({ "cluster": { "name": "test-01" } }),
575                release_name: None,
576                target_namespace: None,
577                install_timeout: Some("25m".into()),
578            }),
579            ..Intent::default()
580        };
581        match i.variant().unwrap() {
582            IntentVariant::Aplicacao(a) => {
583                assert_eq!(a.profile, "all-in-one");
584                assert_eq!(a.version, "0.5.5");
585                assert_eq!(a.install_timeout.as_deref(), Some("25m"));
586            }
587            other => panic!("expected Aplicacao, got {other:?}"),
588        }
589    }
590
591    /// Structural well-formedness of [`IntentKind`] as a
592    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
593    /// testkit lift that pins all structural invariants (`ALL` is
594    /// non-empty, every variant round-trips through `label ↔
595    /// parse_label`, labels are pairwise distinct, `""` is outside
596    /// the closed set, the `UnknownIntentKind` carrier's Display
597    /// renders the substrate-wide `"unknown intent kind: <input>"`
598    /// shape, `labels()` equals the natural `ALL × label` projection,
599    /// `parse_label_with_hint` composes `parse_label` +
600    /// `suggest_closest` verbatim) at ONE call site. Replaces the
601    /// hand-derived `intent_kind_all_is_unique_and_complete` —
602    /// clause (1)+(3) of the testkit subsume the uniqueness +
603    /// non-emptiness sweep that test pinned independently.
604    #[test]
605    fn intent_kind_is_well_formed_closed_set() {
606        tatara_closed_set::assert_closed_set_well_formed::<IntentKind>();
607    }
608
609    /// The Display impl IS `as_str` — pinning this lets future callers
610    /// reach for either projection without drift. Symmetric to the
611    /// sibling `workload_kind_display_matches_as_str` invariant; if a
612    /// reviewer accidentally re-introduces an inline match in Display,
613    /// this test would fail the moment a variant rename touches one
614    /// site but not the other.
615    ///
616    /// Routes through the substrate primitive
617    /// [`crate::tagged_union::assert_display_matches_label`], which
618    /// composes `<T as ClosedSet>::label` against `T::to_string`
619    /// byte-identically for every `<T: ClosedSet + Display>`
620    /// implementor — the Display-alignment testkit shared with every
621    /// sibling `X_display_matches_as_str` site across the crate.
622    /// Pre-lift the 27 bodies each restated the same
623    /// `for k in K::ALL { assert_eq!(k.to_string(), k.as_str()) }`
624    /// two-line probe at the test surface; post-lift the projection
625    /// lives at ONE substrate primitive and every site binds through
626    /// a single call.
627    #[test]
628    fn intent_kind_display_matches_as_str() {
629        crate::tagged_union::assert_display_matches_label::<IntentKind>();
630    }
631
632    /// CANONICAL-KEY CONTRACT: each variant's `as_str()` matches the
633    /// camelCase serde field name on `Intent`. A future rename of
634    /// any field lands here at one site — and the `Empty` diagnostic
635    /// composed from `INTENT_KIND_LIST` stays coherent with the
636    /// wire format.
637    ///
638    /// Routes through the substrate primitive
639    /// [`crate::tagged_union::assert_single_slot_key_matches_label`],
640    /// which pins the exactly-one-key + name-equality projection
641    /// byte-identically for every `<T: TaggedUnion + Serialize>`
642    /// implementor — the wire-alignment testkit shared with the sibling
643    /// `encapsulation_target_as_str_matches_field_name` /
644    /// `artifact_kind_as_str_matches_field_name` /
645    /// `channel_kind_as_str_matches_field_name` sites. Pre-lift the
646    /// four bodies each restated the same serialize-and-inspect sweep
647    /// at the test surface (three through a weaker YAML-substring
648    /// check; this site alone through the strong JSON-object exactly-
649    /// one form); post-lift the projection lives at ONE substrate
650    /// primitive and every site binds through a single call — the
651    /// three YAML sites simultaneously upgrade to the strong exactly-
652    /// one form.
653    #[test]
654    fn intent_kind_as_str_matches_intent_field_name() {
655        crate::tagged_union::assert_single_slot_key_matches_label::<Intent, _>(single_slot_intent);
656    }
657
658    /// ROUND-TRIP CONTRACT: `IntentKind::select(intent).map(|v|
659    /// v.kind()) == Some(kind)`. The reverse `IntentVariant::kind`
660    /// projection composes the closed set in both directions — a
661    /// regression that misroutes a select arm (e.g. `Self::Nix =>
662    /// intent.flux.as_ref()...`) fails loudly here.
663    ///
664    /// Routes through the substrate primitive
665    /// [`crate::tagged_union::assert_variant_round_trip`], which
666    /// composes [`crate::tagged_union::VariantSelector::select`]
667    /// (forward) with [`crate::tagged_union::VariantKind::variant_kind`]
668    /// (reverse) byte-identically for every `<T: TaggedUnion>`
669    /// implementor — the round-trip testkit shared with the sibling
670    /// `artifact_kind_round_trips_through_variant_kind` /
671    /// `channel_kind_round_trips_through_variant_kind` /
672    /// `encapsulation_target_round_trips_through_variant_target`
673    /// sites. Pre-lift the four bodies each restated the same
674    /// two-arm round-trip probe at the test surface; post-lift the
675    /// projection lives at ONE substrate primitive and every site
676    /// binds through a single call.
677    #[test]
678    fn intent_kind_round_trips_through_variant_kind() {
679        crate::tagged_union::assert_variant_round_trip::<Intent, _>(single_slot_intent);
680    }
681
682    /// EMPTY-DIAGNOSTIC CONTRACT: the closed-set kind list embedded
683    /// in `IntentError::Empty` echoes the canonical join of every
684    /// `IntentKind::as_str()` projection. A variant added without
685    /// updating `INTENT_KIND_LIST` (or a renamed variant) shows up
686    /// here as a mismatch.
687    ///
688    /// Routes through the substrate primitive
689    /// [`crate::tagged_union::assert_kind_list_matches_closed_set`],
690    /// which composes `<T::Kind as ClosedSet>::labels_joined("/")`
691    /// against `<T as TaggedUnion>::KIND_LIST` byte-identically for
692    /// every implementor — the diagnostic-stability testkit shared
693    /// with the sibling `artifact_error_empty_lists_every_kind_in_canonical_order`
694    /// / `channel_error_empty_lists_every_kind_in_canonical_order`
695    /// / `encapsulation_kind_error_empty_lists_every_target_in_canonical_order`
696    /// sites. Pre-lift the four bodies each restated the same
697    /// two-argument `assert_eq!(<XxxKind as ClosedSet>::labels_joined("/"),
698    /// XXX_KIND_LIST)` comparison at the test surface; post-lift
699    /// the projection lives at ONE substrate primitive and every
700    /// site binds through a single call.
701    #[test]
702    fn intent_error_empty_lists_every_kind_in_canonical_order() {
703        crate::tagged_union::assert_kind_list_matches_closed_set::<Intent>();
704    }
705
706    /// CANONICAL-BYTES CONTRACT: every populated variant yields the
707    /// SAME bytes as `serde_json::to_vec` on the inner reference.
708    /// Pins the lift of the parallel observe-mode match in
709    /// `tatara-reconciler::render` to this single method.
710    #[test]
711    fn intent_variant_canonical_bytes_matches_inner_serialize() {
712        for kind in IntentKind::ALL {
713            let i = single_slot_intent(kind);
714            let v = i.variant().expect("exactly-one variant");
715            let via_method = v.canonical_bytes();
716            let expected: Vec<u8> = match &v {
717                IntentVariant::Nix(n) => serde_json::to_vec(n).unwrap_or_default(),
718                IntentVariant::Flux(f) => serde_json::to_vec(f).unwrap_or_default(),
719                IntentVariant::Lisp(l) => serde_json::to_vec(l).unwrap_or_default(),
720                IntentVariant::Container(c) => serde_json::to_vec(c).unwrap_or_default(),
721                IntentVariant::Aplicacao(a) => serde_json::to_vec(a).unwrap_or_default(),
722                IntentVariant::Guest(g) => serde_json::to_vec(g).unwrap_or_default(),
723            };
724            assert_eq!(
725                via_method, expected,
726                "canonical_bytes mismatch for {kind:?}"
727            );
728            assert!(!via_method.is_empty(), "{kind:?} produced empty bytes");
729        }
730    }
731
732    /// Construct an `Intent` with two slots populated — drives the
733    /// pairwise `Ambiguous` sweep through the substrate primitive
734    /// [`crate::tagged_union::assert_two_slots_ambiguous`]. Composes
735    /// the single-slot constructor on top of itself per-field so ONE
736    /// source of truth for per-variant inner payloads is preserved.
737    /// Mirrors `two_slot_source` / `two_slot_channel` / `two_slot_kind`
738    /// in shape across `ProcessSpec`'s tagged-union axis.
739    fn two_slot_intent(a: IntentKind, b: IntentKind) -> Intent {
740        let ia = single_slot_intent(a);
741        let ib = single_slot_intent(b);
742        Intent {
743            nix: ia.nix.or(ib.nix),
744            flux: ia.flux.or(ib.flux),
745            lisp: ia.lisp.or(ib.lisp),
746            container: ia.container.or(ib.container),
747            aplicacao: ia.aplicacao.or(ib.aplicacao),
748            guest: ia.guest.or(ib.guest),
749        }
750    }
751
752    /// Construct an `Intent` with exactly the given kind's slot
753    /// populated by a minimal valid inner spec. Shared across the
754    /// closed-set property tests so they each cover every variant
755    /// without restating the construction table.
756    fn single_slot_intent(kind: IntentKind) -> Intent {
757        match kind {
758            IntentKind::Nix => Intent {
759                nix: Some(NixIntent {
760                    flake_ref: "github:a/b".into(),
761                    attribute: "x".into(),
762                    system: None,
763                    attic_cache: None,
764                    extra_args: vec![],
765                    delegate_to_nix_build: false,
766                }),
767                ..Intent::default()
768            },
769            IntentKind::Flux => Intent {
770                flux: Some(FluxIntent {
771                    git_repository: "g".into(),
772                    path: "p".into(),
773                    git_repository_namespace: None,
774                    target_namespace: None,
775                    decrypt_sops: true,
776                    helm_chart: None,
777                    helm_values: None,
778                }),
779                ..Intent::default()
780            },
781            IntentKind::Lisp => Intent {
782                lisp: Some(LispIntent {
783                    source: "()".into(),
784                    reader: "tatara-lisp".into(),
785                    version: "v1".into(),
786                    bindings: BTreeMap::new(),
787                }),
788                ..Intent::default()
789            },
790            IntentKind::Container => Intent {
791                container: Some(ContainerIntent {
792                    image: "ghcr.io/x:1".into(),
793                    replicas: Some(1),
794                    command: vec![],
795                    args: vec![],
796                    env: BTreeMap::new(),
797                    workload_kind: WorkloadKind::default(),
798                }),
799                ..Intent::default()
800            },
801            IntentKind::Aplicacao => Intent {
802                aplicacao: Some(AplicacaoIntent {
803                    chart_ref: "oci://ghcr.io/x".into(),
804                    version: "0.1.0".into(),
805                    profile: String::new(),
806                    values_overlay: serde_json::Value::Null,
807                    release_name: None,
808                    target_namespace: None,
809                    install_timeout: None,
810                }),
811                ..Intent::default()
812            },
813            IntentKind::Guest => Intent {
814                guest: Some(GuestIntent {
815                    spec: serde_json::json!({"name": "guest-1"}),
816                    state_dir: None,
817                    allow_remote_build: None,
818                }),
819                ..Intent::default()
820            },
821        }
822    }
823
824    // ── closed-set algebra for WorkloadKind (ALL × as_str × Display ×
825    //    FromStr × api_version × is_batch) ─────────────────────────────
826
827    /// Structural well-formedness of [`WorkloadKind`] as a
828    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
829    /// testkit lift that pins all three structural invariants (`ALL`
830    /// is non-empty, every variant round-trips through `label ↔
831    /// parse_label`, labels are pairwise distinct, `""` is outside the
832    /// closed set) at ONE call site. Replaces the hand-derived
833    /// `workload_kind_all_is_unique_and_complete` +
834    /// `workload_kind_roundtrip_via_as_str` + the empty-input arm of
835    /// `unknown_workload_kind_errors`. `FromStr` delegates to
836    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
837    /// exercises the same code path the reconciler hits when parsing a
838    /// K8s `kind:`-shaped value back to the typed workload kind.
839    #[test]
840    fn workload_kind_is_well_formed_closed_set() {
841        tatara_closed_set::assert_closed_set_well_formed::<WorkloadKind>();
842    }
843
844    /// CANONICAL-KEY CONTRACT: every variant's `as_str()` matches serde's
845    /// PascalCase output verbatim. A future variant rename (or an
846    /// `as_str` arm typo) lands at one site, instead of drifting
847    /// between the typed surface, the K8s `kind:` manifest field, and
848    /// the YAML wire format the reconciler / operator both read.
849    #[test]
850    fn workload_kind_as_str_matches_serde() {
851        crate::tagged_union::assert_label_matches_serde_serialization::<WorkloadKind>();
852    }
853
854    /// The Display impl IS `as_str` — pinning this lets future callers
855    /// reach for either projection without drift. If a reviewer
856    /// accidentally re-introduces an inline match in Display, this
857    /// test would fail the moment a variant rename touches one site
858    /// but not the other.
859    #[test]
860    fn workload_kind_display_matches_as_str() {
861        crate::tagged_union::assert_display_matches_label::<WorkloadKind>();
862    }
863
864    /// `FromStr` rejects strings that aren't in the canonical
865    /// projection — lowercased / typo / unrelated — and the error
866    /// echoes the input verbatim so the operator-facing diagnostic
867    /// carries the offending value, not a normalized form. The
868    /// empty-input arm is pinned by
869    /// [`workload_kind_is_well_formed_closed_set`] via the
870    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
871    /// verbatim-echo contract on the [`UnknownWorkloadKind`]
872    /// newtype, which the trait's `make_unknown` can't see.
873    #[test]
874    fn unknown_workload_kind_errors() {
875        use std::str::FromStr;
876        for bad in ["deployment", "JOB", "ReplicaSet", "Pod"] {
877            let err = WorkloadKind::from_str(bad).unwrap_err();
878            assert_eq!(err.0, bad, "error payload should echo input verbatim");
879        }
880    }
881
882    #[test]
883    fn workload_kind_default_is_deployment() {
884        assert_eq!(WorkloadKind::default(), WorkloadKind::Deployment);
885    }
886
887    /// TRUTH-TABLE CONTRACT: `api_version` / `is_batch` agree with the
888    /// documented (kind) -> (apiVersion, is_batch) table for every
889    /// variant. A new variant in `WorkloadKind` without extending
890    /// either projection's match is caught by the compiler (closed-set
891    /// match in each method); adding a variant without extending its
892    /// truth row is caught here. Also pins the invariant
893    /// `is_batch <=> api_version == "batch/v1"`, so a future renderer
894    /// can route on either projection without re-deriving the partition.
895    #[test]
896    fn workload_kind_projection_truth_table() {
897        let table: &[(WorkloadKind, &str, bool)] = &[
898            // (kind, api_version, is_batch)
899            (WorkloadKind::Deployment, "apps/v1", false),
900            (WorkloadKind::StatefulSet, "apps/v1", false),
901            (WorkloadKind::DaemonSet, "apps/v1", false),
902            (WorkloadKind::Job, "batch/v1", true),
903            (WorkloadKind::CronJob, "batch/v1", true),
904        ];
905        assert_eq!(table.len(), WorkloadKind::ALL.len());
906        for (kind, api, batch) in table {
907            assert_eq!(kind.api_version(), *api, "api_version drift for {kind:?}");
908            assert_eq!(kind.is_batch(), *batch, "is_batch drift for {kind:?}");
909            assert_eq!(
910                kind.is_batch(),
911                kind.api_version() == "batch/v1",
912                "is_batch / api_version partition disagrees for {kind:?}"
913            );
914        }
915    }
916
917    #[test]
918    fn aplicacao_plus_flux_is_ambiguous() {
919        let i = Intent {
920            aplicacao: Some(AplicacaoIntent {
921                chart_ref: "x".into(),
922                version: "1".into(),
923                profile: String::new(),
924                values_overlay: serde_json::Value::Null,
925                release_name: None,
926                target_namespace: None,
927                install_timeout: None,
928            }),
929            flux: Some(FluxIntent {
930                git_repository: "g".into(),
931                path: "p".into(),
932                git_repository_namespace: None,
933                target_namespace: None,
934                decrypt_sops: true,
935                helm_chart: None,
936                helm_values: None,
937            }),
938            ..Intent::default()
939        };
940        assert_eq!(i.variant().unwrap_err(), IntentError::Ambiguous);
941    }
942}