tatara_process/encapsulates.rs
1//! `EncapsulatesSpec` — how a Process relates to pre-existing
2//! in-cluster state.
3//!
4//! The substrate move: every long-running workload on a pleme-io
5//! cluster — raw HelmReleases, Flux Kustomizations, bare Deployments
6//! — becomes a Process without disruption. Three modes:
7//!
8//! * **Manage** (default) — Process IS the control loop. New
9//! HR/Kustomization emitted by the reconciler use ownerRefs
10//! pointing at the Process; cascade-delete on Reaped.
11//!
12//! * **Adopt** — Take over an existing HR/Kustomization in place.
13//! Reconciler emits a new HR with `releaseName` matching the
14//! running release; helm-controller adopts the existing release
15//! under new management. **No pod restart**; no values diff
16//! unless operator changes them. The original raw HR can be
17//! deleted from git after the takeover confirms.
18//!
19//! * **Observe** — Read-only awareness. Process watches the existing
20//! state for postcondition pillars + emits routing/exports/
21//! attestation, but does NOT modify or own the underlying
22//! HR/Kustomization. Useful for adding DNS + observability to
23//! legacy stacks without taking over.
24//!
25//! These compose progressively: Observe an HR first to confirm
26//! shape, promote to Adopt for zero-downtime takeover, then to
27//! Manage once the Process drives values.
28//!
29//! Lisp authoring:
30//! ```lisp
31//! :encapsulates (:kind (:existing-helm-release
32//! :namespace "demo-ns"
33//! :name "demo-app"
34//! :release-name "demo-app-consolidated")
35//! :mode Adopt)
36//! ```
37
38use schemars::JsonSchema;
39use serde::{Deserialize, Serialize};
40use std::collections::BTreeMap;
41use tatara_lisp::DeriveTataraDomain;
42
43/// How a Process wraps pre-existing in-cluster state.
44///
45/// Optional on `ProcessSpec` — None means the Process is greenfield
46/// (Manage mode applied to nothing pre-existing). The render phase
47/// branches on `kind` to decide whether to emit fresh resources or
48/// reference/adopt running ones.
49#[derive(DeriveTataraDomain, Clone, Debug, Serialize, Deserialize, JsonSchema)]
50#[serde(rename_all = "camelCase")]
51#[tatara(keyword = "defencapsulates")]
52pub struct EncapsulatesSpec {
53 /// What kind of pre-existing state.
54 pub kind: EncapsulationKind,
55
56 /// Reconciler's relationship to that state. Defaults to `Manage`
57 /// (the operational default when `encapsulates` is set without
58 /// an explicit mode).
59 #[serde(default)]
60 pub mode: EncapsulationMode,
61}
62
63/// Three concrete kinds the substrate knows how to wrap. Exactly-
64/// one-Option pattern matching `Intent` / `Lifetime` — additive on
65/// the wire, every variant typed.
66#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "camelCase")]
68pub struct EncapsulationKind {
69 /// An existing FluxCD HelmRelease. The reconciler emits a new HR
70 /// with the SAME `release_name` — helm-controller finds + adopts
71 /// the in-cluster release without recreating Pods.
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub existing_helm_release: Option<ExistingHelmRelease>,
74
75 /// An existing FluxCD Kustomization. The reconciler stops emitting
76 /// its own and instead references the existing one.
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub existing_kustomization: Option<ExistingKustomization>,
79
80 /// Pre-existing in-cluster workload (Deployment/StatefulSet/etc)
81 /// not Flux-managed. The reconciler adds ownerRefs + emits
82 /// routing only. The workload stays where it is.
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub bare_workload: Option<BareWorkload>,
85}
86
87/// Resolved enum view used by the render phase.
88#[derive(Clone, Debug)]
89pub enum EncapsulationKindVariant<'a> {
90 ExistingHelmRelease(&'a ExistingHelmRelease),
91 ExistingKustomization(&'a ExistingKustomization),
92 BareWorkload(&'a BareWorkload),
93}
94
95impl EncapsulationKindVariant<'_> {
96 /// Reverse projection — every borrowed variant knows its
97 /// [`EncapsulationTarget`] discriminator. Pairs with
98 /// [`EncapsulationTarget::select`] so
99 /// `EncapsulationTarget::select(kind).map(|v| v.target())`
100 /// round-trips the closed set on the populated side; pinned by
101 /// `encapsulation_target_round_trips_through_variant_target`.
102 /// Future target-keyed consumers (metric labels like
103 /// `tatara_encapsulations_total{target="existingHelmRelease"}`,
104 /// status reason strings, audit-trail classifiers, LSP completion
105 /// lists) reach through this projection instead of pattern-matching
106 /// the payload-carrying view.
107 pub fn target(&self) -> EncapsulationTarget {
108 match self {
109 Self::ExistingHelmRelease(_) => EncapsulationTarget::ExistingHelmRelease,
110 Self::ExistingKustomization(_) => EncapsulationTarget::ExistingKustomization,
111 Self::BareWorkload(_) => EncapsulationTarget::BareWorkload,
112 }
113 }
114}
115
116/// `EncapsulationKindVariant`'s [`crate::tagged_union::VariantKind`] impl
117/// delegates to the inherent [`Self::target`] — the substrate trait names
118/// the reverse projection uniformly across every borrowed-view enum on
119/// `ProcessSpec`'s tagged-union axis, while the inherent method's
120/// domain-specific name (`.target()`, matching `EncapsulationTarget`) stays
121/// load-bearing at every consumer site. The one-line delegation IS the
122/// only per-site restatement; the ground-truth arm-to-Kind mapping lives
123/// at the inherent method above.
124impl crate::tagged_union::VariantKind<EncapsulationTarget> for EncapsulationKindVariant<'_> {
125 fn variant_kind(&self) -> EncapsulationTarget {
126 self.target()
127 }
128}
129
130/// Closed-set discriminator over `EncapsulationKind`'s three tagged-union
131/// slots. Single source of truth that drives `EncapsulationKind::variant`'s
132/// ambiguity + emptiness resolver, the `EncapsulationKindError::Empty`
133/// diagnostic message, and the reverse `EncapsulationKindVariant::target`
134/// projection. Adding a fourth encapsulation target (e.g., a future
135/// `ExistingNamespace`, `ExistingDaemonSet`, or `ExistingService`) lands
136/// at one `ALL` entry + one `as_str` arm + one `select` arm + one
137/// `EncapsulationKindVariant::target` arm — exhaustively checked by the
138/// compiler.
139///
140/// The (open authoring surface, closed typed discriminator) split mirrors
141/// every other multi-Option tagged union on this `ProcessSpec` axis:
142/// [`crate::intent::IntentKind`] discriminates [`crate::intent::Intent`];
143/// [`crate::lifetime::LifetimeKind`] discriminates
144/// [`crate::lifetime::Lifetime`];
145/// [`crate::export::ArtifactKind`] discriminates
146/// [`crate::export::ArtifactSource`];
147/// [`crate::export::ChannelKind`] discriminates
148/// [`crate::export::VectorChannel`]. The carrier here is named
149/// `EncapsulationKind` (not `Encapsulation`) because it predates the
150/// closed-set lift convention; `EncapsulationTarget` is the typed
151/// discriminator the rest of the typescape projects through, named for
152/// the semantic role each variant plays (a target of encapsulation).
153#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
154#[closed_set(via = "as_str", generate_unknown, display)]
155pub enum EncapsulationTarget {
156 ExistingHelmRelease,
157 ExistingKustomization,
158 BareWorkload,
159}
160
161impl EncapsulationTarget {
162 /// The closed set of encapsulation targets — single source of truth
163 /// that drives `EncapsulationKind::variant`'s sweep so a variant
164 /// added without an `ALL` entry never reaches the resolver. The
165 /// `[Self; 3]` array literal forces the arity at compile time.
166 pub const ALL: [Self; 3] = [
167 Self::ExistingHelmRelease,
168 Self::ExistingKustomization,
169 Self::BareWorkload,
170 ];
171
172 /// Canonical camelCase wire-format key — matches the serde
173 /// `rename_all = "camelCase"` field name on the corresponding
174 /// `Option<…>` slot of `EncapsulationKind`. The
175 /// `EncapsulationKindError::Empty` diagnostic composes the
176 /// human-readable list from this projection so a new variant lands
177 /// in the operator-facing diagnostic automatically via the `ALL`
178 /// sweep, not via hand-maintained error-string drift. Pinned by
179 /// `encapsulation_target_as_str_matches_field_name`.
180 pub const fn as_str(self) -> &'static str {
181 match self {
182 Self::ExistingHelmRelease => "existingHelmRelease",
183 Self::ExistingKustomization => "existingKustomization",
184 Self::BareWorkload => "bareWorkload",
185 }
186 }
187
188 /// Project an `EncapsulationKind` borrow into the optional typed
189 /// variant view for this target. Returns `None` iff the matching
190 /// slot is `None`. Composes the closed-set sweep
191 /// `EncapsulationKind::variant` loops over. Mirrors
192 /// [`crate::intent::IntentKind::select`],
193 /// [`crate::lifetime::LifetimeKind::select`],
194 /// [`crate::export::ArtifactKind::select`], and
195 /// [`crate::export::ChannelKind::select`].
196 pub fn select<'a>(self, kind: &'a EncapsulationKind) -> Option<EncapsulationKindVariant<'a>> {
197 match self {
198 Self::ExistingHelmRelease => kind
199 .existing_helm_release
200 .as_ref()
201 .map(EncapsulationKindVariant::ExistingHelmRelease),
202 Self::ExistingKustomization => kind
203 .existing_kustomization
204 .as_ref()
205 .map(EncapsulationKindVariant::ExistingKustomization),
206 Self::BareWorkload => kind
207 .bare_workload
208 .as_ref()
209 .map(EncapsulationKindVariant::BareWorkload),
210 }
211 }
212}
213
214// `impl FromStr for EncapsulationTarget` + `impl tatara_lisp::ClosedSet for
215// EncapsulationTarget` + `impl std::fmt::Display for EncapsulationTarget` are
216// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
217// declaration above. `label` delegates to the inherent
218// `EncapsulationTarget::as_str` via `#[closed_set(via = "as_str")]` so the
219// camelCase wire-format projection stays load-bearing (matches the serde
220// `rename_all = "camelCase"` field names on `EncapsulationKind` AND the
221// `ENCAPSULATION_TARGET_LIST` slash-joined operator diagnostic verbatim)
222// while generic `T: ClosedSet` consumers reach the STABLE workspace-wide
223// name (`label`). The `display` flag emits the `f.write_str(self.as_str())`
224// delegation block at the same proc-macro site rather than a hand-rolled
225// `fmt::Display` block per implementor.
226
227// `pub struct UnknownEncapsulationTarget(pub String)` is generated by
228// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
229// on the enum declaration above. The auto-derived label `"encapsulation target"`
230// matches the prior hand-rolled `#[error("unknown encapsulation target: {0}")]`
231// verbatim — pinned generically by clause (5) of
232// `tatara_closed_set::assert_closed_set_well_formed::<EncapsulationTarget>()` (called
233// from `encapsulation_target_is_well_formed_closed_set` in the test module).
234// Symmetric to [`UnknownEncapsulationMode`], [`crate::export::UnknownArtifactKind`],
235// [`crate::export::UnknownChannelKind`], and
236// [`crate::lifetime::UnknownTeardownPolicy`].
237
238crate::declare_tagged_union_error! {
239 pub EncapsulationKindError,
240 empty = "encapsulation kind has no variant set (one of {0} required)",
241 ambiguous = "encapsulation kind has multiple variants set; exactly one required",
242}
243
244/// Slash-joined list of every `EncapsulationTarget::as_str()` — composed
245/// once at compile time so `EncapsulationKindError::Empty`'s diagnostic
246/// carries the closed-set summary without per-variant string drift.
247/// Mirrors [`crate::intent::INTENT_KIND_LIST`] /
248/// [`crate::export::ARTIFACT_KIND_LIST`] in shape; pinned by
249/// `encapsulation_kind_error_empty_lists_every_target_in_canonical_order`.
250pub(crate) const ENCAPSULATION_TARGET_LIST: &str =
251 "existingHelmRelease/existingKustomization/bareWorkload";
252
253crate::declare_tagged_union_impls! {
254 parent = EncapsulationKind,
255 kind = EncapsulationTarget,
256 variant = EncapsulationKindVariant,
257 error = EncapsulationKindError,
258 kind_list = ENCAPSULATION_TARGET_LIST,
259}
260
261/// Pointer to an existing FluxCD HelmRelease the Process wraps.
262#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
263#[serde(rename_all = "camelCase")]
264pub struct ExistingHelmRelease {
265 /// Namespace of the HelmRelease CR.
266 pub namespace: String,
267 /// Name of the HelmRelease CR.
268 pub name: String,
269 /// The `spec.releaseName` Helm used for the actual chart install.
270 /// For Adopt mode, the reconciler's emitted HR matches this so
271 /// helm-controller adopts in-place. Required because the HR's
272 /// `metadata.name` and `spec.releaseName` aren't always equal.
273 pub release_name: String,
274}
275
276/// Pointer to an existing FluxCD Kustomization the Process wraps.
277#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
278#[serde(rename_all = "camelCase")]
279pub struct ExistingKustomization {
280 pub namespace: String,
281 pub name: String,
282}
283
284/// Pointer to a bare in-cluster workload (not Flux-managed). The
285/// reconciler identifies the underlying Pods by `selector` and adds
286/// ownerRefs / routing without emitting a new HR/Kustomization.
287#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
288#[serde(rename_all = "camelCase")]
289pub struct BareWorkload {
290 /// Namespace the workload lives in.
291 pub namespace: String,
292 /// Label selector. Must match a single Deployment/StatefulSet/
293 /// DaemonSet; multiple matches are a config error.
294 pub selector: BTreeMap<String, String>,
295}
296
297/// Three modes the reconciler dispatches on at render time.
298#[derive(
299 Clone,
300 Copy,
301 Debug,
302 Default,
303 Serialize,
304 Deserialize,
305 JsonSchema,
306 PartialEq,
307 Eq,
308 Hash,
309 tatara_closed_set::DeriveClosedSet,
310)]
311#[serde(rename_all = "PascalCase")]
312#[closed_set(via = "as_str", generate_unknown, display)]
313pub enum EncapsulationMode {
314 /// **Default** — Process IS the control loop for whatever is
315 /// inside. Emitted HR/Kustomization carry the Process's
316 /// ownerRefs; cascade-delete on Reaped.
317 #[default]
318 Manage,
319
320 /// **Adopt** — Take over the existing release/kustomization in
321 /// place. New HR emitted matches the existing `releaseName`;
322 /// pods don't restart. Used during migration from raw HR → Process.
323 Adopt,
324
325 /// **Observe** — Read-only. Emit routing/exports/attestation but
326 /// don't modify the underlying HR/Kustomization. Used to add
327 /// DNS + observability to legacy stacks without taking over.
328 Observe,
329}
330
331impl EncapsulationMode {
332 /// The closed set of encapsulation modes — single source of truth
333 /// that drives the `as_str` / Display / `FromStr` triad and the
334 /// typed `emits_workload` / `preserves_release_name` dispatch.
335 /// Adding a fourth variant lands at one `ALL` entry + one `as_str`
336 /// arm + one arm in each of the two boolean projections —
337 /// exhaustively checked by the compiler (the `[Self; 3]` array
338 /// literal forces the arity).
339 ///
340 /// Sibling closed-set lifts on the same `ProcessSpec` axis:
341 /// [`crate::export::ExportTrigger::ALL`],
342 /// [`crate::lifetime::TeardownPolicy::ALL`],
343 /// [`crate::intent::IntentKind::ALL`],
344 /// [`crate::lifetime::LifetimeKind::ALL`],
345 /// [`crate::boundary::ConditionKind::ALL`],
346 /// [`crate::phase::ProcessPhase::ALL`],
347 /// [`crate::signal::ProcessSignal::ALL`].
348 pub const ALL: [Self; 3] = [Self::Manage, Self::Adopt, Self::Observe];
349
350 /// Canonical PascalCase wire-format projection — matches the serde
351 /// `rename_all = "PascalCase"` output verbatim. Used by Display
352 /// (single source of truth), by `FromStr` to identify the variant
353 /// from its annotation / status-field representation, and by
354 /// operator-facing reason strings without reaching for `{:?}` Debug
355 /// formatting. Pinned by `mode_as_str_matches_serde`.
356 pub const fn as_str(self) -> &'static str {
357 match self {
358 Self::Manage => "Manage",
359 Self::Adopt => "Adopt",
360 Self::Observe => "Observe",
361 }
362 }
363
364 /// True iff the reconciler should emit (or re-emit) the
365 /// underlying HR/Kustomization at render time.
366 /// Observe ⇒ false; Manage/Adopt ⇒ true.
367 ///
368 /// Closed-set match (not `matches!`) so adding a fourth variant
369 /// triggers the compiler's exhaustiveness check at this site
370 /// rather than silently defaulting to `false`. ONE typed dispatch
371 /// over the closed set that replaces the
372 /// `mode == EncapsulationMode::Observe` hand-rolled equality at
373 /// the reconciler's render entry — the truth table for "should
374 /// this mode emit a workload?" is now owned by the typed surface,
375 /// not by a pattern fragment two crates have to keep coherent.
376 pub const fn emits_workload(self) -> bool {
377 match self {
378 Self::Manage | Self::Adopt => true,
379 Self::Observe => false,
380 }
381 }
382
383 /// True iff the reconciler should preserve the existing release
384 /// name (so helm-controller adopts in-place). Only Adopt.
385 ///
386 /// Closed-set match (not `matches!`) so adding a fourth variant
387 /// triggers the compiler's exhaustiveness check at this site.
388 /// ONE typed dispatch that replaces the
389 /// `mode == EncapsulationMode::Adopt` hand-rolled equality at the
390 /// reconciler's `render_aplicacao` adoption-annotation branch.
391 pub const fn preserves_release_name(self) -> bool {
392 match self {
393 Self::Adopt => true,
394 Self::Manage | Self::Observe => false,
395 }
396 }
397}
398
399// `impl FromStr for EncapsulationMode` + `impl tatara_lisp::ClosedSet for
400// EncapsulationMode` + `impl std::fmt::Display for EncapsulationMode` are
401// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
402// declaration above. `label` delegates to the inherent
403// `EncapsulationMode::as_str` via `#[closed_set(via = "as_str")]` so the
404// PascalCase wire-format projection stays load-bearing (matches the serde
405// `rename_all = "PascalCase"` external-tag form on the wire AND the
406// reconciler's `mode: {Manage,Adopt,Observe}` status-condition reason
407// strings verbatim) while generic `T: ClosedSet` consumers reach the
408// STABLE workspace-wide name (`label`). The `display` flag emits the
409// `f.write_str(self.as_str())` delegation block at the same proc-macro
410// site rather than a hand-rolled `fmt::Display` block per implementor.
411
412// `pub struct UnknownEncapsulationMode(pub String)` is generated by
413// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
414// on the enum declaration above. The auto-derived label `"encapsulation mode"`
415// matches the prior hand-rolled `#[error("unknown encapsulation mode: {0}")]`
416// verbatim — pinned generically by clause (5) of
417// `tatara_closed_set::assert_closed_set_well_formed::<EncapsulationMode>()` (called
418// from `mode_is_well_formed_closed_set` in the test module).
419// Symmetric to [`UnknownEncapsulationTarget`], [`crate::export::UnknownExportTrigger`],
420// [`crate::lifetime::UnknownTeardownPolicy`],
421// [`crate::boundary::UnknownConditionKind`], and
422// [`crate::phase::UnknownPhase`].
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 fn demo_adopt() -> EncapsulatesSpec {
429 EncapsulatesSpec {
430 kind: EncapsulationKind {
431 existing_helm_release: Some(ExistingHelmRelease {
432 namespace: "demo-ns".into(),
433 name: "demo-app".into(),
434 release_name: "demo-app-consolidated".into(),
435 }),
436 ..EncapsulationKind::default()
437 },
438 mode: EncapsulationMode::Adopt,
439 }
440 }
441
442 #[test]
443 fn kind_empty_errors() {
444 let k = EncapsulationKind::default();
445 assert_eq!(
446 k.variant().unwrap_err(),
447 EncapsulationKindError::Empty(ENCAPSULATION_TARGET_LIST)
448 );
449 }
450
451 #[test]
452 fn kind_existing_hr_resolves() {
453 let s = demo_adopt();
454 match s.kind.variant().unwrap() {
455 EncapsulationKindVariant::ExistingHelmRelease(h) => {
456 assert_eq!(h.namespace, "demo-ns");
457 assert_eq!(h.release_name, "demo-app-consolidated");
458 }
459 other => panic!("expected ExistingHelmRelease, got {other:?}"),
460 }
461 }
462
463 #[test]
464 fn kind_two_variants_ambiguous() {
465 let k = EncapsulationKind {
466 existing_helm_release: Some(ExistingHelmRelease {
467 namespace: "ns".into(),
468 name: "n".into(),
469 release_name: "r".into(),
470 }),
471 existing_kustomization: Some(ExistingKustomization {
472 namespace: "ns".into(),
473 name: "n".into(),
474 }),
475 ..EncapsulationKind::default()
476 };
477 assert_eq!(k.variant().unwrap_err(), EncapsulationKindError::Ambiguous);
478 }
479
480 #[test]
481 fn mode_dispatch() {
482 assert!(EncapsulationMode::Manage.emits_workload());
483 assert!(EncapsulationMode::Adopt.emits_workload());
484 assert!(!EncapsulationMode::Observe.emits_workload());
485
486 assert!(!EncapsulationMode::Manage.preserves_release_name());
487 assert!(EncapsulationMode::Adopt.preserves_release_name());
488 assert!(!EncapsulationMode::Observe.preserves_release_name());
489 }
490
491 #[test]
492 fn mode_default_is_manage() {
493 assert_eq!(EncapsulationMode::default(), EncapsulationMode::Manage);
494 }
495
496 // ── closed-set algebra for EncapsulationMode (ALL × as_str ×
497 // Display × FromStr × emits_workload × preserves_release_name) ─
498
499 /// Structural well-formedness of [`EncapsulationMode`] as a
500 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
501 /// testkit lift that pins all three structural invariants (`ALL`
502 /// is non-empty, every variant round-trips through
503 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
504 /// outside the closed set) at ONE call site. Replaces the hand-
505 /// derived `mode_all_is_unique_and_complete` +
506 /// `mode_roundtrip_via_as_str` + the empty-input arm of
507 /// `unknown_encapsulation_mode_errors`. `FromStr` delegates to
508 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
509 /// exercises the same code path the reconciler hits when parsing a
510 /// CRD `enum:`-validated `mode` value back to the typed mode.
511 #[test]
512 fn mode_is_well_formed_closed_set() {
513 tatara_closed_set::assert_closed_set_well_formed::<EncapsulationMode>();
514 }
515
516 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
517 /// output verbatim for every variant. A future variant rename
518 /// (or an `as_str` arm typo) lands here at one site, instead of
519 /// drifting between the typed surface and the YAML wire format
520 /// the reconciler / operator both read.
521 #[test]
522 fn mode_as_str_matches_serde() {
523 crate::tagged_union::assert_label_matches_serde_serialization::<EncapsulationMode>();
524 }
525
526 /// The Display impl IS `as_str` — pinning this lets future callers
527 /// reach for either projection without drift. If a reviewer
528 /// accidentally re-introduces an inline match in Display, this
529 /// test would fail the moment a variant rename touches one site
530 /// but not the other.
531 #[test]
532 fn mode_display_matches_as_str() {
533 crate::tagged_union::assert_display_matches_label::<EncapsulationMode>();
534 }
535
536 /// `FromStr` rejects strings that aren't in the canonical
537 /// projection — lowercased / typo / unrelated — and the error
538 /// echoes the input verbatim so the operator-facing diagnostic
539 /// carries the offending value, not a normalized form. The
540 /// empty-input arm is pinned by
541 /// [`mode_is_well_formed_closed_set`] via the
542 /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
543 /// verbatim-echo contract on the [`UnknownEncapsulationMode`]
544 /// newtype, which the trait's `make_unknown` can't see.
545 #[test]
546 fn unknown_encapsulation_mode_errors() {
547 use std::str::FromStr;
548 for bad in ["manage", "ADOPT", "Observed", "Wrap"] {
549 let err = EncapsulationMode::from_str(bad).unwrap_err();
550 assert_eq!(err.0, bad, "error payload should echo input verbatim");
551 }
552 }
553
554 /// TRUTH-TABLE CONTRACT: `emits_workload` / `preserves_release_name`
555 /// agree with the documented (mode) -> (bool, bool) table for every
556 /// variant. A new variant in `EncapsulationMode` without extending
557 /// either projection's match is caught by the compiler (closed-set
558 /// match in each method); adding a variant without extending its
559 /// truth row is caught here.
560 #[test]
561 fn mode_projection_truth_table() {
562 let table: &[(EncapsulationMode, bool, bool)] = &[
563 // (mode, emits_workload, preserves_release_name)
564 (EncapsulationMode::Manage, true, false),
565 (EncapsulationMode::Adopt, true, true),
566 (EncapsulationMode::Observe, false, false),
567 ];
568 assert_eq!(table.len(), EncapsulationMode::ALL.len());
569 for (mode, emits, preserves) in table {
570 assert_eq!(
571 mode.emits_workload(),
572 *emits,
573 "emits_workload drift for {mode:?}"
574 );
575 assert_eq!(
576 mode.preserves_release_name(),
577 *preserves,
578 "preserves_release_name drift for {mode:?}"
579 );
580 }
581 }
582
583 /// DRIFT-PROOF CONTRACT: the hand-rolled
584 /// `mode == EncapsulationMode::Observe` and
585 /// `mode == EncapsulationMode::Adopt` checks the reconciler's
586 /// `render` function used pre-lift agree with the typed
587 /// projections for every variant in `ALL`. A regression that
588 /// re-introduces a raw `==` against a variant name fails here:
589 /// `!emits_workload()` IS "Observe mode" and
590 /// `preserves_release_name()` IS "Adopt mode", expressed as a
591 /// property of the typed surface rather than a pattern fragment
592 /// two crates have to keep coherent.
593 #[test]
594 fn mode_typed_projections_replace_raw_equality() {
595 for mode in EncapsulationMode::ALL {
596 assert_eq!(
597 !mode.emits_workload(),
598 mode == EncapsulationMode::Observe,
599 "!emits_workload() drift for {mode:?}"
600 );
601 assert_eq!(
602 mode.preserves_release_name(),
603 mode == EncapsulationMode::Adopt,
604 "preserves_release_name() drift for {mode:?}"
605 );
606 }
607 }
608
609 #[test]
610 fn serde_round_trip_via_yaml() {
611 let s = demo_adopt();
612 let yaml = serde_yaml::to_string(&s).unwrap();
613 assert!(yaml.contains("existingHelmRelease:"));
614 assert!(yaml.contains("releaseName: demo-app-consolidated"));
615 assert!(yaml.contains("mode: Adopt"));
616 let back: EncapsulatesSpec = serde_yaml::from_str(&yaml).unwrap();
617 assert!(back.kind.existing_helm_release.is_some());
618 assert_eq!(back.mode, EncapsulationMode::Adopt);
619 }
620
621 #[test]
622 fn bare_workload_selector_round_trips() {
623 let mut sel = BTreeMap::new();
624 sel.insert("app".into(), "demo-app".into());
625 sel.insert("tier".into(), "prod".into());
626 let s = EncapsulatesSpec {
627 kind: EncapsulationKind {
628 bare_workload: Some(BareWorkload {
629 namespace: "legacy".into(),
630 selector: sel,
631 }),
632 ..EncapsulationKind::default()
633 },
634 mode: EncapsulationMode::Observe,
635 };
636 let yaml = serde_yaml::to_string(&s).unwrap();
637 assert!(yaml.contains("bareWorkload:"));
638 assert!(yaml.contains("app: demo-app"));
639 assert!(yaml.contains("mode: Observe"));
640 let back: EncapsulatesSpec = serde_yaml::from_str(&yaml).unwrap();
641 match back.kind.variant().unwrap() {
642 EncapsulationKindVariant::BareWorkload(b) => {
643 assert_eq!(b.selector.len(), 2);
644 assert_eq!(b.selector.get("app").map(String::as_str), Some("demo-app"));
645 }
646 other => panic!("expected BareWorkload, got {other:?}"),
647 }
648 }
649
650 #[test]
651 fn lisp_round_trip_existing_hr() {
652 let src = r#"
653 (defencapsulates demo-adopt
654 :kind (:existing-helm-release
655 (:namespace "demo-ns"
656 :name "demo-app"
657 :release-name "demo-app-consolidated"))
658 :mode Adopt)
659 "#;
660 let defs: Vec<tatara_lisp::NamedDefinition<EncapsulatesSpec>> =
661 tatara_lisp::compile_named::<EncapsulatesSpec>(src).expect("compile");
662 let d = &defs[0];
663 assert_eq!(d.name, "demo-adopt");
664 assert_eq!(d.spec.mode, EncapsulationMode::Adopt);
665 let h = d.spec.kind.existing_helm_release.as_ref().unwrap();
666 assert_eq!(h.namespace, "demo-ns");
667 assert_eq!(h.release_name, "demo-app-consolidated");
668 }
669
670 #[test]
671 fn lisp_default_mode_is_manage() {
672 // `:mode` omitted ⇒ Manage (Default derive).
673 let src = r#"
674 (defencapsulates greenfield
675 :kind (:existing-kustomization
676 (:namespace "flux-system"
677 :name "openclaw")))
678 "#;
679 let defs: Vec<tatara_lisp::NamedDefinition<EncapsulatesSpec>> =
680 tatara_lisp::compile_named::<EncapsulatesSpec>(src).expect("compile");
681 let d = &defs[0];
682 assert_eq!(d.spec.mode, EncapsulationMode::Manage);
683 }
684
685 // ── closed-set algebra for EncapsulationTarget (ALL × as_str ×
686 // Display × FromStr × select × EncapsulationKindVariant::target) ─
687
688 /// Construct an `EncapsulationKind` with one slot populated — the
689 /// composable construction table the closed-set property tests
690 /// loop over. Mirrors `single_slot_source` in
691 /// [`crate::export`] in shape.
692 fn single_slot_kind(target: EncapsulationTarget) -> EncapsulationKind {
693 match target {
694 EncapsulationTarget::ExistingHelmRelease => EncapsulationKind {
695 existing_helm_release: Some(ExistingHelmRelease {
696 namespace: "ns".into(),
697 name: "hr".into(),
698 release_name: "rel".into(),
699 }),
700 ..EncapsulationKind::default()
701 },
702 EncapsulationTarget::ExistingKustomization => EncapsulationKind {
703 existing_kustomization: Some(ExistingKustomization {
704 namespace: "ns".into(),
705 name: "ks".into(),
706 }),
707 ..EncapsulationKind::default()
708 },
709 EncapsulationTarget::BareWorkload => {
710 let mut sel = BTreeMap::new();
711 sel.insert("app".into(), "x".into());
712 EncapsulationKind {
713 bare_workload: Some(BareWorkload {
714 namespace: "ns".into(),
715 selector: sel,
716 }),
717 ..EncapsulationKind::default()
718 }
719 }
720 }
721 }
722
723 /// Construct an `EncapsulationKind` with two slots populated — drives
724 /// the pairwise `Ambiguous` sweep. Composes the single-slot
725 /// constructor on top of itself to keep one source of truth for
726 /// per-variant inner payloads.
727 fn two_slot_kind(a: EncapsulationTarget, b: EncapsulationTarget) -> EncapsulationKind {
728 let ka = single_slot_kind(a);
729 let kb = single_slot_kind(b);
730 EncapsulationKind {
731 existing_helm_release: ka.existing_helm_release.or(kb.existing_helm_release),
732 existing_kustomization: ka.existing_kustomization.or(kb.existing_kustomization),
733 bare_workload: ka.bare_workload.or(kb.bare_workload),
734 }
735 }
736
737 /// Structural well-formedness of [`EncapsulationTarget`] as a
738 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
739 /// testkit lift that pins all three structural invariants (`ALL`
740 /// is non-empty, every variant round-trips through
741 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
742 /// outside the closed set) at ONE call site. Replaces the hand-
743 /// derived `encapsulation_target_all_is_unique_and_complete` +
744 /// `encapsulation_target_roundtrip_via_as_str` + the empty-input
745 /// arm of `unknown_encapsulation_target_errors`. `FromStr`
746 /// delegates to `<Self as tatara_closed_set::ClosedSet>::parse_label`, so
747 /// this helper exercises the same code path the
748 /// `EncapsulationKind::variant` resolver hits when keying on a
749 /// camelCase target name back to the typed target.
750 #[test]
751 fn encapsulation_target_is_well_formed_closed_set() {
752 tatara_closed_set::assert_closed_set_well_formed::<EncapsulationTarget>();
753 }
754
755 /// CANONICAL-KEY CONTRACT: every `EncapsulationTarget::as_str()`
756 /// matches the serde `rename_all = "camelCase"` field name on the
757 /// corresponding `Option<…>` slot of `EncapsulationKind`. A future
758 /// rename of either the struct field OR the `as_str` arm lands here
759 /// at one site, instead of drifting between the typed surface, the
760 /// wire format, and the `EncapsulationKindError::Empty` diagnostic.
761 ///
762 /// Routes through the substrate primitive
763 /// [`crate::tagged_union::assert_single_slot_key_matches_label`],
764 /// which pins the exactly-one-key + name-equality projection
765 /// byte-identically for every `<T: TaggedUnion + Serialize>`
766 /// implementor — the wire-alignment testkit shared with the sibling
767 /// `intent_kind_as_str_matches_intent_field_name` /
768 /// `artifact_kind_as_str_matches_field_name` /
769 /// `channel_kind_as_str_matches_field_name` sites. Pre-lift this
770 /// site restated a weaker YAML-substring check (`yaml.contains(&format!("{key}:"))`)
771 /// which would silently pass on drift where a non-tagged-union
772 /// field was added to `EncapsulationKind`; post-lift the primitive's
773 /// JSON exactly-one form catches that drift too — at ONE substrate
774 /// site.
775 #[test]
776 fn encapsulation_target_as_str_matches_field_name() {
777 crate::tagged_union::assert_single_slot_key_matches_label::<EncapsulationKind, _>(
778 single_slot_kind,
779 );
780 }
781
782 /// CANONICAL-NAMES PIN: byte-exact camelCase wire-format pin —
783 /// renaming any of these strings IS a wire-format break that fails
784 /// this test FIRST so the rename stays a deliberate decision, not a
785 /// typo. Locks the (variant → operator-facing key) table.
786 #[test]
787 fn encapsulation_target_canonical_names_pinned() {
788 assert_eq!(
789 EncapsulationTarget::ExistingHelmRelease.as_str(),
790 "existingHelmRelease"
791 );
792 assert_eq!(
793 EncapsulationTarget::ExistingKustomization.as_str(),
794 "existingKustomization"
795 );
796 assert_eq!(EncapsulationTarget::BareWorkload.as_str(), "bareWorkload");
797 }
798
799 /// The Display impl IS `as_str` — pinning this lets future callers
800 /// reach for either projection without drift. If a reviewer
801 /// accidentally re-introduces an inline match in Display, this test
802 /// would fail the moment a variant rename touches one site but not
803 /// the other.
804 #[test]
805 fn encapsulation_target_display_matches_as_str() {
806 crate::tagged_union::assert_display_matches_label::<EncapsulationTarget>();
807 }
808
809 /// `FromStr` rejects strings that aren't in the canonical projection
810 /// — PascalCased / typo / cross-axis-leaked inputs from sibling
811 /// closed-set enums on the same `ProcessSpec` axis (`Manage`,
812 /// `Adopt`, `Observe`, `OnAttested`, …) — and the error echoes the
813 /// input verbatim so the operator-facing diagnostic carries the
814 /// offending value, not a normalized form. `EncapsulationTarget`
815 /// is its own axis, NOT a transparent reflection of any sibling.
816 /// The empty-input arm is pinned by
817 /// [`encapsulation_target_is_well_formed_closed_set`] via the
818 /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
819 /// verbatim-echo contract on the [`UnknownEncapsulationTarget`]
820 /// newtype, which the trait's `make_unknown` can't see.
821 #[test]
822 fn unknown_encapsulation_target_errors() {
823 use std::str::FromStr;
824 for bad in [
825 "ExistingHelmRelease",
826 "existing_helm_release",
827 "EXISTINGHELMRELEASE",
828 "helmRelease",
829 "kustomization",
830 "Manage",
831 "Adopt",
832 "Observe",
833 "OnAttested",
834 ] {
835 let err = EncapsulationTarget::from_str(bad).unwrap_err();
836 assert_eq!(err.0, bad, "error payload should echo input verbatim");
837 }
838 }
839
840 /// ROUND-TRIP CONTRACT: every target reaches its borrowed-variant
841 /// view via `select`, and that variant projects back to the same
842 /// target via `EncapsulationKindVariant::target`. A regression that
843 /// misroutes a `select` arm (e.g.
844 /// `Self::ExistingHelmRelease => kind.existing_kustomization
845 /// .as_ref()...`) fails loudly here. Also pins that the resolver
846 /// lands on the same target.
847 ///
848 /// Routes through the substrate primitive
849 /// [`crate::tagged_union::assert_variant_round_trip`] shared with
850 /// the sibling `intent_kind_round_trips_through_variant_kind` /
851 /// `artifact_kind_round_trips_through_variant_kind` /
852 /// `channel_kind_round_trips_through_variant_kind` sites — the
853 /// projection lives at ONE substrate primitive and every site
854 /// binds through a single call. The `target()` inherent method
855 /// (semantic-specific to `EncapsulationTarget`) stays load-bearing
856 /// on the callsite convention while the trait projection carries
857 /// the round-trip check uniformly.
858 #[test]
859 fn encapsulation_target_round_trips_through_variant_target() {
860 crate::tagged_union::assert_variant_round_trip::<EncapsulationKind, _>(single_slot_kind);
861 }
862
863 /// SELECT-EMPTY CONTRACT: an unpopulated slot returns `None` from
864 /// `select`, for every target. Pairs with the resolver's `Empty`
865 /// path so a future target's slot defaulting wrong (e.g.
866 /// accidentally `Some(Default::default())` instead of `None`) is
867 /// caught here.
868 #[test]
869 fn encapsulation_target_select_returns_none_for_unset_slot() {
870 let empty = EncapsulationKind::default();
871 for t in EncapsulationTarget::ALL {
872 assert!(
873 t.select(&empty).is_none(),
874 "{t:?} reported populated on a default EncapsulationKind"
875 );
876 }
877 }
878
879 /// EMPTY-DIAGNOSTIC CONTRACT: the closed-set target list embedded
880 /// in `EncapsulationKindError::Empty` echoes the canonical join of
881 /// every `EncapsulationTarget::as_str()` projection. A variant
882 /// added without updating `ENCAPSULATION_TARGET_LIST` (or a renamed
883 /// variant) shows up here as a mismatch. Routes through the
884 /// substrate primitive
885 /// [`crate::tagged_union::assert_kind_list_matches_closed_set`]
886 /// shared with the sibling `intent_error_empty_lists_every_kind_in_canonical_order`
887 /// / `artifact_error_empty_lists_every_kind_in_canonical_order`
888 /// / `channel_error_empty_lists_every_kind_in_canonical_order`
889 /// sites — the projection lives at ONE substrate primitive and
890 /// every site binds through a single call. The paired assertion
891 /// that the diagnostic reaches operator-facing output verbatim
892 /// stays local because the empty-arm construction differs per
893 /// carrier.
894 #[test]
895 fn encapsulation_kind_error_empty_lists_every_target_in_canonical_order() {
896 crate::tagged_union::assert_kind_list_matches_closed_set::<EncapsulationKind>();
897 // And the diagnostic carries that exact list.
898 let err = EncapsulationKind::default().variant().unwrap_err();
899 assert_eq!(
900 err,
901 EncapsulationKindError::Empty(ENCAPSULATION_TARGET_LIST)
902 );
903 }
904
905 /// AMBIGUOUS-PATH CONTRACT: when two slots are populated the
906 /// resolver yields `Ambiguous`, exhaustively across every pair in
907 /// `ALL × ALL` (excluding the diagonal). A future asymmetry where
908 /// one slot would silently shadow another (e.g. an `if-let` chain
909 /// re-introducing first-wins ordering) is caught here. Routes
910 /// through the substrate primitive
911 /// [`crate::tagged_union::assert_two_slots_ambiguous`] shared with
912 /// the sibling `artifact_source_two_slots_is_ambiguous_across_every_pair`
913 /// / `vector_channel_two_slots_is_ambiguous_across_every_pair`
914 /// / `intent_two_slots_is_ambiguous_across_every_pair` sites — the
915 /// nested-`for a in K::ALL { for b in K::ALL { … } }` sweep lives
916 /// at ONE substrate site.
917 #[test]
918 fn encapsulation_kind_two_slots_is_ambiguous_across_every_pair() {
919 crate::tagged_union::assert_two_slots_ambiguous::<EncapsulationKind, _>(two_slot_kind);
920 }
921
922 // Per-implementor `unknown_X_message_matches_substrate_convention`
923 // tests removed — clause (5) of
924 // `tatara_closed_set::assert_closed_set_well_formed::<T>()` now verifies
925 // the substrate-wide `"unknown {SET_LABEL}: {input}"` carrier shape
926 // generically (called above on `EncapsulationTarget` /
927 // `EncapsulationMode` through their `*_is_well_formed_closed_set`
928 // sites). The `SET_LABEL` projection is pinned independently by
929 // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests` —
930 // together the two contracts guarantee the operator-facing
931 // diagnostic without needing per-enum literal pins.
932}