Skip to main content

max / makenotwork

10.8 KB · 376 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 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
172 #[serde(rename_all = "snake_case")]
173 pub enum Step {
174 Checkout,
175 Prebuild,
176 Build,
177 Sign,
178 Notarize,
179 Staple,
180 Verify,
181 Package,
182 Publish,
183 Collect,
184 Deploy,
185 }
186
187 impl Step {
188 /// All steps in canonical order — the matrix column set.
189 pub const ALL: [Step; 11] = [
190 Step::Checkout,
191 Step::Prebuild,
192 Step::Build,
193 Step::Sign,
194 Step::Notarize,
195 Step::Staple,
196 Step::Verify,
197 Step::Package,
198 Step::Publish,
199 Step::Collect,
200 Step::Deploy,
201 ];
202
203 pub fn as_str(self) -> &'static str {
204 match self {
205 Step::Checkout => "checkout",
206 Step::Prebuild => "prebuild",
207 Step::Build => "build",
208 Step::Sign => "sign",
209 Step::Notarize => "notarize",
210 Step::Staple => "staple",
211 Step::Verify => "verify",
212 Step::Package => "package",
213 Step::Publish => "publish",
214 Step::Collect => "collect",
215 Step::Deploy => "deploy",
216 }
217 }
218 }
219
220 impl fmt::Display for Step {
221 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222 f.write_str(self.as_str())
223 }
224 }
225
226 impl FromStr for Step {
227 type Err = String;
228 fn from_str(s: &str) -> Result<Self, Self::Err> {
229 Step::ALL
230 .into_iter()
231 .find(|st| st.as_str() == s)
232 .ok_or_else(|| format!("unknown step `{s}`"))
233 }
234 }
235
236 // ---------------------------------------------------------------------
237 // Version (semver)
238 // ---------------------------------------------------------------------
239
240 /// App semver (e.g. `0.4.1`), read from `tauri.conf.json` or supplied to
241 /// `/build`. Stored as TEXT. Ordered by semver precedence (not the TEXT
242 /// column's lexical order) so publish can enforce version monotonicity.
243 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
244 pub struct Version(semver::Version);
245
246 impl Version {
247 pub fn parse(s: &str) -> Result<Self, String> {
248 semver::Version::parse(s)
249 .map(Self)
250 .map_err(|e| format!("invalid semver `{s}`: {e}"))
251 }
252
253 /// The `(major, minor, patch)` core, ignoring any prerelease/build suffix.
254 /// Used to match a build against the plain `X.Y.Z` embedded in an artifact
255 /// file name, which never carries the suffix.
256 pub fn core(&self) -> (u64, u64, u64) {
257 (self.0.major, self.0.minor, self.0.patch)
258 }
259 }
260
261 impl fmt::Display for Version {
262 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263 self.0.fmt(f)
264 }
265 }
266
267 impl FromStr for Version {
268 type Err = String;
269 fn from_str(s: &str) -> Result<Self, Self::Err> {
270 Version::parse(s)
271 }
272 }
273
274 impl Serialize for Version {
275 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
276 s.serialize_str(&self.to_string())
277 }
278 }
279
280 impl<'de> Deserialize<'de> for Version {
281 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
282 let s = String::deserialize(d)?;
283 Version::parse(&s).map_err(serde::de::Error::custom)
284 }
285 }
286
287 // ---------------------------------------------------------------------
288 // StepRunId — primary key of `step_runs`, used to key live tails (Ord so the
289 // TUI can iterate chronologically, like Sando's GateRunId).
290 // ---------------------------------------------------------------------
291
292 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
293 #[serde(transparent)]
294 pub struct StepRunId(pub i64);
295
296 impl fmt::Display for StepRunId {
297 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298 self.0.fmt(f)
299 }
300 }
301
302 /// Outcome of a step / target / build.
303 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
304 #[serde(rename_all = "snake_case")]
305 pub enum Status {
306 Pending,
307 Running,
308 Ok,
309 Failed,
310 }
311
312 impl Status {
313 pub fn as_str(self) -> &'static str {
314 match self {
315 Status::Pending => "pending",
316 Status::Running => "running",
317 Status::Ok => "ok",
318 Status::Failed => "failed",
319 }
320 }
321 }
322
323 #[cfg(test)]
324 mod tests {
325 use super::*;
326
327 #[test]
328 fn target_roundtrips() {
329 let t: Target = "macos/aarch64".parse().unwrap();
330 assert_eq!(t.platform, Platform::Macos);
331 assert_eq!(t.arch, Arch::Aarch64);
332 assert_eq!(t.to_string(), "macos/aarch64");
333 }
334
335 #[test]
336 fn target_rejects_garbage() {
337 assert!("macos".parse::<Target>().is_err());
338 assert!("mac/aarch64".parse::<Target>().is_err());
339 assert!("macos/sparc".parse::<Target>().is_err());
340 }
341
342 #[test]
343 fn target_json_is_the_string() {
344 let t: Target = "linux/x86_64".parse().unwrap();
345 assert_eq!(serde_json::to_string(&t).unwrap(), "\"linux/x86_64\"");
346 let back: Target = serde_json::from_str("\"linux/x86_64\"").unwrap();
347 assert_eq!(back, t);
348 }
349
350 #[test]
351 fn step_roundtrips() {
352 for s in Step::ALL {
353 assert_eq!(s.as_str().parse::<Step>().unwrap(), s);
354 }
355 assert!("frobnicate".parse::<Step>().is_err());
356 }
357
358 #[test]
359 fn version_parses_semver() {
360 assert_eq!(Version::parse("0.4.1").unwrap().to_string(), "0.4.1");
361 assert!(Version::parse("v0.4").is_err());
362 }
363
364 #[test]
365 fn version_orders_by_semver_not_lexically() {
366 let v = |s: &str| Version::parse(s).unwrap();
367 assert!(v("0.4.0") < v("0.5.0"));
368 // Lexical order would put "0.4.10" < "0.4.9"; semver must not.
369 assert!(v("0.4.10") > v("0.4.9"));
370 assert!(v("1.0.0") > v("0.99.99"));
371 let mut vs = [v("0.4.10"), v("0.4.2"), v("0.5.0")];
372 vs.sort();
373 assert_eq!(vs.iter().max().unwrap().to_string(), "0.5.0");
374 }
375 }
376