Skip to main content

max / makenotwork

25.1 KB · 779 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 /// Post-deploy, and the only gate that reads the site the way a visitor
476 /// receives it: a real browser, over the tier's **public hostname**, with
477 /// the CDN in front.
478 ///
479 /// Every other gate here is build-host or origin-side. `BootSmoke` runs on
480 /// fw13 and proves nothing about a node; `NodeHealth` reaches the node over
481 /// its executor and so never crosses the edge. That left a whole layer
482 /// unwatched, and on 2026-08-14 it shipped: testnot served a landing page
483 /// whose JavaScript did not run for hours behind nine green gates. A stale
484 /// `dispatch.js` held at the CDN failed to link against a freshly deployed
485 /// `index.js`, and since `core/index.ts` side-effect-imports every common
486 /// island, one bad link killed all of them plus the legacy page scripts
487 /// that read globals it publishes.
488 ///
489 /// Nothing could have caught it earlier. Each artifact was individually
490 /// correct — right markup, right stylesheets, every module answering 200
491 /// with current bytes. Only the composition was broken, and composition is
492 /// observable in a browser and nowhere else.
493 ///
494 /// Progressive enhancement is what makes this a gate rather than a nicety.
495 /// Every island enhances server-rendered markup that stands on its own, so
496 /// a dead bundle renders the unenhanced page — a state the design
497 /// deliberately supports. "Broken" and "working as intended" are the same
498 /// screenshot, and the difference is only visible as behaviour.
499 ///
500 /// Runs `scripts/page-smoke.mjs` with `BASE` set to the tier's public URL.
501 /// Red on any uncaught exception, any console error, any island that did
502 /// not run, or any failed page expectation.
503 PageSmoke,
504 BurnIn,
505 ManualConfirm,
506 }
507
508 impl GateKind {
509 pub fn as_str(self) -> &'static str {
510 match self {
511 GateKind::CargoTest => "cargo_test",
512 GateKind::HardeningTest => "hardening_test",
513 GateKind::Clippy => "clippy",
514 GateKind::Fmt => "fmt",
515 GateKind::CargoAudit => "cargo_audit",
516 GateKind::CargoDeny => "cargo_deny",
517 GateKind::MigrationDryRun => "migration_dry_run",
518 GateKind::CodeSmoke => "code_smoke",
519 GateKind::BootSmoke => "boot_smoke",
520 GateKind::NodeHealth => "node_health",
521 GateKind::PageSmoke => "page_smoke",
522 GateKind::BurnIn => "burn_in",
523 GateKind::ManualConfirm => "manual_confirm",
524 }
525 }
526 }
527
528 #[derive(Debug, thiserror::Error)]
529 #[error("unknown gate kind `{0}`")]
530 pub struct GateKindParseError(pub String);
531
532 impl FromStr for GateKind {
533 type Err = GateKindParseError;
534 fn from_str(s: &str) -> Result<Self, Self::Err> {
535 match s {
536 "cargo_test" => Ok(GateKind::CargoTest),
537 "hardening_test" => Ok(GateKind::HardeningTest),
538 "clippy" => Ok(GateKind::Clippy),
539 "fmt" => Ok(GateKind::Fmt),
540 "cargo_audit" => Ok(GateKind::CargoAudit),
541 "cargo_deny" => Ok(GateKind::CargoDeny),
542 "migration_dry_run" => Ok(GateKind::MigrationDryRun),
543 "code_smoke" => Ok(GateKind::CodeSmoke),
544 "boot_smoke" => Ok(GateKind::BootSmoke),
545 "node_health" => Ok(GateKind::NodeHealth),
546 "page_smoke" => Ok(GateKind::PageSmoke),
547 "burn_in" => Ok(GateKind::BurnIn),
548 "manual_confirm" => Ok(GateKind::ManualConfirm),
549 other => Err(GateKindParseError(other.to_owned())),
550 }
551 }
552 }
553
554 impl fmt::Display for GateKind {
555 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
556 f.write_str(self.as_str())
557 }
558 }
559
560 impl sqlx::Type<Sqlite> for GateKind {
561 fn type_info() -> <Sqlite as sqlx::Database>::TypeInfo {
562 <String as sqlx::Type<Sqlite>>::type_info()
563 }
564 fn compatible(ty: &<Sqlite as sqlx::Database>::TypeInfo) -> bool {
565 <String as sqlx::Type<Sqlite>>::compatible(ty)
566 }
567 }
568
569 impl sqlx::Encode<'_, Sqlite> for GateKind {
570 fn encode_by_ref(
571 &self,
572 buf: &mut <Sqlite as sqlx::Database>::ArgumentBuffer,
573 ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
574 <String as sqlx::Encode<Sqlite>>::encode(self.as_str().to_owned(), buf)
575 }
576 }
577
578 impl<'r> sqlx::Decode<'r, Sqlite> for GateKind {
579 fn decode(
580 value: <Sqlite as sqlx::Database>::ValueRef<'r>,
581 ) -> Result<Self, sqlx::error::BoxDynError> {
582 let s = <String as sqlx::Decode<Sqlite>>::decode(value)?;
583 Ok(GateKind::from_str(&s)?)
584 }
585 }
586
587 // ---------------------------------------------------------------------
588 // Row ids
589 // ---------------------------------------------------------------------
590
591 /// Primary key of `gate_runs`. Carried through `GateStart` → `GateLogChunk`
592 /// → `GateDone` so client-side correlation is trivial. `Ord` is monotonic
593 /// in insertion order (sqlite `INTEGER PRIMARY KEY AUTOINCREMENT`), which
594 /// the TUI's run-buffer map relies on for chronological iteration.
595 #[derive(
596 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type,
597 )]
598 #[sqlx(transparent)]
599 #[serde(transparent)]
600 pub struct GateRunId(pub i64);
601
602 impl fmt::Display for GateRunId {
603 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
604 self.0.fmt(f)
605 }
606 }
607
608 /// Primary key of `deploys`.
609 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
610 #[sqlx(transparent)]
611 #[serde(transparent)]
612 pub struct DeployId(pub i64);
613
614 impl fmt::Display for DeployId {
615 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
616 self.0.fmt(f)
617 }
618 }
619
620 /// Primary key of `build_runs` — the resource a `/rebuild` returns and a
621 /// non-TUI driver polls via `GET /runs/{id}`. Distinct from `GateRunId`
622 /// (one build run drives many gate runs).
623 #[derive(
624 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type,
625 )]
626 #[sqlx(transparent)]
627 #[serde(transparent)]
628 pub struct RunId(pub i64);
629
630 impl fmt::Display for RunId {
631 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
632 self.0.fmt(f)
633 }
634 }
635
636 #[cfg(test)]
637 mod tests {
638 use super::*;
639
640 #[test]
641 fn tier_id_round_trips_through_json() {
642 let t = TierId::new("host");
643 let s = serde_json::to_string(&t).unwrap();
644 assert_eq!(s, "\"host\"");
645 let back: TierId = serde_json::from_str(&s).unwrap();
646 assert_eq!(t, back);
647 }
648
649 #[test]
650 fn version_parses_and_displays() {
651 let v: Version = "0.9.6".parse().unwrap();
652 assert_eq!(v.to_string(), "0.9.6");
653 assert!("not-a-version".parse::<Version>().is_err());
654 }
655
656 #[test]
657 fn version_json_is_string_form() {
658 let v: Version = "1.2.3-rc.1".parse().unwrap();
659 let s = serde_json::to_string(&v).unwrap();
660 assert_eq!(s, "\"1.2.3-rc.1\"");
661 let back: Version = serde_json::from_str(&s).unwrap();
662 assert_eq!(v, back);
663 }
664
665 #[test]
666 fn git_sha_accepts_short_and_full() {
667 assert!(GitSha::parse("abc1234").is_ok());
668 assert!(GitSha::parse("0123456789abcdef0123456789abcdef01234567").is_ok());
669 // length out of range
670 assert!(GitSha::parse("abc").is_err());
671 assert!(GitSha::parse(&"a".repeat(41)).is_err());
672 // non-hex
673 assert!(GitSha::parse("zzzzzzz").is_err());
674 }
675
676 #[test]
677 fn git_sha_short_truncates_safely() {
678 let s = GitSha::parse("abc1234").unwrap();
679 assert_eq!(s.short(), "abc1234");
680 let long = GitSha::parse("0123456789abcdef0123456789abcdef01234567").unwrap();
681 assert_eq!(long.short(), "0123456");
682 }
683
684 #[test]
685 fn git_sha_normalizes_to_lowercase() {
686 let s = GitSha::parse("ABCdef1").unwrap();
687 assert_eq!(s.as_str(), "abcdef1");
688 }
689
690 #[test]
691 fn gate_kind_round_trips_through_json() {
692 // serde_json uses #[serde(rename_all = "snake_case")] — verify the
693 // shape the TUI's `format_event` already consumes is preserved.
694 let k = GateKind::MigrationDryRun;
695 let s = serde_json::to_string(&k).unwrap();
696 assert_eq!(s, "\"migration_dry_run\"");
697 let back: GateKind = serde_json::from_str(&s).unwrap();
698 assert_eq!(k, back);
699 }
700
701 #[test]
702 fn gate_kind_as_str_matches_serde_form() {
703 // The legacy `gates::kind_str` helper produced strings the TUI
704 // matched on. Locking in that our serde form matches those exactly
705 // so step 3 (events use the types) doesn't change the wire shape.
706 for k in [
707 GateKind::CargoTest,
708 GateKind::HardeningTest,
709 GateKind::MigrationDryRun,
710 GateKind::CodeSmoke,
711 GateKind::BootSmoke,
712 GateKind::BurnIn,
713 GateKind::ManualConfirm,
714 ] {
715 let via_serde: String =
716 serde_json::from_str::<String>(&serde_json::to_string(&k).unwrap()).unwrap();
717 assert_eq!(via_serde, k.as_str());
718 }
719 }
720
721 #[test]
722 fn gate_kind_hardening_test_round_trips() {
723 // It is written into gate_runs.gate_kind and read back by
724 // unsatisfied_gates, so the two directions must agree exactly.
725 assert_eq!(GateKind::HardeningTest.as_str(), "hardening_test");
726 assert_eq!(
727 "hardening_test".parse::<GateKind>().unwrap(),
728 GateKind::HardeningTest
729 );
730 }
731
732 #[test]
733 fn gate_kind_from_str_rejects_unknown() {
734 assert!("not_a_gate".parse::<GateKind>().is_err());
735 }
736
737 #[test]
738 fn gate_run_id_serializes_as_number() {
739 let id = GateRunId(42);
740 assert_eq!(serde_json::to_string(&id).unwrap(), "42");
741 }
742
743 #[test]
744 fn platform_components_take_the_punctuation_target_names_use() {
745 // Real target names carry `_`, `-` and `.` inside a component, and the
746 // charset here is the only thing that lets them through. A narrower
747 // predicate rejects `unknown-linux-gnu` or `armv7.hf` while still
748 // parsing `linux/aarch64`, which is why the shape test above cannot
749 // see the difference.
750 for good in [
751 "linux_gnu/x86_64",
752 "unknown-linux/aarch64",
753 "linux/armv7.hf",
754 "linux/x86_64",
755 ] {
756 assert!(Platform::parse(good).is_ok(), "{good:?} should parse");
757 }
758 for bad in [
759 "linux+gnu/x86_64",
760 "linux/x86 64",
761 "linux/x86:64",
762 "li nux/x",
763 ] {
764 assert!(Platform::parse(bad).is_err(), "{bad:?} should not parse");
765 }
766 }
767
768 #[test]
769 fn platform_halves_read_back_lowercased() {
770 // `os()` and `arch()` are what a caller compares and what the SQL
771 // lookup binds, so they have to be the normalized halves rather than
772 // the spelling the config used.
773 let p = Platform::parse("Linux/AArch64").unwrap();
774 assert_eq!(p.os(), "linux");
775 assert_eq!(p.arch(), "aarch64");
776 assert_eq!(p.to_string(), "linux/aarch64");
777 }
778 }
779