//! Domain vocabulary for Bento — the types every module speaks. //! //! Bento's axes are App x Target x Step. A `(app, target)` resolves to a Rhai //! recipe; the recipe walks the canonical [`Step`] sequence. Newtypes carry //! the boundary parse: a [`Target`] exists because some `"platform/arch"` //! string validated, so downstream code never re-parses. use serde::{Deserialize, Serialize}; use std::fmt; use std::str::FromStr; // --------------------------------------------------------------------- // App identifier // --------------------------------------------------------------------- /// An app Bento can release: `goingson`, `balanced_breakfast`, `audiofiles`. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct AppId(String); impl AppId { pub fn new(s: impl Into) -> Self { Self(s.into()) } pub fn as_str(&self) -> &str { &self.0 } } impl fmt::Display for AppId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } impl From<&str> for AppId { fn from(s: &str) -> Self { Self(s.to_owned()) } } // --------------------------------------------------------------------- // Platform / Arch / Target // --------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Platform { Macos, Ios, Linux, Windows, Android, } impl Platform { pub fn as_str(self) -> &'static str { match self { Platform::Macos => "macos", Platform::Ios => "ios", Platform::Linux => "linux", Platform::Windows => "windows", Platform::Android => "android", } } } impl FromStr for Platform { type Err = String; fn from_str(s: &str) -> Result { Ok(match s { "macos" => Platform::Macos, "ios" => Platform::Ios, "linux" => Platform::Linux, "windows" => Platform::Windows, "android" => Platform::Android, other => return Err(format!("unknown platform `{other}`")), }) } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Arch { Aarch64, X86_64, Universal, } impl Arch { pub fn as_str(self) -> &'static str { match self { Arch::Aarch64 => "aarch64", Arch::X86_64 => "x86_64", Arch::Universal => "universal", } } } impl FromStr for Arch { type Err = String; fn from_str(s: &str) -> Result { Ok(match s { "aarch64" => Arch::Aarch64, "x86_64" => Arch::X86_64, "universal" => Arch::Universal, other => return Err(format!("unknown arch `{other}`")), }) } } /// A build target: `(platform, arch)`, rendered `platform/arch` /// (e.g. `macos/aarch64`). This is the dispatch unit — a target only runs on a /// host that declares it can build it natively. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Target { pub platform: Platform, pub arch: Arch, } impl Target { pub fn new(platform: Platform, arch: Arch) -> Self { Self { platform, arch } } } impl fmt::Display for Target { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}/{}", self.platform.as_str(), self.arch.as_str()) } } impl FromStr for Target { type Err = String; fn from_str(s: &str) -> Result { let (p, a) = s .split_once('/') .ok_or_else(|| format!("target `{s}` is not `platform/arch`"))?; Ok(Target::new(p.parse()?, a.parse()?)) } } // Round-trip through JSON / TOML as the `platform/arch` string. impl Serialize for Target { fn serialize(&self, s: S) -> Result { s.serialize_str(&self.to_string()) } } impl<'de> Deserialize<'de> for Target { fn deserialize>(d: D) -> Result { let s = String::deserialize(d)?; s.parse().map_err(serde::de::Error::custom) } } // --------------------------------------------------------------------- // Step // --------------------------------------------------------------------- /// The canonical release step sequence. A recipe marks transitions by calling /// the `step(name)` host function; not every platform uses every step (Linux /// skips sign/notarize/staple). The TUI renders these as matrix columns. /// /// `Deploy` is the terminal step for a [`crate::topology::Kind::Service`]: where /// an app ends at `collect` and a library at `publish`, a service ends by /// landing its binary on the host that runs it and restarting the unit. It is /// last in `ALL` because a service reaches it after every gate the other kinds /// use, and appending rather than inserting leaves the existing column order /// (and every stored `step_runs.step` string) untouched. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Step { Checkout, Prebuild, Build, Sign, Notarize, Staple, Verify, Package, Publish, Collect, Deploy, } impl Step { /// All steps in canonical order — the matrix column set. pub const ALL: [Step; 11] = [ Step::Checkout, Step::Prebuild, Step::Build, Step::Sign, Step::Notarize, Step::Staple, Step::Verify, Step::Package, Step::Publish, Step::Collect, Step::Deploy, ]; pub fn as_str(self) -> &'static str { match self { Step::Checkout => "checkout", Step::Prebuild => "prebuild", Step::Build => "build", Step::Sign => "sign", Step::Notarize => "notarize", Step::Staple => "staple", Step::Verify => "verify", Step::Package => "package", Step::Publish => "publish", Step::Collect => "collect", Step::Deploy => "deploy", } } } impl fmt::Display for Step { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } impl FromStr for Step { type Err = String; fn from_str(s: &str) -> Result { Step::ALL .into_iter() .find(|st| st.as_str() == s) .ok_or_else(|| format!("unknown step `{s}`")) } } // --------------------------------------------------------------------- // Version (semver) // --------------------------------------------------------------------- /// App semver (e.g. `0.4.1`), read from `tauri.conf.json` or supplied to /// `/build`. Stored as TEXT. Ordered by semver precedence (not the TEXT /// column's lexical order) so publish can enforce version monotonicity. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Version(semver::Version); impl Version { pub fn parse(s: &str) -> Result { semver::Version::parse(s) .map(Self) .map_err(|e| format!("invalid semver `{s}`: {e}")) } /// The `(major, minor, patch)` core, ignoring any prerelease/build suffix. /// Used to match a build against the plain `X.Y.Z` embedded in an artifact /// file name, which never carries the suffix. pub fn core(&self) -> (u64, u64, u64) { (self.0.major, self.0.minor, self.0.patch) } } impl fmt::Display for Version { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } impl FromStr for Version { type Err = String; fn from_str(s: &str) -> Result { Version::parse(s) } } impl Serialize for Version { fn serialize(&self, s: S) -> Result { s.serialize_str(&self.to_string()) } } impl<'de> Deserialize<'de> for Version { fn deserialize>(d: D) -> Result { let s = String::deserialize(d)?; Version::parse(&s).map_err(serde::de::Error::custom) } } // --------------------------------------------------------------------- // StepRunId — primary key of `step_runs`, used to key live tails (Ord so the // TUI can iterate chronologically, like Sando's GateRunId). // --------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct StepRunId(pub i64); impl fmt::Display for StepRunId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } /// Outcome of a step / target / build. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Status { Pending, Running, Ok, Failed, } impl Status { pub fn as_str(self) -> &'static str { match self { Status::Pending => "pending", Status::Running => "running", Status::Ok => "ok", Status::Failed => "failed", } } } #[cfg(test)] mod tests { use super::*; #[test] fn target_roundtrips() { let t: Target = "macos/aarch64".parse().unwrap(); assert_eq!(t.platform, Platform::Macos); assert_eq!(t.arch, Arch::Aarch64); assert_eq!(t.to_string(), "macos/aarch64"); } #[test] fn target_rejects_garbage() { assert!("macos".parse::().is_err()); assert!("mac/aarch64".parse::().is_err()); assert!("macos/sparc".parse::().is_err()); } #[test] fn target_json_is_the_string() { let t: Target = "linux/x86_64".parse().unwrap(); assert_eq!(serde_json::to_string(&t).unwrap(), "\"linux/x86_64\""); let back: Target = serde_json::from_str("\"linux/x86_64\"").unwrap(); assert_eq!(back, t); } #[test] fn step_roundtrips() { for s in Step::ALL { assert_eq!(s.as_str().parse::().unwrap(), s); } assert!("frobnicate".parse::().is_err()); } #[test] fn version_parses_semver() { assert_eq!(Version::parse("0.4.1").unwrap().to_string(), "0.4.1"); assert!(Version::parse("v0.4").is_err()); } #[test] fn version_orders_by_semver_not_lexically() { let v = |s: &str| Version::parse(s).unwrap(); assert!(v("0.4.0") < v("0.5.0")); // Lexical order would put "0.4.10" < "0.4.9"; semver must not. assert!(v("0.4.10") > v("0.4.9")); assert!(v("1.0.0") > v("0.99.99")); let mut vs = [v("0.4.10"), v("0.4.2"), v("0.5.0")]; vs.sort(); assert_eq!(vs.iter().max().unwrap().to_string(), "0.5.0"); } }