//! Domain types — the vocabulary every other module speaks. //! //! These newtypes replace string-typed fields across the daemon, schema, //! WS payloads, and TUI. Construction is the boundary parse: a `Version` //! exists because some byte sequence at the edge of the process passed //! semver validation; downstream code is freed from re-validating it. //! //! All types implement `Display`, `FromStr`, `Serialize`, `Deserialize`, //! and `sqlx::Type` so they round-trip through events, JSON //! responses, and SQLite columns without per-site conversion. //! //! See `plans/observability.md` for the architecture this is the first //! step of. // Step 1 is pure addition: nothing else in the crate uses these yet. // Steps 2-7 thread the types through call sites; remove the allow then. #![allow(dead_code)] use serde::{Deserialize, Serialize}; use sqlx::Sqlite; use std::fmt; use std::str::FromStr; // --------------------------------------------------------------------- // String-backed identifiers // --------------------------------------------------------------------- /// A tier in the deploy topology (e.g. "host", "a", "b"). /// /// Construction does no cross-validation against the loaded `Topology` — /// that is the responsibility of `Topology::load`, which mints the /// canonical `TierId` set. Use `TierId::new` only at boundaries (config /// load, deserialization of inbound requests). #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] #[sqlx(transparent)] #[serde(transparent)] pub struct TierId(String); impl TierId { pub fn new(s: impl Into) -> Self { Self(s.into()) } pub fn as_str(&self) -> &str { &self.0 } } impl fmt::Display for TierId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } impl FromStr for TierId { type Err = std::convert::Infallible; fn from_str(s: &str) -> Result { Ok(Self(s.to_owned())) } } impl From<&str> for TierId { fn from(s: &str) -> Self { Self(s.to_owned()) } } impl From for TierId { fn from(s: String) -> Self { Self(s) } } /// A node name within a tier (e.g. "alpha-west-1"). #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] #[sqlx(transparent)] #[serde(transparent)] pub struct NodeId(String); impl NodeId { pub fn new(s: impl Into) -> Self { Self(s.into()) } pub fn as_str(&self) -> &str { &self.0 } } impl fmt::Display for NodeId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } impl FromStr for NodeId { type Err = std::convert::Infallible; fn from_str(s: &str) -> Result { Ok(Self(s.to_owned())) } } impl From<&str> for NodeId { fn from(s: &str) -> Self { Self(s.to_owned()) } } impl From for NodeId { fn from(s: String) -> Self { Self(s) } } // --------------------------------------------------------------------- // Version (semver) // --------------------------------------------------------------------- /// Server semver (e.g. `0.9.6`). Parsed once at the build step; stored /// as TEXT in the schema. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct Version(semver::Version); #[derive(Debug, thiserror::Error)] #[error("invalid semver `{input}`: {source}")] pub struct VersionParseError { pub input: String, #[source] pub source: semver::Error, } impl Version { pub fn parse(s: &str) -> Result { semver::Version::parse(s) .map(Self) .map_err(|e| VersionParseError { input: s.to_owned(), source: e }) } pub fn as_inner(&self) -> &semver::Version { &self.0 } } impl fmt::Display for Version { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } impl FromStr for Version { type Err = VersionParseError; fn from_str(s: &str) -> Result { Self::parse(s) } } impl TryFrom for Version { type Error = VersionParseError; fn try_from(s: String) -> Result { Self::parse(&s) } } impl From for String { fn from(v: Version) -> Self { v.0.to_string() } } impl sqlx::Type for Version { fn type_info() -> ::TypeInfo { >::type_info() } fn compatible(ty: &::TypeInfo) -> bool { >::compatible(ty) } } impl<'q> sqlx::Encode<'q, Sqlite> for Version { fn encode_by_ref( &self, buf: &mut ::ArgumentBuffer<'q>, ) -> Result { >::encode(self.0.to_string(), buf) } } impl<'r> sqlx::Decode<'r, Sqlite> for Version { fn decode( value: ::ValueRef<'r>, ) -> Result { let s = >::decode(value)?; Ok(Version::parse(&s)?) } } // --------------------------------------------------------------------- // Git sha // --------------------------------------------------------------------- /// A git commit sha. Always stored in its full 40-hex-character form; /// short forms entering at the edge are accepted only if the topology /// resolves them unambiguously (resolution happens at the call site, /// not in this type — this type only enforces shape). #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct GitSha(String); #[derive(Debug, thiserror::Error)] pub enum GitShaParseError { #[error("git sha `{0}` is not 7-40 hex chars")] BadShape(String), } impl GitSha { pub fn parse(s: &str) -> Result { let len = s.len(); let ok = (7..=40).contains(&len) && s.bytes().all(|b| b.is_ascii_hexdigit()); if ok { Ok(Self(s.to_ascii_lowercase())) } else { Err(GitShaParseError::BadShape(s.to_owned())) } } pub fn as_str(&self) -> &str { &self.0 } /// Best-effort 7-char prefix for display. pub fn short(&self) -> &str { &self.0[..self.0.len().min(7)] } } impl fmt::Display for GitSha { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } impl FromStr for GitSha { type Err = GitShaParseError; fn from_str(s: &str) -> Result { Self::parse(s) } } impl TryFrom for GitSha { type Error = GitShaParseError; fn try_from(s: String) -> Result { Self::parse(&s) } } impl From for String { fn from(g: GitSha) -> Self { g.0 } } impl sqlx::Type for GitSha { fn type_info() -> ::TypeInfo { >::type_info() } fn compatible(ty: &::TypeInfo) -> bool { >::compatible(ty) } } impl<'q> sqlx::Encode<'q, Sqlite> for GitSha { fn encode_by_ref( &self, buf: &mut ::ArgumentBuffer<'q>, ) -> Result { >::encode(self.0.clone(), buf) } } impl<'r> sqlx::Decode<'r, Sqlite> for GitSha { fn decode( value: ::ValueRef<'r>, ) -> Result { let s = >::decode(value)?; Ok(GitSha::parse(&s)?) } } // --------------------------------------------------------------------- // Gate kind // --------------------------------------------------------------------- /// The discriminant of `topology::Gate`. `Gate` carries gate parameters /// (e.g. `BurnIn { hours }`); `GateKind` is the identifier we use in /// events, schema columns, and the TUI. They were the same type before; /// splitting them is what lets a gate's parameters evolve without /// touching the wire/schema vocabulary. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum GateKind { CargoTest, MigrationDryRun, BootSmoke, BurnIn, ManualConfirm, } impl GateKind { pub fn as_str(self) -> &'static str { match self { GateKind::CargoTest => "cargo_test", GateKind::MigrationDryRun => "migration_dry_run", GateKind::BootSmoke => "boot_smoke", GateKind::BurnIn => "burn_in", GateKind::ManualConfirm => "manual_confirm", } } } #[derive(Debug, thiserror::Error)] #[error("unknown gate kind `{0}`")] pub struct GateKindParseError(pub String); impl FromStr for GateKind { type Err = GateKindParseError; fn from_str(s: &str) -> Result { match s { "cargo_test" => Ok(GateKind::CargoTest), "migration_dry_run" => Ok(GateKind::MigrationDryRun), "boot_smoke" => Ok(GateKind::BootSmoke), "burn_in" => Ok(GateKind::BurnIn), "manual_confirm" => Ok(GateKind::ManualConfirm), other => Err(GateKindParseError(other.to_owned())), } } } impl fmt::Display for GateKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } impl sqlx::Type for GateKind { fn type_info() -> ::TypeInfo { >::type_info() } fn compatible(ty: &::TypeInfo) -> bool { >::compatible(ty) } } impl<'q> sqlx::Encode<'q, Sqlite> for GateKind { fn encode_by_ref( &self, buf: &mut ::ArgumentBuffer<'q>, ) -> Result { >::encode(self.as_str().to_owned(), buf) } } impl<'r> sqlx::Decode<'r, Sqlite> for GateKind { fn decode( value: ::ValueRef<'r>, ) -> Result { let s = >::decode(value)?; Ok(GateKind::from_str(&s)?) } } // --------------------------------------------------------------------- // Row ids // --------------------------------------------------------------------- /// Primary key of `gate_runs`. Carried through `GateStart` → `GateLogChunk` /// → `GateDone` so client-side correlation is trivial. `Ord` is monotonic /// in insertion order (sqlite `INTEGER PRIMARY KEY AUTOINCREMENT`), which /// the TUI's run-buffer map relies on for chronological iteration. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type)] #[sqlx(transparent)] #[serde(transparent)] pub struct GateRunId(pub i64); impl fmt::Display for GateRunId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } /// Primary key of `deploys`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] #[sqlx(transparent)] #[serde(transparent)] pub struct DeployId(pub i64); impl fmt::Display for DeployId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } #[cfg(test)] mod tests { use super::*; #[test] fn tier_id_round_trips_through_json() { let t = TierId::new("host"); let s = serde_json::to_string(&t).unwrap(); assert_eq!(s, "\"host\""); let back: TierId = serde_json::from_str(&s).unwrap(); assert_eq!(t, back); } #[test] fn version_parses_and_displays() { let v: Version = "0.9.6".parse().unwrap(); assert_eq!(v.to_string(), "0.9.6"); assert!("not-a-version".parse::().is_err()); } #[test] fn version_json_is_string_form() { let v: Version = "1.2.3-rc.1".parse().unwrap(); let s = serde_json::to_string(&v).unwrap(); assert_eq!(s, "\"1.2.3-rc.1\""); let back: Version = serde_json::from_str(&s).unwrap(); assert_eq!(v, back); } #[test] fn git_sha_accepts_short_and_full() { assert!(GitSha::parse("abc1234").is_ok()); assert!(GitSha::parse("0123456789abcdef0123456789abcdef01234567").is_ok()); // length out of range assert!(GitSha::parse("abc").is_err()); assert!(GitSha::parse(&"a".repeat(41)).is_err()); // non-hex assert!(GitSha::parse("zzzzzzz").is_err()); } #[test] fn git_sha_short_truncates_safely() { let s = GitSha::parse("abc1234").unwrap(); assert_eq!(s.short(), "abc1234"); let long = GitSha::parse("0123456789abcdef0123456789abcdef01234567").unwrap(); assert_eq!(long.short(), "0123456"); } #[test] fn git_sha_normalizes_to_lowercase() { let s = GitSha::parse("ABCdef1").unwrap(); assert_eq!(s.as_str(), "abcdef1"); } #[test] fn gate_kind_round_trips_through_json() { // serde_json uses #[serde(rename_all = "snake_case")] — verify the // shape the TUI's `format_event` already consumes is preserved. let k = GateKind::MigrationDryRun; let s = serde_json::to_string(&k).unwrap(); assert_eq!(s, "\"migration_dry_run\""); let back: GateKind = serde_json::from_str(&s).unwrap(); assert_eq!(k, back); } #[test] fn gate_kind_as_str_matches_serde_form() { // The legacy `gates::kind_str` helper produced strings the TUI // matched on. Locking in that our serde form matches those exactly // so step 3 (events use the types) doesn't change the wire shape. for k in [ GateKind::CargoTest, GateKind::MigrationDryRun, GateKind::BootSmoke, GateKind::BurnIn, GateKind::ManualConfirm, ] { let via_serde: String = serde_json::from_str::( &serde_json::to_string(&k).unwrap(), ) .unwrap(); assert_eq!(via_serde, k.as_str()); } } #[test] fn gate_kind_from_str_rejects_unknown() { assert!("not_a_gate".parse::().is_err()); } #[test] fn gate_run_id_serializes_as_number() { let id = GateRunId(42); assert_eq!(serde_json::to_string(&id).unwrap(), "42"); } }