Skip to main content

max / makenotwork

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