Skip to main content

max / makenotwork

22.0 KB · 712 lines History Blame Raw
1 //! Domain types — the vocabulary every other module speaks.
2 //!
3 //! These newtypes replace string-typed fields across the daemon, schema,
4 //! WS payloads, and TUI. Construction is the boundary parse: a `Version`
5 //! exists because some byte sequence at the edge of the process passed
6 //! semver validation; downstream code is freed from re-validating it.
7 //!
8 //! All types implement `Display`, `FromStr`, `Serialize`, `Deserialize`,
9 //! and `sqlx::Type<Sqlite>` so they round-trip through events, JSON
10 //! responses, and SQLite columns without per-site conversion.
11 //!
12 //! See `plans/observability.md` for the architecture this is the first
13 //! step of.
14
15 // Step 1 is pure addition: nothing else in the crate uses these yet.
16 // Steps 2-7 thread the types through call sites; remove the allow then.
17 #![allow(dead_code)]
18
19 use serde::{Deserialize, Serialize};
20 use sqlx::Sqlite;
21 use std::fmt;
22 use std::str::FromStr;
23
24 // ---------------------------------------------------------------------
25 // String-backed identifiers
26 // ---------------------------------------------------------------------
27
28 /// A product Sando ships (e.g. "mnw", "pom").
29 ///
30 /// Sando was single-product for its whole life, so every keyed table, every
31 /// tier, and every release root was implicitly about the MNW server. Nothing
32 /// said so, which is why nothing had to be changed when a second product
33 /// arrived and everything had to be changed at once. This is that "which
34 /// product", made explicit and threaded rather than assumed.
35 ///
36 /// [`DEFAULT_APP`] is what a pre-multi-app config and every pre-multi-app row
37 /// mean, so an existing deployment keeps working unedited.
38 #[derive(
39 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type,
40 )]
41 #[sqlx(transparent)]
42 #[serde(transparent)]
43 pub struct AppId(String);
44
45 /// The app a config with no `[app.*]` tables describes, and the value migration
46 /// 011 backfills every pre-existing row to. Sando's one product until 2026-08-06.
47 pub const DEFAULT_APP: &str = "mnw";
48
49 impl AppId {
50 pub fn new(s: impl Into<String>) -> Self {
51 Self(s.into())
52 }
53 pub fn as_str(&self) -> &str {
54 &self.0
55 }
56 }
57
58 impl Default for AppId {
59 fn default() -> Self {
60 Self(DEFAULT_APP.to_owned())
61 }
62 }
63
64 impl fmt::Display for AppId {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 self.0.fmt(f)
67 }
68 }
69
70 impl FromStr for AppId {
71 type Err = std::convert::Infallible;
72 fn from_str(s: &str) -> Result<Self, Self::Err> {
73 Ok(Self(s.to_owned()))
74 }
75 }
76
77 impl From<&str> for AppId {
78 fn from(s: &str) -> Self {
79 Self(s.to_owned())
80 }
81 }
82
83 impl From<String> for AppId {
84 fn from(s: String) -> Self {
85 Self(s)
86 }
87 }
88
89 /// A tier in the deploy topology (e.g. "host", "a", "b").
90 ///
91 /// Construction does no cross-validation against the loaded `Topology` —
92 /// that is the responsibility of `Topology::load`, which mints the
93 /// canonical `TierId` set. Use `TierId::new` only at boundaries (config
94 /// load, deserialization of inbound requests).
95 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
96 #[sqlx(transparent)]
97 #[serde(transparent)]
98 pub struct TierId(String);
99
100 impl TierId {
101 pub fn new(s: impl Into<String>) -> Self {
102 Self(s.into())
103 }
104 pub fn as_str(&self) -> &str {
105 &self.0
106 }
107 }
108
109 impl fmt::Display for TierId {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 self.0.fmt(f)
112 }
113 }
114
115 impl FromStr for TierId {
116 type Err = std::convert::Infallible;
117 fn from_str(s: &str) -> Result<Self, Self::Err> {
118 Ok(Self(s.to_owned()))
119 }
120 }
121
122 impl From<&str> for TierId {
123 fn from(s: &str) -> Self {
124 Self(s.to_owned())
125 }
126 }
127
128 impl From<String> for TierId {
129 fn from(s: String) -> Self {
130 Self(s)
131 }
132 }
133
134 /// A node name within a tier (e.g. "alpha-west-1").
135 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
136 #[sqlx(transparent)]
137 #[serde(transparent)]
138 pub struct NodeId(String);
139
140 impl NodeId {
141 pub fn new(s: impl Into<String>) -> Self {
142 Self(s.into())
143 }
144 pub fn as_str(&self) -> &str {
145 &self.0
146 }
147 }
148
149 impl fmt::Display for NodeId {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 self.0.fmt(f)
152 }
153 }
154
155 impl FromStr for NodeId {
156 type Err = std::convert::Infallible;
157 fn from_str(s: &str) -> Result<Self, Self::Err> {
158 Ok(Self(s.to_owned()))
159 }
160 }
161
162 impl From<&str> for NodeId {
163 fn from(s: &str) -> Self {
164 Self(s.to_owned())
165 }
166 }
167
168 impl From<String> for NodeId {
169 fn from(s: String) -> Self {
170 Self(s)
171 }
172 }
173
174 // ---------------------------------------------------------------------
175 // Version (semver)
176 // ---------------------------------------------------------------------
177
178 /// Server semver (e.g. `0.9.6`). Parsed once at the build step; stored
179 /// as TEXT in the schema.
180 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
181 #[serde(try_from = "String", into = "String")]
182 pub struct Version(semver::Version);
183
184 #[derive(Debug, thiserror::Error)]
185 #[error("invalid semver `{input}`: {source}")]
186 pub struct VersionParseError {
187 pub input: String,
188 #[source]
189 pub source: semver::Error,
190 }
191
192 impl Version {
193 pub fn parse(s: &str) -> Result<Self, VersionParseError> {
194 semver::Version::parse(s)
195 .map(Self)
196 .map_err(|e| VersionParseError {
197 input: s.to_owned(),
198 source: e,
199 })
200 }
201 pub fn as_inner(&self) -> &semver::Version {
202 &self.0
203 }
204 }
205
206 impl fmt::Display for Version {
207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208 self.0.fmt(f)
209 }
210 }
211
212 impl FromStr for Version {
213 type Err = VersionParseError;
214 fn from_str(s: &str) -> Result<Self, Self::Err> {
215 Self::parse(s)
216 }
217 }
218
219 impl TryFrom<String> for Version {
220 type Error = VersionParseError;
221 fn try_from(s: String) -> Result<Self, Self::Error> {
222 Self::parse(&s)
223 }
224 }
225
226 impl From<Version> for String {
227 fn from(v: Version) -> Self {
228 v.0.to_string()
229 }
230 }
231
232 impl sqlx::Type<Sqlite> for Version {
233 fn type_info() -> <Sqlite as sqlx::Database>::TypeInfo {
234 <String as sqlx::Type<Sqlite>>::type_info()
235 }
236 fn compatible(ty: &<Sqlite as sqlx::Database>::TypeInfo) -> bool {
237 <String as sqlx::Type<Sqlite>>::compatible(ty)
238 }
239 }
240
241 impl sqlx::Encode<'_, Sqlite> for Version {
242 fn encode_by_ref(
243 &self,
244 buf: &mut <Sqlite as sqlx::Database>::ArgumentBuffer,
245 ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
246 <String as sqlx::Encode<Sqlite>>::encode(self.0.to_string(), buf)
247 }
248 }
249
250 impl<'r> sqlx::Decode<'r, Sqlite> for Version {
251 fn decode(
252 value: <Sqlite as sqlx::Database>::ValueRef<'r>,
253 ) -> Result<Self, sqlx::error::BoxDynError> {
254 let s = <String as sqlx::Decode<Sqlite>>::decode(value)?;
255 Ok(Version::parse(&s)?)
256 }
257 }
258
259 // ---------------------------------------------------------------------
260 // Git sha
261 // ---------------------------------------------------------------------
262
263 /// A git commit sha. Always stored in its full 40-hex-character form;
264 /// short forms entering at the edge are accepted only if the topology
265 /// resolves them unambiguously (resolution happens at the call site,
266 /// not in this type — this type only enforces shape).
267 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
268 #[serde(try_from = "String", into = "String")]
269 pub struct GitSha(String);
270
271 #[derive(Debug, thiserror::Error)]
272 pub enum GitShaParseError {
273 #[error("git sha `{0}` is not 7-40 hex chars")]
274 BadShape(String),
275 }
276
277 impl GitSha {
278 pub fn parse(s: &str) -> Result<Self, GitShaParseError> {
279 let len = s.len();
280 let ok = (7..=40).contains(&len) && s.bytes().all(|b| b.is_ascii_hexdigit());
281 if ok {
282 Ok(Self(s.to_ascii_lowercase()))
283 } else {
284 Err(GitShaParseError::BadShape(s.to_owned()))
285 }
286 }
287 pub fn as_str(&self) -> &str {
288 &self.0
289 }
290 /// Best-effort 7-char prefix for display.
291 pub fn short(&self) -> &str {
292 &self.0[..self.0.len().min(7)]
293 }
294 }
295
296 impl fmt::Display for GitSha {
297 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298 self.0.fmt(f)
299 }
300 }
301
302 impl FromStr for GitSha {
303 type Err = GitShaParseError;
304 fn from_str(s: &str) -> Result<Self, Self::Err> {
305 Self::parse(s)
306 }
307 }
308
309 impl TryFrom<String> for GitSha {
310 type Error = GitShaParseError;
311 fn try_from(s: String) -> Result<Self, Self::Error> {
312 Self::parse(&s)
313 }
314 }
315
316 impl From<GitSha> for String {
317 fn from(g: GitSha) -> Self {
318 g.0
319 }
320 }
321
322 // ---------------------------------------------------------------------
323 // Platform
324 // ---------------------------------------------------------------------
325
326 /// What a bundle was built for, and what a node can run: `os/arch`.
327 ///
328 /// Sando was single-platform for its whole life — one `build_host`, one
329 /// architecture, one bundle per version — so nothing ever had to say which
330 /// machine a set of bytes was for. pom breaks that: astra is aarch64 and
331 /// hetzner is x86_64, so one pom version is two bundles with two digests, and
332 /// "which of these goes to which box" becomes a question the system has to be
333 /// able to answer.
334 ///
335 /// Parsed rather than stringly so the answer is a comparison of two values and
336 /// not of two spellings. `ArtifactRecord.provenance.target` already carries this
337 /// in `linux/aarch64` form; this is the type it parses into.
338 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
339 #[serde(try_from = "String", into = "String")]
340 pub struct Platform {
341 os: String,
342 arch: String,
343 }
344
345 #[derive(Debug, thiserror::Error)]
346 pub enum PlatformParseError {
347 #[error("platform `{0}` is not `os/arch` (e.g. `linux/aarch64`)")]
348 BadShape(String),
349 }
350
351 impl Platform {
352 pub fn parse(s: &str) -> Result<Self, PlatformParseError> {
353 let bad = || PlatformParseError::BadShape(s.to_owned());
354 let (os, arch) = s.split_once('/').ok_or_else(bad)?;
355 let part_ok = |p: &str| {
356 !p.is_empty()
357 && p.bytes()
358 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
359 };
360 if !part_ok(os) || !part_ok(arch) {
361 return Err(bad());
362 }
363 Ok(Self {
364 os: os.to_ascii_lowercase(),
365 arch: arch.to_ascii_lowercase(),
366 })
367 }
368 pub fn os(&self) -> &str {
369 &self.os
370 }
371 pub fn arch(&self) -> &str {
372 &self.arch
373 }
374 }
375
376 impl fmt::Display for Platform {
377 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
378 write!(f, "{}/{}", self.os, self.arch)
379 }
380 }
381
382 impl FromStr for Platform {
383 type Err = PlatformParseError;
384 fn from_str(s: &str) -> Result<Self, Self::Err> {
385 Self::parse(s)
386 }
387 }
388
389 impl TryFrom<String> for Platform {
390 type Error = PlatformParseError;
391 fn try_from(s: String) -> Result<Self, Self::Error> {
392 Self::parse(&s)
393 }
394 }
395
396 impl From<Platform> for String {
397 fn from(p: Platform) -> Self {
398 p.to_string()
399 }
400 }
401
402 impl sqlx::Type<Sqlite> for GitSha {
403 fn type_info() -> <Sqlite as sqlx::Database>::TypeInfo {
404 <String as sqlx::Type<Sqlite>>::type_info()
405 }
406 fn compatible(ty: &<Sqlite as sqlx::Database>::TypeInfo) -> bool {
407 <String as sqlx::Type<Sqlite>>::compatible(ty)
408 }
409 }
410
411 impl sqlx::Encode<'_, Sqlite> for GitSha {
412 fn encode_by_ref(
413 &self,
414 buf: &mut <Sqlite as sqlx::Database>::ArgumentBuffer,
415 ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
416 <String as sqlx::Encode<Sqlite>>::encode(self.0.clone(), buf)
417 }
418 }
419
420 impl<'r> sqlx::Decode<'r, Sqlite> for GitSha {
421 fn decode(
422 value: <Sqlite as sqlx::Database>::ValueRef<'r>,
423 ) -> Result<Self, sqlx::error::BoxDynError> {
424 let s = <String as sqlx::Decode<Sqlite>>::decode(value)?;
425 Ok(GitSha::parse(&s)?)
426 }
427 }
428
429 // ---------------------------------------------------------------------
430 // Gate kind
431 // ---------------------------------------------------------------------
432
433 /// The discriminant of `topology::Gate`. `Gate` carries gate parameters
434 /// (e.g. `BurnIn { hours }`); `GateKind` is the identifier we use in
435 /// events, schema columns, and the TUI. They were the same type before;
436 /// splitting them is what lets a gate's parameters evolve without
437 /// touching the wire/schema vocabulary.
438 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
439 #[serde(rename_all = "snake_case")]
440 pub enum GateKind {
441 CargoTest,
442 /// The tests `cargo_test` structurally cannot run: `--features fast-tests`
443 /// relaxes the auth/sandbox rate limits and argon2 cost so the signup-heavy
444 /// workflow suite finishes, and the rate-limiting tests are
445 /// `#[cfg_attr(feature = "fast-tests", ignore)]`d because a bucket that
446 /// refills at 100/sec never depletes under parallel test threads. This gate
447 /// runs that set WITHOUT the feature, against the production constants, so a
448 /// rate-limiter regression cannot reach a node unblocked.
449 HardeningTest,
450 /// `cargo clippy --all-targets -- -D warnings` over every configured
451 /// `test_target`. Before this existed, `-D warnings` was enforced in exactly
452 /// one place — `server/deploy/run-ci.sh`, which died with the astra pipeline
453 /// — so lint drift had no gate at all.
454 Clippy,
455 /// `cargo fmt --check` over every configured `test_target`.
456 Fmt,
457 /// `cargo audit` over the crates that carry a triaged `.cargo/audit.toml`.
458 CargoAudit,
459 /// `cargo deny check` over the crates that carry a `deny.toml`.
460 CargoDeny,
461 MigrationDryRun,
462 /// Boots the freshly-built binary against a throwaway *empty* DB it
463 /// migrates from scratch, seeds the example catalog into, and probes
464 /// `GET /health` on — a fast, infra-light "is the code sound" check that
465 /// runs first, so a later `cargo_test`/`migration_dry_run` red reads as an
466 /// environment problem, not a code one. Distinct from `BootSmoke`, which
467 /// boots the staged artifact in a minimal no-DB mode.
468 CodeSmoke,
469 BootSmoke,
470 /// Post-deploy readiness of the *deployed nodes* (distinct from `BootSmoke`,
471 /// which boots the staged artifact on the build host). Probes each node's
472 /// service over its executor — the gate that actually proves a tier's nodes
473 /// are serving before the next promote.
474 NodeHealth,
475 BurnIn,
476 ManualConfirm,
477 }
478
479 impl GateKind {
480 pub fn as_str(self) -> &'static str {
481 match self {
482 GateKind::CargoTest => "cargo_test",
483 GateKind::HardeningTest => "hardening_test",
484 GateKind::Clippy => "clippy",
485 GateKind::Fmt => "fmt",
486 GateKind::CargoAudit => "cargo_audit",
487 GateKind::CargoDeny => "cargo_deny",
488 GateKind::MigrationDryRun => "migration_dry_run",
489 GateKind::CodeSmoke => "code_smoke",
490 GateKind::BootSmoke => "boot_smoke",
491 GateKind::NodeHealth => "node_health",
492 GateKind::BurnIn => "burn_in",
493 GateKind::ManualConfirm => "manual_confirm",
494 }
495 }
496 }
497
498 #[derive(Debug, thiserror::Error)]
499 #[error("unknown gate kind `{0}`")]
500 pub struct GateKindParseError(pub String);
501
502 impl FromStr for GateKind {
503 type Err = GateKindParseError;
504 fn from_str(s: &str) -> Result<Self, Self::Err> {
505 match s {
506 "cargo_test" => Ok(GateKind::CargoTest),
507 "hardening_test" => Ok(GateKind::HardeningTest),
508 "clippy" => Ok(GateKind::Clippy),
509 "fmt" => Ok(GateKind::Fmt),
510 "cargo_audit" => Ok(GateKind::CargoAudit),
511 "cargo_deny" => Ok(GateKind::CargoDeny),
512 "migration_dry_run" => Ok(GateKind::MigrationDryRun),
513 "code_smoke" => Ok(GateKind::CodeSmoke),
514 "boot_smoke" => Ok(GateKind::BootSmoke),
515 "node_health" => Ok(GateKind::NodeHealth),
516 "burn_in" => Ok(GateKind::BurnIn),
517 "manual_confirm" => Ok(GateKind::ManualConfirm),
518 other => Err(GateKindParseError(other.to_owned())),
519 }
520 }
521 }
522
523 impl fmt::Display for GateKind {
524 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
525 f.write_str(self.as_str())
526 }
527 }
528
529 impl sqlx::Type<Sqlite> for GateKind {
530 fn type_info() -> <Sqlite as sqlx::Database>::TypeInfo {
531 <String as sqlx::Type<Sqlite>>::type_info()
532 }
533 fn compatible(ty: &<Sqlite as sqlx::Database>::TypeInfo) -> bool {
534 <String as sqlx::Type<Sqlite>>::compatible(ty)
535 }
536 }
537
538 impl sqlx::Encode<'_, Sqlite> for GateKind {
539 fn encode_by_ref(
540 &self,
541 buf: &mut <Sqlite as sqlx::Database>::ArgumentBuffer,
542 ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
543 <String as sqlx::Encode<Sqlite>>::encode(self.as_str().to_owned(), buf)
544 }
545 }
546
547 impl<'r> sqlx::Decode<'r, Sqlite> for GateKind {
548 fn decode(
549 value: <Sqlite as sqlx::Database>::ValueRef<'r>,
550 ) -> Result<Self, sqlx::error::BoxDynError> {
551 let s = <String as sqlx::Decode<Sqlite>>::decode(value)?;
552 Ok(GateKind::from_str(&s)?)
553 }
554 }
555
556 // ---------------------------------------------------------------------
557 // Row ids
558 // ---------------------------------------------------------------------
559
560 /// Primary key of `gate_runs`. Carried through `GateStart` → `GateLogChunk`
561 /// → `GateDone` so client-side correlation is trivial. `Ord` is monotonic
562 /// in insertion order (sqlite `INTEGER PRIMARY KEY AUTOINCREMENT`), which
563 /// the TUI's run-buffer map relies on for chronological iteration.
564 #[derive(
565 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type,
566 )]
567 #[sqlx(transparent)]
568 #[serde(transparent)]
569 pub struct GateRunId(pub i64);
570
571 impl fmt::Display for GateRunId {
572 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
573 self.0.fmt(f)
574 }
575 }
576
577 /// Primary key of `deploys`.
578 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
579 #[sqlx(transparent)]
580 #[serde(transparent)]
581 pub struct DeployId(pub i64);
582
583 impl fmt::Display for DeployId {
584 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
585 self.0.fmt(f)
586 }
587 }
588
589 /// Primary key of `build_runs` — the resource a `/rebuild` returns and a
590 /// non-TUI driver polls via `GET /runs/{id}`. Distinct from `GateRunId`
591 /// (one build run drives many gate runs).
592 #[derive(
593 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type,
594 )]
595 #[sqlx(transparent)]
596 #[serde(transparent)]
597 pub struct RunId(pub i64);
598
599 impl fmt::Display for RunId {
600 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
601 self.0.fmt(f)
602 }
603 }
604
605 #[cfg(test)]
606 mod tests {
607 use super::*;
608
609 #[test]
610 fn tier_id_round_trips_through_json() {
611 let t = TierId::new("host");
612 let s = serde_json::to_string(&t).unwrap();
613 assert_eq!(s, "\"host\"");
614 let back: TierId = serde_json::from_str(&s).unwrap();
615 assert_eq!(t, back);
616 }
617
618 #[test]
619 fn version_parses_and_displays() {
620 let v: Version = "0.9.6".parse().unwrap();
621 assert_eq!(v.to_string(), "0.9.6");
622 assert!("not-a-version".parse::<Version>().is_err());
623 }
624
625 #[test]
626 fn version_json_is_string_form() {
627 let v: Version = "1.2.3-rc.1".parse().unwrap();
628 let s = serde_json::to_string(&v).unwrap();
629 assert_eq!(s, "\"1.2.3-rc.1\"");
630 let back: Version = serde_json::from_str(&s).unwrap();
631 assert_eq!(v, back);
632 }
633
634 #[test]
635 fn git_sha_accepts_short_and_full() {
636 assert!(GitSha::parse("abc1234").is_ok());
637 assert!(GitSha::parse("0123456789abcdef0123456789abcdef01234567").is_ok());
638 // length out of range
639 assert!(GitSha::parse("abc").is_err());
640 assert!(GitSha::parse(&"a".repeat(41)).is_err());
641 // non-hex
642 assert!(GitSha::parse("zzzzzzz").is_err());
643 }
644
645 #[test]
646 fn git_sha_short_truncates_safely() {
647 let s = GitSha::parse("abc1234").unwrap();
648 assert_eq!(s.short(), "abc1234");
649 let long = GitSha::parse("0123456789abcdef0123456789abcdef01234567").unwrap();
650 assert_eq!(long.short(), "0123456");
651 }
652
653 #[test]
654 fn git_sha_normalizes_to_lowercase() {
655 let s = GitSha::parse("ABCdef1").unwrap();
656 assert_eq!(s.as_str(), "abcdef1");
657 }
658
659 #[test]
660 fn gate_kind_round_trips_through_json() {
661 // serde_json uses #[serde(rename_all = "snake_case")] — verify the
662 // shape the TUI's `format_event` already consumes is preserved.
663 let k = GateKind::MigrationDryRun;
664 let s = serde_json::to_string(&k).unwrap();
665 assert_eq!(s, "\"migration_dry_run\"");
666 let back: GateKind = serde_json::from_str(&s).unwrap();
667 assert_eq!(k, back);
668 }
669
670 #[test]
671 fn gate_kind_as_str_matches_serde_form() {
672 // The legacy `gates::kind_str` helper produced strings the TUI
673 // matched on. Locking in that our serde form matches those exactly
674 // so step 3 (events use the types) doesn't change the wire shape.
675 for k in [
676 GateKind::CargoTest,
677 GateKind::HardeningTest,
678 GateKind::MigrationDryRun,
679 GateKind::CodeSmoke,
680 GateKind::BootSmoke,
681 GateKind::BurnIn,
682 GateKind::ManualConfirm,
683 ] {
684 let via_serde: String =
685 serde_json::from_str::<String>(&serde_json::to_string(&k).unwrap()).unwrap();
686 assert_eq!(via_serde, k.as_str());
687 }
688 }
689
690 #[test]
691 fn gate_kind_hardening_test_round_trips() {
692 // It is written into gate_runs.gate_kind and read back by
693 // unsatisfied_gates, so the two directions must agree exactly.
694 assert_eq!(GateKind::HardeningTest.as_str(), "hardening_test");
695 assert_eq!(
696 "hardening_test".parse::<GateKind>().unwrap(),
697 GateKind::HardeningTest
698 );
699 }
700
701 #[test]
702 fn gate_kind_from_str_rejects_unknown() {
703 assert!("not_a_gate".parse::<GateKind>().is_err());
704 }
705
706 #[test]
707 fn gate_run_id_serializes_as_number() {
708 let id = GateRunId(42);
709 assert_eq!(serde_json::to_string(&id).unwrap(), "42");
710 }
711 }
712