Skip to main content

max / makenotwork

11.3 KB · 386 lines History Blame Raw
1 //! Domain vocabulary for Bento — the types every module speaks.
2 //!
3 //! Bento's axes are App x Target x Step. A `(app, target)` resolves to a Rhai
4 //! recipe; the recipe walks the canonical [`Step`] sequence. Newtypes carry
5 //! the boundary parse: a [`Target`] exists because some `"platform/arch"`
6 //! string validated, so downstream code never re-parses.
7
8 use serde::{Deserialize, Serialize};
9 use std::fmt;
10 use std::str::FromStr;
11
12 // ---------------------------------------------------------------------
13 // App identifier
14 // ---------------------------------------------------------------------
15
16 /// An app Bento can release: `goingson`, `balanced_breakfast`, `audiofiles`.
17 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
18 #[serde(transparent)]
19 pub struct AppId(String);
20
21 impl AppId {
22 pub fn new(s: impl Into<String>) -> Self {
23 Self(s.into())
24 }
25 pub fn as_str(&self) -> &str {
26 &self.0
27 }
28 }
29
30 impl fmt::Display for AppId {
31 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32 self.0.fmt(f)
33 }
34 }
35
36 impl From<&str> for AppId {
37 fn from(s: &str) -> Self {
38 Self(s.to_owned())
39 }
40 }
41
42 // ---------------------------------------------------------------------
43 // Platform / Arch / Target
44 // ---------------------------------------------------------------------
45
46 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
47 #[serde(rename_all = "snake_case")]
48 pub enum Platform {
49 Macos,
50 Ios,
51 Linux,
52 Windows,
53 Android,
54 }
55
56 impl Platform {
57 pub fn as_str(self) -> &'static str {
58 match self {
59 Platform::Macos => "macos",
60 Platform::Ios => "ios",
61 Platform::Linux => "linux",
62 Platform::Windows => "windows",
63 Platform::Android => "android",
64 }
65 }
66 }
67
68 impl FromStr for Platform {
69 type Err = String;
70 fn from_str(s: &str) -> Result<Self, Self::Err> {
71 Ok(match s {
72 "macos" => Platform::Macos,
73 "ios" => Platform::Ios,
74 "linux" => Platform::Linux,
75 "windows" => Platform::Windows,
76 "android" => Platform::Android,
77 other => return Err(format!("unknown platform `{other}`")),
78 })
79 }
80 }
81
82 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
83 #[serde(rename_all = "snake_case")]
84 pub enum Arch {
85 Aarch64,
86 X86_64,
87 Universal,
88 }
89
90 impl Arch {
91 pub fn as_str(self) -> &'static str {
92 match self {
93 Arch::Aarch64 => "aarch64",
94 Arch::X86_64 => "x86_64",
95 Arch::Universal => "universal",
96 }
97 }
98 }
99
100 impl FromStr for Arch {
101 type Err = String;
102 fn from_str(s: &str) -> Result<Self, Self::Err> {
103 Ok(match s {
104 "aarch64" => Arch::Aarch64,
105 "x86_64" => Arch::X86_64,
106 "universal" => Arch::Universal,
107 other => return Err(format!("unknown arch `{other}`")),
108 })
109 }
110 }
111
112 /// A build target: `(platform, arch)`, rendered `platform/arch`
113 /// (e.g. `macos/aarch64`). This is the dispatch unit — a target only runs on a
114 /// host that declares it can build it natively.
115 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
116 pub struct Target {
117 pub platform: Platform,
118 pub arch: Arch,
119 }
120
121 impl Target {
122 pub fn new(platform: Platform, arch: Arch) -> Self {
123 Self { platform, arch }
124 }
125 }
126
127 impl fmt::Display for Target {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 write!(f, "{}/{}", self.platform.as_str(), self.arch.as_str())
130 }
131 }
132
133 impl FromStr for Target {
134 type Err = String;
135 fn from_str(s: &str) -> Result<Self, Self::Err> {
136 let (p, a) = s
137 .split_once('/')
138 .ok_or_else(|| format!("target `{s}` is not `platform/arch`"))?;
139 Ok(Target::new(p.parse()?, a.parse()?))
140 }
141 }
142
143 // Round-trip through JSON / TOML as the `platform/arch` string.
144 impl Serialize for Target {
145 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
146 s.serialize_str(&self.to_string())
147 }
148 }
149
150 impl<'de> Deserialize<'de> for Target {
151 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
152 let s = String::deserialize(d)?;
153 s.parse().map_err(serde::de::Error::custom)
154 }
155 }
156
157 // ---------------------------------------------------------------------
158 // Step
159 // ---------------------------------------------------------------------
160
161 /// The canonical release step sequence. A recipe marks transitions by calling
162 /// the `step(name)` host function; not every platform uses every step (Linux
163 /// skips sign/notarize/staple). The TUI renders these as matrix columns.
164 ///
165 /// `Deploy` is the terminal step for a [`crate::topology::Kind::Service`]: where
166 /// an app ends at `collect` and a library at `publish`, a service ends by
167 /// landing its binary on the host that runs it and restarting the unit. It is
168 /// last in `ALL` because a service reaches it after every gate the other kinds
169 /// use, and appending rather than inserting leaves the existing column order
170 /// (and every stored `step_runs.step` string) untouched.
171 ///
172 /// `Handoff` is appended for the same reason, and is the one step no recipe
173 /// runs: the daemon performs it after the recipe finishes, sending the artifact
174 /// to the Sando that will decide whether it advances (see [`crate::handoff`]).
175 /// It exists as a step so a failed handoff has an honest column to fail in —
176 /// the recipe's `collect` really did succeed, and reporting the failure there
177 /// would contradict a green step row.
178 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
179 #[serde(rename_all = "snake_case")]
180 pub enum Step {
181 Checkout,
182 Prebuild,
183 Build,
184 Sign,
185 Notarize,
186 Staple,
187 Verify,
188 Package,
189 Publish,
190 Collect,
191 Deploy,
192 Handoff,
193 }
194
195 impl Step {
196 /// All steps in canonical order — the matrix column set.
197 pub const ALL: [Step; 12] = [
198 Step::Checkout,
199 Step::Prebuild,
200 Step::Build,
201 Step::Sign,
202 Step::Notarize,
203 Step::Staple,
204 Step::Verify,
205 Step::Package,
206 Step::Publish,
207 Step::Collect,
208 Step::Deploy,
209 Step::Handoff,
210 ];
211
212 pub fn as_str(self) -> &'static str {
213 match self {
214 Step::Checkout => "checkout",
215 Step::Prebuild => "prebuild",
216 Step::Build => "build",
217 Step::Sign => "sign",
218 Step::Notarize => "notarize",
219 Step::Staple => "staple",
220 Step::Verify => "verify",
221 Step::Package => "package",
222 Step::Publish => "publish",
223 Step::Collect => "collect",
224 Step::Deploy => "deploy",
225 Step::Handoff => "handoff",
226 }
227 }
228 }
229
230 impl fmt::Display for Step {
231 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232 f.write_str(self.as_str())
233 }
234 }
235
236 impl FromStr for Step {
237 type Err = String;
238 fn from_str(s: &str) -> Result<Self, Self::Err> {
239 Step::ALL
240 .into_iter()
241 .find(|st| st.as_str() == s)
242 .ok_or_else(|| format!("unknown step `{s}`"))
243 }
244 }
245
246 // ---------------------------------------------------------------------
247 // Version (semver)
248 // ---------------------------------------------------------------------
249
250 /// App semver (e.g. `0.4.1`), read from `tauri.conf.json` or supplied to
251 /// `/build`. Stored as TEXT. Ordered by semver precedence (not the TEXT
252 /// column's lexical order) so publish can enforce version monotonicity.
253 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
254 pub struct Version(semver::Version);
255
256 impl Version {
257 pub fn parse(s: &str) -> Result<Self, String> {
258 semver::Version::parse(s)
259 .map(Self)
260 .map_err(|e| format!("invalid semver `{s}`: {e}"))
261 }
262
263 /// The `(major, minor, patch)` core, ignoring any prerelease/build suffix.
264 /// Used to match a build against the plain `X.Y.Z` embedded in an artifact
265 /// file name, which never carries the suffix.
266 pub fn core(&self) -> (u64, u64, u64) {
267 (self.0.major, self.0.minor, self.0.patch)
268 }
269 }
270
271 impl fmt::Display for Version {
272 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273 self.0.fmt(f)
274 }
275 }
276
277 impl FromStr for Version {
278 type Err = String;
279 fn from_str(s: &str) -> Result<Self, Self::Err> {
280 Version::parse(s)
281 }
282 }
283
284 impl Serialize for Version {
285 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
286 s.serialize_str(&self.to_string())
287 }
288 }
289
290 impl<'de> Deserialize<'de> for Version {
291 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
292 let s = String::deserialize(d)?;
293 Version::parse(&s).map_err(serde::de::Error::custom)
294 }
295 }
296
297 // ---------------------------------------------------------------------
298 // StepRunId — primary key of `step_runs`, used to key live tails (Ord so the
299 // TUI can iterate chronologically, like Sando's GateRunId).
300 // ---------------------------------------------------------------------
301
302 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
303 #[serde(transparent)]
304 pub struct StepRunId(pub i64);
305
306 impl fmt::Display for StepRunId {
307 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308 self.0.fmt(f)
309 }
310 }
311
312 /// Outcome of a step / target / build.
313 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
314 #[serde(rename_all = "snake_case")]
315 pub enum Status {
316 Pending,
317 Running,
318 Ok,
319 Failed,
320 }
321
322 impl Status {
323 pub fn as_str(self) -> &'static str {
324 match self {
325 Status::Pending => "pending",
326 Status::Running => "running",
327 Status::Ok => "ok",
328 Status::Failed => "failed",
329 }
330 }
331 }
332
333 #[cfg(test)]
334 mod tests {
335 use super::*;
336
337 #[test]
338 fn target_roundtrips() {
339 let t: Target = "macos/aarch64".parse().unwrap();
340 assert_eq!(t.platform, Platform::Macos);
341 assert_eq!(t.arch, Arch::Aarch64);
342 assert_eq!(t.to_string(), "macos/aarch64");
343 }
344
345 #[test]
346 fn target_rejects_garbage() {
347 assert!("macos".parse::<Target>().is_err());
348 assert!("mac/aarch64".parse::<Target>().is_err());
349 assert!("macos/sparc".parse::<Target>().is_err());
350 }
351
352 #[test]
353 fn target_json_is_the_string() {
354 let t: Target = "linux/x86_64".parse().unwrap();
355 assert_eq!(serde_json::to_string(&t).unwrap(), "\"linux/x86_64\"");
356 let back: Target = serde_json::from_str("\"linux/x86_64\"").unwrap();
357 assert_eq!(back, t);
358 }
359
360 #[test]
361 fn step_roundtrips() {
362 for s in Step::ALL {
363 assert_eq!(s.as_str().parse::<Step>().unwrap(), s);
364 }
365 assert!("frobnicate".parse::<Step>().is_err());
366 }
367
368 #[test]
369 fn version_parses_semver() {
370 assert_eq!(Version::parse("0.4.1").unwrap().to_string(), "0.4.1");
371 assert!(Version::parse("v0.4").is_err());
372 }
373
374 #[test]
375 fn version_orders_by_semver_not_lexically() {
376 let v = |s: &str| Version::parse(s).unwrap();
377 assert!(v("0.4.0") < v("0.5.0"));
378 // Lexical order would put "0.4.10" < "0.4.9"; semver must not.
379 assert!(v("0.4.10") > v("0.4.9"));
380 assert!(v("1.0.0") > v("0.99.99"));
381 let mut vs = [v("0.4.10"), v("0.4.2"), v("0.5.0")];
382 vs.sort();
383 assert_eq!(vs.iter().max().unwrap().to_string(), "0.5.0");
384 }
385 }
386