Skip to main content

runner_manager_domain/
model.rs

1// owner: b1-domain-core
2
3//! Value types shared by every other module in this crate.
4//!
5//! These are the types named in
6//! `.taskflow/2026-08-21-local-runner-manager/04-subsystem-contracts.md`,
7//! "Persistent local data". Two rules shape every type here:
8//!
9//! 1. **No I/O.** Nothing in this crate opens a socket, a file, or a database.
10//!    `rusqlite` is declared in `crates/domain/Cargo.toml` for `b2`, which owns
11//!    `store.rs`; no module owned by `b1` refers to it.
12//! 2. **An illegal configuration should be unrepresentable, not merely
13//!    rejected.** Where the contract document writes a plain integer that has a
14//!    documented floor or ceiling — `host_capacity`, `refresh_interval_secs` —
15//!    this module gives it a type that cannot hold the illegal value, so a
16//!    caller who forgets to validate still cannot build one.
17//!
18//! D4 removed `scale_set_id`, `scale_set_name`, and `protocol_flag` from the
19//! model. They are not here under another name, and they must not come back:
20//! the routing token is now [`crate::policy::RoutingLabels`].
21
22use std::cmp::Ordering;
23use std::fmt;
24use std::hash::{Hash, Hasher};
25use std::num::{NonZeroU16, NonZeroUsize};
26use std::str::FromStr;
27
28use serde::{Deserialize, Serialize};
29use uuid::Uuid;
30
31use crate::path::LocalAbsolutePath;
32
33/// Every timestamp in the domain is UTC.
34///
35/// The domain never reads this from the operating system. Decisions that depend
36/// on elapsed time take a [`Clock`], which the tests replace with
37/// `runner_manager_testkit::clock::FakeClock`.
38pub type Timestamp = chrono::DateTime<chrono::Utc>;
39
40/// A span of time, re-exported so callers need not name `chrono` directly.
41pub type Elapsed = chrono::TimeDelta;
42
43// ---------------------------------------------------------------------------
44// Errors
45// ---------------------------------------------------------------------------
46
47/// A value that cannot be constructed because it would violate a documented
48/// constraint.
49#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
50pub enum ValidationError {
51    #[error("{what} must not be empty")]
52    Empty { what: &'static str },
53
54    #[error("{what} must be at most {max} characters, got {actual}")]
55    TooLong {
56        what: &'static str,
57        max: usize,
58        actual: usize,
59    },
60
61    #[error("{what} contains a character that is not allowed here: {found:?}")]
62    IllegalCharacter { what: &'static str, found: char },
63
64    #[error("{what} must not start or end with {edge:?}")]
65    IllegalEdge { what: &'static str, edge: char },
66
67    #[error(
68        "a repository target must be written as OWNER/REPO; got {got:?} with {slashes} separator(s)"
69    )]
70    MalformedOwnerRepo { got: String, slashes: usize },
71
72    #[error("{what} must be at least {min}, got {actual}")]
73    BelowFloor {
74        what: &'static str,
75        min: u16,
76        actual: u16,
77    },
78
79    #[error("a non-empty collection of {what} was required, but none were supplied")]
80    NonEmptyRequired { what: &'static str },
81
82    #[error("{what} is not a recognised value: {got:?}")]
83    Unrecognised { what: &'static str, got: String },
84}
85
86// ---------------------------------------------------------------------------
87// Clock
88// ---------------------------------------------------------------------------
89
90/// The domain's only source of "now".
91///
92/// `b1`'s Definition of Done requires that recovery decisions be testable with
93/// no real time dependency, so every function in this crate that compares
94/// timestamps takes one of these rather than calling the system clock.
95pub trait Clock: fmt::Debug + Send + Sync {
96    fn now(&self) -> Timestamp;
97}
98
99/// The production adapter.
100///
101/// It is an adapter and nothing more: **no decision function in this crate
102/// constructs one**. Every such function takes `&dyn Clock`, which is what makes
103/// `FakeClock` a complete substitute in tests. It lives here rather than in
104/// `crates/platform` because the port lives here and `platform` depends on
105/// `domain`, not the other way round.
106#[derive(Debug, Clone, Copy, Default)]
107pub struct SystemClock;
108
109impl Clock for SystemClock {
110    fn now(&self) -> Timestamp {
111        chrono::Utc::now()
112    }
113}
114
115impl<T: Clock + ?Sized> Clock for &T {
116    fn now(&self) -> Timestamp {
117        (**self).now()
118    }
119}
120
121impl<T: Clock + ?Sized> Clock for std::sync::Arc<T> {
122    fn now(&self) -> Timestamp {
123        (**self).now()
124    }
125}
126
127// ---------------------------------------------------------------------------
128// Identifiers
129// ---------------------------------------------------------------------------
130
131macro_rules! uuid_newtype {
132    ($name:ident, $doc:literal) => {
133        #[doc = $doc]
134        #[derive(
135            Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
136        )]
137        #[serde(transparent)]
138        pub struct $name(Uuid);
139
140        impl $name {
141            /// A fresh random identifier.
142            #[must_use]
143            pub fn new_random() -> Self {
144                Self(Uuid::new_v4())
145            }
146
147            /// A deterministic identifier, for fixtures and tests.
148            #[must_use]
149            pub const fn from_u128(value: u128) -> Self {
150                Self(Uuid::from_u128(value))
151            }
152
153            #[must_use]
154            pub const fn from_uuid(value: Uuid) -> Self {
155                Self(value)
156            }
157
158            #[must_use]
159            pub const fn as_uuid(&self) -> &Uuid {
160                &self.0
161            }
162        }
163
164        impl fmt::Display for $name {
165            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166                fmt::Display::fmt(&self.0, f)
167            }
168        }
169    };
170}
171
172uuid_newtype!(HostId, "Identifies one physical machine running one agent.");
173uuid_newtype!(PolicyId, "Identifies one `ScalePolicy`.");
174uuid_newtype!(AttemptId, "Identifies one `RunnerAttempt`.");
175
176// ---------------------------------------------------------------------------
177// Host operating system and architecture
178// ---------------------------------------------------------------------------
179
180/// The host operating systems this product supports.
181///
182/// The supported-version matrix — Windows 10/11 and Server 2016/2019/2022,
183/// macOS 11.0+, and the nine listed Linux distributions — is `d1`'s to validate
184/// (`01-current-architecture.md`, "Authoritative external constraints"). The
185/// domain only needs the three families, because that is what a routing label
186/// encodes.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum Os {
190    Windows,
191    MacOs,
192    Linux,
193}
194
195impl Os {
196    pub const ALL: [Os; 3] = [Os::Windows, Os::MacOs, Os::Linux];
197
198    /// The token this OS contributes to a derived routing label.
199    ///
200    /// These are GitHub's own runner-package OS tokens (`win`, `osx`, `linux`),
201    /// which is what `e2` will select a download by, so a label and a package
202    /// name never disagree about what "this host" is. `02-target-architecture.md`
203    /// gives `rm-home-win-x64` as the worked example, which fixes `win`.
204    #[must_use]
205    pub const fn label_token(self) -> &'static str {
206        match self {
207            Os::Windows => "win",
208            Os::MacOs => "osx",
209            Os::Linux => "linux",
210        }
211    }
212
213    /// Container actions and service containers require Linux
214    /// (`01-current-architecture.md`, edge case 2). `f2` surfaces this on a
215    /// macOS or Windows policy.
216    #[must_use]
217    pub const fn supports_container_actions(self) -> bool {
218        matches!(self, Os::Linux)
219    }
220}
221
222impl fmt::Display for Os {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        f.write_str(self.label_token())
225    }
226}
227
228impl FromStr for Os {
229    type Err = ValidationError;
230
231    fn from_str(s: &str) -> Result<Self, Self::Err> {
232        match s.trim().to_ascii_lowercase().as_str() {
233            "win" | "windows" => Ok(Os::Windows),
234            "osx" | "macos" | "mac" | "darwin" => Ok(Os::MacOs),
235            "linux" => Ok(Os::Linux),
236            other => Err(ValidationError::Unrecognised {
237                what: "host operating system",
238                got: other.to_string(),
239            }),
240        }
241    }
242}
243
244/// The host architectures this product supports.
245///
246/// ARM64 is public preview on all three operating systems and ARM32 is Linux
247/// only (`01-current-architecture.md`). Enforcing that pairing is `d1`'s job;
248/// this enum only has to be able to name the values.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub enum Arch {
252    X64,
253    Arm64,
254    Arm32,
255}
256
257impl Arch {
258    pub const ALL: [Arch; 3] = [Arch::X64, Arch::Arm64, Arch::Arm32];
259
260    /// The token this architecture contributes to a derived routing label.
261    /// GitHub's runner-package architecture tokens; `x64` is fixed by the
262    /// `rm-home-win-x64` example in `02-target-architecture.md`.
263    #[must_use]
264    pub const fn label_token(self) -> &'static str {
265        match self {
266            Arch::X64 => "x64",
267            Arch::Arm64 => "arm64",
268            Arch::Arm32 => "arm",
269        }
270    }
271
272    /// ARM64 is public preview, which `f2` must warn about rather than reject.
273    #[must_use]
274    pub const fn is_public_preview(self) -> bool {
275        matches!(self, Arch::Arm64)
276    }
277}
278
279impl fmt::Display for Arch {
280    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281        f.write_str(self.label_token())
282    }
283}
284
285impl FromStr for Arch {
286    type Err = ValidationError;
287
288    fn from_str(s: &str) -> Result<Self, Self::Err> {
289        match s.trim().to_ascii_lowercase().as_str() {
290            "x64" | "x86_64" | "amd64" => Ok(Arch::X64),
291            "arm64" | "aarch64" => Ok(Arch::Arm64),
292            "arm" | "arm32" | "armv7" => Ok(Arch::Arm32),
293            other => Err(ValidationError::Unrecognised {
294                what: "host architecture",
295                got: other.to_string(),
296            }),
297        }
298    }
299}
300
301// ---------------------------------------------------------------------------
302// Service start mode and cache policy
303// ---------------------------------------------------------------------------
304
305/// When the installed service starts (`05-infrastructure.md`, "Service
306/// behavior"). D13 makes `Boot` the default; `Login` is available for operators
307/// who prefer a user-scoped secret store.
308#[derive(
309    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
310)]
311#[serde(rename_all = "snake_case")]
312pub enum StartMode {
313    #[default]
314    Boot,
315    Login,
316}
317
318impl fmt::Display for StartMode {
319    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
320        f.write_str(match self {
321            StartMode::Boot => "boot",
322            StartMode::Login => "login",
323        })
324    }
325}
326
327impl FromStr for StartMode {
328    type Err = ValidationError;
329
330    fn from_str(s: &str) -> Result<Self, Self::Err> {
331        match s.trim().to_ascii_lowercase().as_str() {
332            "boot" => Ok(StartMode::Boot),
333            "login" => Ok(StartMode::Login),
334            other => Err(ValidationError::Unrecognised {
335                what: "service start mode",
336                got: other.to_string(),
337            }),
338        }
339    }
340}
341
342/// What survives an attempt's cleanup.
343///
344/// `04-subsystem-contracts.md`, precedence rule 6: "Runtime cache retention is
345/// optional; job workspace retention is **always disabled** in v1."
346///
347/// The first half is the choice this enum offers. The second half is enforced by
348/// having no representation at all: [`CachePolicy::retains_job_workspace`] is a
349/// `const fn` returning `false`, and there is no variant, field, or constructor
350/// that could make it return anything else. A future version that wants
351/// workspace retention has to add one deliberately, which is the point — a
352/// retained workspace is the two-job contamination path that `e3` and
353/// `07-security.md` test against.
354#[derive(
355    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
356)]
357#[serde(rename_all = "snake_case")]
358pub enum CachePolicy {
359    /// Keep the verified runner package cache between attempts (the default;
360    /// re-downloading it on every cold start would be the main avoidable cost).
361    #[default]
362    RetainRunnerPackage,
363    /// Discard the runner package cache after each attempt.
364    DiscardRunnerPackage,
365}
366
367impl CachePolicy {
368    #[must_use]
369    pub const fn retains_runner_package(self) -> bool {
370        matches!(self, CachePolicy::RetainRunnerPackage)
371    }
372
373    /// Always `false`, by construction rather than by policy.
374    ///
375    /// D4 is the deliberate addition the type comment anticipated, and it was
376    /// made **on another type**: job-workspace retention is
377    /// [`crate::workspace::WorkspacePolicy`], repository-scoped, opt-in, and
378    /// carrying its own root. Nothing was added here, so a caller reading a
379    /// `CachePolicy` still cannot conclude anything about the job workspace —
380    /// which is the point of keeping them apart
381    /// (`02-target-architecture.md`, "Repository policy").
382    #[must_use]
383    pub const fn retains_job_workspace(self) -> bool {
384        false
385    }
386}
387
388// ---------------------------------------------------------------------------
389// Refresh interval
390// ---------------------------------------------------------------------------
391
392/// The agent's per-target refresh interval.
393///
394/// `04-subsystem-contracts.md`, "Refresh and backpressure": "a bounded interval,
395/// default 60 seconds with a hard floor of 30 seconds per target". The floor is
396/// a rate-budget constraint, not a preference — below it, one target's demand,
397/// inventory, and workflow-count polling alone consumes roughly a tenth of the
398/// hourly ceiling. Making it a type means a caller cannot write `5` into
399/// `Host.refresh_interval_secs` at all.
400#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
401#[serde(try_from = "u16", into = "u16")]
402pub struct RefreshInterval(u16);
403
404impl RefreshInterval {
405    pub const MIN_SECS: u16 = 30;
406    pub const DEFAULT_SECS: u16 = 60;
407
408    /// # Errors
409    /// [`ValidationError::BelowFloor`] if `secs` is under the documented
410    /// 30-second floor.
411    pub fn from_secs(secs: u16) -> Result<Self, ValidationError> {
412        if secs < Self::MIN_SECS {
413            return Err(ValidationError::BelowFloor {
414                what: "refresh interval (seconds)",
415                min: Self::MIN_SECS,
416                actual: secs,
417            });
418        }
419        Ok(Self(secs))
420    }
421
422    #[must_use]
423    pub const fn as_secs(self) -> u16 {
424        self.0
425    }
426}
427
428impl Default for RefreshInterval {
429    fn default() -> Self {
430        Self(Self::DEFAULT_SECS)
431    }
432}
433
434impl TryFrom<u16> for RefreshInterval {
435    type Error = ValidationError;
436
437    fn try_from(value: u16) -> Result<Self, Self::Error> {
438        Self::from_secs(value)
439    }
440}
441
442impl From<RefreshInterval> for u16 {
443    fn from(value: RefreshInterval) -> Self {
444        value.0
445    }
446}
447
448impl fmt::Display for RefreshInterval {
449    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
450        write!(f, "{}s", self.0)
451    }
452}
453
454// ---------------------------------------------------------------------------
455// Labels
456// ---------------------------------------------------------------------------
457
458/// One GitHub runner label, normalised.
459///
460/// **Normalisation is lower-casing, and it is not cosmetic.** The `v1` spike
461/// registered a runner with `["rm-d18-spike","Windows","X64","self-hosted-rm"]`
462/// and GitHub returned `windows` and `x64`
463/// (`docs/spikes/d18-org-jit-verification.md`, Point 3, finding 3). A label read
464/// back from the API therefore never matches a mixed-case label held locally
465/// unless both sides are folded first. Folding on construction makes every
466/// comparison in this crate — `Eq`, `Ord`, `Hash`, set membership — case
467/// insensitive for free, so no call site can forget.
468///
469/// **The fold is ASCII, matching [`HostLabel`] and the target [`Name`] types.**
470/// The only case folding this crate has evidence for is GitHub's, and the only
471/// evidence is the spike above, which is entirely ASCII. Unicode
472/// `to_lowercase()` would also make folding length-changing — `İ` (U+0130)
473/// lowercases to two chars — which is how a 256-character label could exceed
474/// [`Self::MAX_LEN`] *after* construction. ASCII folding is length-preserving,
475/// so that class of bug cannot occur, and the length check below is applied to
476/// the folded value regardless so the stored string is what was measured.
477///
478/// **What the character rules are for.** They are round-trippability rules, not
479/// injection defences, and reading them as the latter is how a rule that
480/// defends nothing gets added. A `Label` is never interpolated into a shell: it
481/// travels as one element of the runner's comma-separated `--labels` argument
482/// and as a JSON string. So exactly two characters break the round trip — the
483/// comma, which is the separator itself, and control characters, which break
484/// both the argument and the JSON framing. Nothing else does, and a quote rule
485/// in particular is neither necessary (no shell is involved) nor sufficient
486/// (`<`, `>`, `$`, `` ` ``, `;`, `|`, `&` and whitespace would all still pass).
487///
488/// An explicit allow-list was considered and rejected: real GitHub labels
489/// include `c#`, `.net`, `x86_64` and similar, so any allow-list narrow enough
490/// to be worth having would reject legitimate `runs-on` values and turn a
491/// cosmetic concern into a demand-matching failure in
492/// [`crate::policy::RunsOn::required_labels`].
493#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
494#[serde(try_from = "String", into = "String")]
495pub struct Label(String);
496
497impl Label {
498    /// GitHub's documented maximum label length.
499    pub const MAX_LEN: usize = 256;
500
501    /// # Errors
502    /// Empty, over-long, comma-bearing, or control-character-bearing input. A
503    /// comma is rejected because it separates labels in the runner's own
504    /// configuration, so a label containing one is not round-trippable; control
505    /// characters break the same argument and the JSON encoding around it. See
506    /// the type documentation for why the list stops there.
507    pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
508        let trimmed = raw.as_ref().trim();
509        if trimmed.is_empty() {
510            return Err(ValidationError::Empty { what: "a label" });
511        }
512        if let Some(bad) = trimmed.chars().find(|c| *c == ',' || c.is_control()) {
513            return Err(ValidationError::IllegalCharacter {
514                what: "a label",
515                found: bad,
516            });
517        }
518        // Folded first, then measured: the length that matters is the length of
519        // the value this type will actually hold and hand to GitHub.
520        let folded = trimmed.to_ascii_lowercase();
521        if folded.chars().count() > Self::MAX_LEN {
522            return Err(ValidationError::TooLong {
523                what: "a label",
524                max: Self::MAX_LEN,
525                actual: folded.chars().count(),
526            });
527        }
528        Ok(Self(folded))
529    }
530
531    #[must_use]
532    pub fn as_str(&self) -> &str {
533        &self.0
534    }
535}
536
537impl fmt::Display for Label {
538    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
539        f.write_str(&self.0)
540    }
541}
542
543impl TryFrom<String> for Label {
544    type Error = ValidationError;
545
546    fn try_from(value: String) -> Result<Self, Self::Error> {
547        Self::new(value)
548    }
549}
550
551impl TryFrom<&str> for Label {
552    type Error = ValidationError;
553
554    fn try_from(value: &str) -> Result<Self, Self::Error> {
555        Self::new(value)
556    }
557}
558
559impl From<Label> for String {
560    fn from(value: Label) -> Self {
561        value.0
562    }
563}
564
565impl FromStr for Label {
566    type Err = ValidationError;
567
568    fn from_str(s: &str) -> Result<Self, Self::Err> {
569        Self::new(s)
570    }
571}
572
573/// The operator's `--host-label` value: the human-chosen half of a routing
574/// label.
575///
576/// Narrower than a [`Label`] on purpose. This value is concatenated into
577/// `rm-<host>-<os>-<arch>`, so a host label containing a space or an upper-case
578/// letter would produce a routing label an operator could not retype into
579/// `runs-on` from memory.
580#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
581#[serde(try_from = "String", into = "String")]
582pub struct HostLabel(String);
583
584impl HostLabel {
585    pub const MAX_LEN: usize = 64;
586
587    /// # Errors
588    /// Empty, over-long, or containing anything but ASCII alphanumerics,
589    /// `-`, and `_`; or starting or ending with `-`.
590    pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
591        let trimmed = raw.as_ref().trim();
592        if trimmed.is_empty() {
593            return Err(ValidationError::Empty {
594                what: "a host label",
595            });
596        }
597        if trimmed.len() > Self::MAX_LEN {
598            return Err(ValidationError::TooLong {
599                what: "a host label",
600                max: Self::MAX_LEN,
601                actual: trimmed.len(),
602            });
603        }
604        if let Some(bad) = trimmed
605            .chars()
606            .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
607        {
608            return Err(ValidationError::IllegalCharacter {
609                what: "a host label",
610                found: bad,
611            });
612        }
613        if trimmed.starts_with('-') || trimmed.ends_with('-') {
614            return Err(ValidationError::IllegalEdge {
615                what: "a host label",
616                edge: '-',
617            });
618        }
619        Ok(Self(trimmed.to_ascii_lowercase()))
620    }
621
622    #[must_use]
623    pub fn as_str(&self) -> &str {
624        &self.0
625    }
626}
627
628impl fmt::Display for HostLabel {
629    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
630        f.write_str(&self.0)
631    }
632}
633
634impl TryFrom<String> for HostLabel {
635    type Error = ValidationError;
636
637    fn try_from(value: String) -> Result<Self, Self::Error> {
638        Self::new(value)
639    }
640}
641
642impl From<HostLabel> for String {
643    fn from(value: HostLabel) -> Self {
644        value.0
645    }
646}
647
648impl FromStr for HostLabel {
649    type Err = ValidationError;
650
651    fn from_str(s: &str) -> Result<Self, Self::Err> {
652        Self::new(s)
653    }
654}
655
656// ---------------------------------------------------------------------------
657// NonEmpty
658// ---------------------------------------------------------------------------
659
660/// A collection that cannot be empty.
661///
662/// `04-subsystem-contracts.md` types the routing token as
663/// `Option<NonEmpty<Label>>`, and the `v1` spike shows why the inner
664/// non-emptiness is load-bearing rather than tidy: `generate-jitconfig` with
665/// `labels: []` returns `422 Invalid property /labels: 1 item required`
666/// (`docs/spikes/d18-org-jit-verification.md`, Point 3). An empty label set is
667/// not a degenerate case to handle at the gateway; it is a value the domain
668/// should not be able to hand out.
669///
670/// [`crate::policy::RoutingLabels`] gives a *stronger* guarantee than this type
671/// and is what a policy actually stores; this type is the shape the contract
672/// document names, and [`crate::policy::RoutingLabels::to_non_empty`] produces
673/// it.
674#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
675pub struct NonEmpty<T> {
676    items: Vec<T>,
677}
678
679impl<T> NonEmpty<T> {
680    #[must_use]
681    pub fn of(first: T) -> Self {
682        Self { items: vec![first] }
683    }
684
685    /// # Errors
686    /// [`ValidationError::NonEmptyRequired`] when `items` is empty.
687    pub fn try_from_vec(items: Vec<T>, what: &'static str) -> Result<Self, ValidationError> {
688        if items.is_empty() {
689            return Err(ValidationError::NonEmptyRequired { what });
690        }
691        Ok(Self { items })
692    }
693
694    #[must_use]
695    pub fn first(&self) -> &T {
696        // Safe: no constructor produces an empty `items`.
697        &self.items[0]
698    }
699
700    /// The number of elements. Never zero, which is why this returns
701    /// [`NonZeroUsize`] rather than `usize` and is not called `len`.
702    #[must_use]
703    pub fn count(&self) -> NonZeroUsize {
704        NonZeroUsize::new(self.items.len()).expect("NonEmpty is never empty")
705    }
706
707    pub fn iter(&self) -> std::slice::Iter<'_, T> {
708        self.items.iter()
709    }
710
711    #[must_use]
712    pub fn as_slice(&self) -> &[T] {
713        &self.items
714    }
715
716    pub fn push(&mut self, item: T) {
717        self.items.push(item);
718    }
719
720    #[must_use]
721    pub fn into_vec(self) -> Vec<T> {
722        self.items
723    }
724
725    #[must_use]
726    pub fn contains(&self, needle: &T) -> bool
727    where
728        T: PartialEq,
729    {
730        self.items.contains(needle)
731    }
732}
733
734impl<'a, T> IntoIterator for &'a NonEmpty<T> {
735    type Item = &'a T;
736    type IntoIter = std::slice::Iter<'a, T>;
737
738    fn into_iter(self) -> Self::IntoIter {
739        self.items.iter()
740    }
741}
742
743impl<T> IntoIterator for NonEmpty<T> {
744    type Item = T;
745    type IntoIter = std::vec::IntoIter<T>;
746
747    fn into_iter(self) -> Self::IntoIter {
748        self.items.into_iter()
749    }
750}
751
752// ---------------------------------------------------------------------------
753// Targets
754// ---------------------------------------------------------------------------
755
756/// A GitHub name compared the way GitHub compares it: without regard to case.
757///
758/// The original spelling is preserved for display — an operator who typed
759/// `IvanMurzak/GitHub-Runner-Scaler-UI` should see it back — while `Eq`, `Ord`,
760/// and `Hash` fold case, so `f2`'s duplicate-policy check cannot be defeated by
761/// re-adding the same repository in different capitalisation.
762#[derive(Debug, Clone)]
763struct Name(String);
764
765impl Name {
766    fn as_str(&self) -> &str {
767        &self.0
768    }
769}
770
771impl PartialEq for Name {
772    fn eq(&self, other: &Self) -> bool {
773        self.0.eq_ignore_ascii_case(&other.0)
774    }
775}
776
777impl Eq for Name {}
778
779impl Ord for Name {
780    fn cmp(&self, other: &Self) -> Ordering {
781        self.0
782            .bytes()
783            .map(|b| b.to_ascii_lowercase())
784            .cmp(other.0.bytes().map(|b| b.to_ascii_lowercase()))
785    }
786}
787
788impl PartialOrd for Name {
789    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
790        Some(self.cmp(other))
791    }
792}
793
794impl Hash for Name {
795    fn hash<H: Hasher>(&self, state: &mut H) {
796        for byte in self.0.bytes() {
797            state.write_u8(byte.to_ascii_lowercase());
798        }
799        state.write_u8(0xff);
800    }
801}
802
803impl fmt::Display for Name {
804    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
805        f.write_str(&self.0)
806    }
807}
808
809fn validate_login(raw: &str, what: &'static str) -> Result<Name, ValidationError> {
810    let trimmed = raw.trim();
811    if trimmed.is_empty() {
812        return Err(ValidationError::Empty { what });
813    }
814    if trimmed.len() > 39 {
815        return Err(ValidationError::TooLong {
816            what,
817            max: 39,
818            actual: trimmed.len(),
819        });
820    }
821    if let Some(bad) = trimmed
822        .chars()
823        .find(|c| !(c.is_ascii_alphanumeric() || *c == '-'))
824    {
825        return Err(ValidationError::IllegalCharacter { what, found: bad });
826    }
827    if trimmed.starts_with('-') || trimmed.ends_with('-') {
828        return Err(ValidationError::IllegalEdge { what, edge: '-' });
829    }
830    Ok(Name(trimmed.to_string()))
831}
832
833fn validate_repo_name(raw: &str) -> Result<Name, ValidationError> {
834    const WHAT: &str = "a repository name";
835    let trimmed = raw.trim();
836    if trimmed.is_empty() {
837        return Err(ValidationError::Empty { what: WHAT });
838    }
839    if trimmed.len() > 100 {
840        return Err(ValidationError::TooLong {
841            what: WHAT,
842            max: 100,
843            actual: trimmed.len(),
844        });
845    }
846    if trimmed == "." || trimmed == ".." {
847        return Err(ValidationError::Unrecognised {
848            what: WHAT,
849            got: trimmed.to_string(),
850        });
851    }
852    if let Some(bad) = trimmed
853        .chars()
854        .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')))
855    {
856        return Err(ValidationError::IllegalCharacter {
857            what: WHAT,
858            found: bad,
859        });
860    }
861    Ok(Name(trimmed.to_string()))
862}
863
864/// One repository, as `OWNER/REPO`.
865#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
866#[serde(try_from = "String", into = "String")]
867pub struct OwnerRepo {
868    owner: Name,
869    repo: Name,
870}
871
872impl OwnerRepo {
873    /// # Errors
874    /// Either half failing GitHub's naming rules.
875    pub fn new(owner: impl AsRef<str>, repo: impl AsRef<str>) -> Result<Self, ValidationError> {
876        Ok(Self {
877            owner: validate_login(owner.as_ref(), "a repository owner")?,
878            repo: validate_repo_name(repo.as_ref())?,
879        })
880    }
881
882    /// # Errors
883    /// Input that is not exactly one `owner/repo` pair.
884    pub fn parse(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
885        let raw = raw.as_ref().trim();
886        let slashes = raw.matches('/').count();
887        if slashes != 1 {
888            return Err(ValidationError::MalformedOwnerRepo {
889                got: raw.to_string(),
890                slashes,
891            });
892        }
893        let (owner, repo) = raw.split_once('/').expect("exactly one separator");
894        Self::new(owner, repo)
895    }
896
897    #[must_use]
898    pub fn owner(&self) -> &str {
899        self.owner.as_str()
900    }
901
902    #[must_use]
903    pub fn repo(&self) -> &str {
904        self.repo.as_str()
905    }
906}
907
908impl fmt::Display for OwnerRepo {
909    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
910        write!(f, "{}/{}", self.owner, self.repo)
911    }
912}
913
914impl TryFrom<String> for OwnerRepo {
915    type Error = ValidationError;
916
917    fn try_from(value: String) -> Result<Self, Self::Error> {
918        Self::parse(value)
919    }
920}
921
922impl From<OwnerRepo> for String {
923    fn from(value: OwnerRepo) -> Self {
924        value.to_string()
925    }
926}
927
928impl FromStr for OwnerRepo {
929    type Err = ValidationError;
930
931    fn from_str(s: &str) -> Result<Self, Self::Err> {
932        Self::parse(s)
933    }
934}
935
936/// One organization login.
937#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
938#[serde(try_from = "String", into = "String")]
939pub struct Org(Name);
940
941impl Org {
942    /// # Errors
943    /// Input failing GitHub's login rules.
944    pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
945        Ok(Self(validate_login(raw.as_ref(), "an organization login")?))
946    }
947
948    #[must_use]
949    pub fn as_str(&self) -> &str {
950        self.0.as_str()
951    }
952}
953
954impl fmt::Display for Org {
955    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
956        fmt::Display::fmt(&self.0, f)
957    }
958}
959
960impl TryFrom<String> for Org {
961    type Error = ValidationError;
962
963    fn try_from(value: String) -> Result<Self, Self::Error> {
964        Self::new(value)
965    }
966}
967
968impl From<Org> for String {
969    fn from(value: Org) -> Self {
970        value.to_string()
971    }
972}
973
974impl FromStr for Org {
975    type Err = ValidationError;
976
977    fn from_str(s: &str) -> Result<Self, Self::Err> {
978        Self::new(s)
979    }
980}
981
982/// Which scope a target has. D18's *whole* difference lives in this enum.
983#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
984#[serde(rename_all = "snake_case")]
985pub enum TargetScope {
986    Repository,
987    Organization,
988}
989
990/// What one policy scales for (D18).
991///
992/// `04-subsystem-contracts.md`: "The two differ only in which GitHub endpoints
993/// and which App permission the gateway uses; ownership, capacity, and lifecycle
994/// rules are identical."
995///
996/// That sentence is a design constraint on *this crate*, and it is why nothing
997/// below branches on the variant: there is no `is_repository()` shortcut, no
998/// per-variant capacity rule, and no endpoint string. Endpoints belong to `c3`
999/// and `c4`; the only thing the domain exposes is [`ScaleTarget::scope`], so a
1000/// gateway can select one. `policy::tests::repository_and_organization_targets_
1001/// are_equivalent` runs one body over both variants to keep it that way.
1002#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1003#[serde(tag = "scope", content = "value", rename_all = "snake_case")]
1004pub enum ScaleTarget {
1005    Repository(OwnerRepo),
1006    Organization(Org),
1007}
1008
1009impl ScaleTarget {
1010    /// # Errors
1011    /// A malformed `OWNER/REPO`.
1012    pub fn repository(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1013        Ok(Self::Repository(OwnerRepo::parse(raw)?))
1014    }
1015
1016    /// # Errors
1017    /// A malformed organization login.
1018    pub fn organization(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1019        Ok(Self::Organization(Org::new(raw)?))
1020    }
1021
1022    #[must_use]
1023    pub const fn scope(&self) -> TargetScope {
1024        match self {
1025            ScaleTarget::Repository(_) => TargetScope::Repository,
1026            ScaleTarget::Organization(_) => TargetScope::Organization,
1027        }
1028    }
1029
1030    /// The target as an operator would type it: `owner/repo` or `org`.
1031    #[must_use]
1032    pub fn slug(&self) -> String {
1033        match self {
1034            ScaleTarget::Repository(r) => r.to_string(),
1035            ScaleTarget::Organization(o) => o.to_string(),
1036        }
1037    }
1038}
1039
1040impl fmt::Display for ScaleTarget {
1041    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1042        f.write_str(&self.slug())
1043    }
1044}
1045
1046// ---------------------------------------------------------------------------
1047// Host
1048// ---------------------------------------------------------------------------
1049
1050/// One machine running one agent.
1051///
1052/// `host_capacity` is the ceiling on concurrent runner attempts across **every**
1053/// policy on this machine (D9). It is a [`NonZeroU16`] because a host that
1054/// declares zero capacity is not a configured host, it is a disabled one, and
1055/// the two should not be spelled the same way. [`crate::capacity::HostAllocator`]
1056/// is the only thing that spends it.
1057#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1058pub struct Host {
1059    pub id: HostId,
1060    pub display_name: String,
1061    pub os: Os,
1062    pub architecture: Arch,
1063    pub host_capacity: NonZeroU16,
1064    pub service_start_mode: StartMode,
1065    pub refresh_interval: RefreshInterval,
1066    /// Where disposable runner attempts are created, when the operator has said
1067    /// (D2).
1068    ///
1069    /// `None` means "use the platform default", which is resolved at runtime by
1070    /// `b1` and shown as `platform-default` rather than baked into the row.
1071    /// `02-target-architecture.md`: "Storing only the override allows a future
1072    /// platform-default correction without rewriting every database" — a stored
1073    /// `C:\rman` would silently become the operator's explicit choice the day
1074    /// the default moves.
1075    ///
1076    /// This is **runner** placement and not application data: config, SQLite,
1077    /// logs, diagnostics and the verified package cache stay under `AppPaths`,
1078    /// and `--data-dir` continues to move those and only those (invariant 1).
1079    pub runner_root_override: Option<LocalAbsolutePath>,
1080    pub created_at: Timestamp,
1081}
1082
1083impl Host {
1084    /// # Errors
1085    /// An empty display name.
1086    pub fn new(
1087        id: HostId,
1088        display_name: impl AsRef<str>,
1089        os: Os,
1090        architecture: Arch,
1091        host_capacity: NonZeroU16,
1092        created_at: Timestamp,
1093    ) -> Result<Self, ValidationError> {
1094        let display_name = display_name.as_ref().trim();
1095        if display_name.is_empty() {
1096            return Err(ValidationError::Empty {
1097                what: "a host display name",
1098            });
1099        }
1100        Ok(Self {
1101            id,
1102            display_name: display_name.to_string(),
1103            os,
1104            architecture,
1105            host_capacity,
1106            service_start_mode: StartMode::default(),
1107            refresh_interval: RefreshInterval::default(),
1108            // D3: a newly registered host places attempts under the platform
1109            // default. Nothing but an explicit `host set-runtime-root` fills
1110            // this in.
1111            runner_root_override: None,
1112            created_at,
1113        })
1114    }
1115
1116    #[must_use]
1117    pub fn host_capacity(&self) -> u16 {
1118        self.host_capacity.get()
1119    }
1120
1121    /// Whether the runner root came from the operator rather than the platform.
1122    ///
1123    /// D11 requires CLI and TUI to show "the effective path \[and] configured
1124    /// source"; the effective path needs `b1`'s platform default, but which of
1125    /// the two sources produced it is decidable here, with no I/O.
1126    #[must_use]
1127    pub const fn has_configured_runner_root(&self) -> bool {
1128        self.runner_root_override.is_some()
1129    }
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134    use super::*;
1135
1136    fn ts(secs: i64) -> Timestamp {
1137        chrono::DateTime::from_timestamp(secs, 0).expect("valid timestamp")
1138    }
1139
1140    // -- labels -------------------------------------------------------------
1141
1142    #[test]
1143    fn a_label_is_folded_to_lower_case_because_github_stores_it_that_way() {
1144        // `docs/spikes/d18-org-jit-verification.md`, Point 3, finding 3:
1145        // "`Windows` and `X64` came back as `windows` and `x64`."
1146        assert_eq!(Label::new("Windows").unwrap().as_str(), "windows");
1147        assert_eq!(Label::new("X64").unwrap().as_str(), "x64");
1148        assert_eq!(
1149            Label::new("  RM-Home-Win-X64 ").unwrap().as_str(),
1150            "rm-home-win-x64"
1151        );
1152        assert_eq!(
1153            Label::new("Windows").unwrap(),
1154            Label::new("windows").unwrap()
1155        );
1156    }
1157
1158    #[test]
1159    fn label_case_folding_reaches_eq_ord_and_hash_together() {
1160        use std::collections::BTreeSet;
1161        let mut set = BTreeSet::new();
1162        set.insert(Label::new("Windows").unwrap());
1163        set.insert(Label::new("windows").unwrap());
1164        set.insert(Label::new("WINDOWS").unwrap());
1165        assert_eq!(
1166            set.len(),
1167            1,
1168            "three spellings of one GitHub label must collapse to one member, \
1169             or a routing-label set can silently hold duplicates"
1170        );
1171    }
1172
1173    #[test]
1174    fn an_unusable_label_cannot_be_constructed() {
1175        assert!(matches!(Label::new(""), Err(ValidationError::Empty { .. })));
1176        assert!(matches!(
1177            Label::new("   "),
1178            Err(ValidationError::Empty { .. })
1179        ));
1180        assert!(
1181            matches!(
1182                Label::new("a,b"),
1183                Err(ValidationError::IllegalCharacter { found: ',', .. })
1184            ),
1185            "a comma separates labels in the runner's own configuration, so a \
1186             label containing one does not round-trip"
1187        );
1188        assert!(matches!(
1189            Label::new("a\nb"),
1190            Err(ValidationError::IllegalCharacter { .. })
1191        ));
1192        assert!(matches!(
1193            Label::new("x".repeat(Label::MAX_LEN + 1)),
1194            Err(ValidationError::TooLong { .. })
1195        ));
1196        assert!(Label::new("x".repeat(Label::MAX_LEN)).is_ok());
1197    }
1198
1199    #[test]
1200    fn the_label_character_rules_are_about_round_tripping_not_injection() {
1201        // A quote is not rejected. It has no authority behind it: a Label is
1202        // never interpolated into a shell, so quoting cannot break anything, and
1203        // rejecting quotes while accepting every one of the characters below
1204        // would be a rule that defends nothing while looking like it defends
1205        // something.
1206        assert_eq!(Label::new(r#"say"hi"#).unwrap().as_str(), r#"say"hi"#);
1207        assert_eq!(Label::new("it's").unwrap().as_str(), "it's");
1208
1209        // The characters an injection rule would have to cover, all accepted --
1210        // this is the "neither necessary nor sufficient" half, pinned so that a
1211        // future quote-style rule has to confront it.
1212        for raw in ["a<b", "a>b", "a$b", "a`b", "a;b", "a|b", "a&b", "a b"] {
1213            assert!(
1214                Label::new(raw).is_ok(),
1215                "{raw:?} must construct: the rule set is round-trippability, not \
1216                 shell safety"
1217            );
1218        }
1219
1220        // Real GitHub labels an allow-list would have had to enumerate.
1221        for raw in ["c#", ".net", "x86_64", "ubuntu-22.04"] {
1222            assert!(Label::new(raw).is_ok(), "{raw:?} is a real GitHub label");
1223        }
1224    }
1225
1226    #[test]
1227    fn label_folding_is_ascii_and_the_length_is_measured_after_folding() {
1228        // Consistent with HostLabel and with the target Name type, both of which
1229        // fold with to_ascii_lowercase.
1230        assert_eq!(
1231            Label::new("RM-Home-Win-X64").unwrap().as_str(),
1232            "rm-home-win-x64"
1233        );
1234
1235        // U+0130 lowercases to two chars under Unicode folding. Under ASCII
1236        // folding it is left alone, so a MAX_LEN input stays MAX_LEN and cannot
1237        // exceed the ceiling after construction -- which is what the old
1238        // fold-after-measure order allowed.
1239        let mut raw = "x".repeat(Label::MAX_LEN - 1);
1240        raw.push('\u{0130}');
1241        let label = Label::new(&raw).expect("exactly MAX_LEN characters");
1242        assert_eq!(
1243            label.as_str().chars().count(),
1244            Label::MAX_LEN,
1245            "a constructed Label must never be longer than MAX_LEN"
1246        );
1247    }
1248
1249    #[test]
1250    fn a_host_label_is_narrower_than_a_label() {
1251        assert_eq!(HostLabel::new("Home-Win").unwrap().as_str(), "home-win");
1252        assert!(matches!(
1253            HostLabel::new("home win"),
1254            Err(ValidationError::IllegalCharacter { found: ' ', .. })
1255        ));
1256        assert!(matches!(
1257            HostLabel::new("-home"),
1258            Err(ValidationError::IllegalEdge { edge: '-', .. })
1259        ));
1260        assert!(matches!(
1261            HostLabel::new("home-"),
1262            Err(ValidationError::IllegalEdge { edge: '-', .. })
1263        ));
1264        assert!(matches!(
1265            HostLabel::new(""),
1266            Err(ValidationError::Empty { .. })
1267        ));
1268        assert!(HostLabel::new("home_win2").is_ok());
1269    }
1270
1271    // -- NonEmpty -----------------------------------------------------------
1272
1273    #[test]
1274    fn non_empty_rejects_an_empty_vec_and_reports_count_as_non_zero() {
1275        assert!(matches!(
1276            NonEmpty::<Label>::try_from_vec(Vec::new(), "labels"),
1277            Err(ValidationError::NonEmptyRequired { what: "labels" })
1278        ));
1279
1280        let one = NonEmpty::of(Label::new("a").unwrap());
1281        assert_eq!(one.count().get(), 1);
1282        assert_eq!(one.first().as_str(), "a");
1283
1284        let mut two = one;
1285        two.push(Label::new("b").unwrap());
1286        assert_eq!(two.count().get(), 2);
1287        assert!(two.contains(&Label::new("B").unwrap()));
1288    }
1289
1290    // -- refresh interval ---------------------------------------------------
1291
1292    #[test]
1293    fn the_refresh_interval_floor_is_unrepresentable_rather_than_validated() {
1294        // `04-subsystem-contracts.md`: "default 60 seconds with a hard floor of
1295        // 30 seconds per target".
1296        assert_eq!(RefreshInterval::default().as_secs(), 60);
1297        assert_eq!(RefreshInterval::from_secs(30).unwrap().as_secs(), 30);
1298        assert!(matches!(
1299            RefreshInterval::from_secs(29),
1300            Err(ValidationError::BelowFloor {
1301                min: 30,
1302                actual: 29,
1303                ..
1304            })
1305        ));
1306        assert!(matches!(
1307            RefreshInterval::from_secs(0),
1308            Err(ValidationError::BelowFloor { .. })
1309        ));
1310        // Deserialisation goes through the same gate, so `b2` cannot load a
1311        // hand-edited row that polls every second.
1312        assert!(serde_json::from_str::<RefreshInterval>("29").is_err());
1313        assert_eq!(
1314            serde_json::from_str::<RefreshInterval>("45")
1315                .unwrap()
1316                .as_secs(),
1317            45
1318        );
1319    }
1320
1321    // -- cache policy -------------------------------------------------------
1322
1323    #[test]
1324    fn job_workspace_retention_has_no_representation_in_v1() {
1325        // `04-subsystem-contracts.md`, precedence rule 6.
1326        for policy in [
1327            CachePolicy::RetainRunnerPackage,
1328            CachePolicy::DiscardRunnerPackage,
1329        ] {
1330            assert!(
1331                !policy.retains_job_workspace(),
1332                "no CachePolicy value may retain a job workspace in v1; a \
1333                 retained workspace is the two-job contamination path"
1334            );
1335        }
1336        assert!(CachePolicy::default().retains_runner_package());
1337    }
1338
1339    // -- targets ------------------------------------------------------------
1340
1341    #[test]
1342    fn owner_repo_parsing_accepts_one_separator_and_nothing_else() {
1343        let ok = OwnerRepo::parse("IvanMurzak/GitHub-Runner-Scaler-UI").unwrap();
1344        assert_eq!(ok.owner(), "IvanMurzak");
1345        assert_eq!(ok.repo(), "GitHub-Runner-Scaler-UI");
1346        assert_eq!(ok.to_string(), "IvanMurzak/GitHub-Runner-Scaler-UI");
1347
1348        for bad in ["owner", "owner/repo/extra", "/repo", "owner/"] {
1349            assert!(
1350                OwnerRepo::parse(bad).is_err(),
1351                "{bad:?} must not parse as a repository target"
1352            );
1353        }
1354    }
1355
1356    #[test]
1357    fn github_names_compare_without_regard_to_case_but_display_as_typed() {
1358        use std::collections::HashSet;
1359
1360        let typed = OwnerRepo::parse("IvanMurzak/Repo").unwrap();
1361        let other = OwnerRepo::parse("ivanmurzak/repo").unwrap();
1362        assert_eq!(
1363            typed, other,
1364            "GitHub resolves these to one repository, so `f2`'s duplicate check \
1365             must too"
1366        );
1367        assert_eq!(
1368            typed.to_string(),
1369            "IvanMurzak/Repo",
1370            "the operator's spelling survives for display"
1371        );
1372
1373        let mut seen = HashSet::new();
1374        seen.insert(typed.clone());
1375        assert!(
1376            !seen.insert(other),
1377            "Hash must agree with Eq, or a HashSet-based duplicate check leaks"
1378        );
1379
1380        assert_eq!(
1381            Org::new("Tap-Top-Fun").unwrap(),
1382            Org::new("tap-top-fun").unwrap()
1383        );
1384    }
1385
1386    #[test]
1387    fn a_scale_target_exposes_its_scope_and_nothing_endpoint_shaped() {
1388        let repo = ScaleTarget::repository("o/r").unwrap();
1389        let org = ScaleTarget::organization("o").unwrap();
1390        assert_eq!(repo.scope(), TargetScope::Repository);
1391        assert_eq!(org.scope(), TargetScope::Organization);
1392        assert_eq!(repo.slug(), "o/r");
1393        assert_eq!(org.slug(), "o");
1394    }
1395
1396    #[test]
1397    fn a_scale_target_round_trips_through_serde_at_both_scopes() {
1398        for target in [
1399            ScaleTarget::repository("owner/repo").unwrap(),
1400            ScaleTarget::organization("org").unwrap(),
1401        ] {
1402            let json = serde_json::to_string(&target).unwrap();
1403            let back: ScaleTarget = serde_json::from_str(&json).unwrap();
1404            assert_eq!(target, back, "{json} did not round-trip");
1405        }
1406    }
1407
1408    // -- os / arch ----------------------------------------------------------
1409
1410    #[test]
1411    fn os_and_arch_tokens_are_the_ones_the_worked_example_fixes() {
1412        // `02-target-architecture.md`: "encodes the product, host identity, and
1413        // host OS — for example `rm-home-win-x64`".
1414        assert_eq!(Os::Windows.label_token(), "win");
1415        assert_eq!(Arch::X64.label_token(), "x64");
1416        assert_eq!(Os::MacOs.label_token(), "osx");
1417        assert_eq!(Os::Linux.label_token(), "linux");
1418        assert_eq!(Arch::Arm64.label_token(), "arm64");
1419        assert_eq!(Arch::Arm32.label_token(), "arm");
1420
1421        for os in Os::ALL {
1422            assert_eq!(os.label_token().parse::<Os>().unwrap(), os);
1423        }
1424        for arch in Arch::ALL {
1425            assert_eq!(arch.label_token().parse::<Arch>().unwrap(), arch);
1426        }
1427        assert!("plan9".parse::<Os>().is_err());
1428        assert!("riscv".parse::<Arch>().is_err());
1429    }
1430
1431    #[test]
1432    fn only_linux_supports_container_actions_and_only_arm64_is_preview() {
1433        // `01-current-architecture.md`, edge case 2 and the architecture row.
1434        assert!(Os::Linux.supports_container_actions());
1435        assert!(!Os::Windows.supports_container_actions());
1436        assert!(!Os::MacOs.supports_container_actions());
1437
1438        assert!(Arch::Arm64.is_public_preview());
1439        assert!(!Arch::X64.is_public_preview());
1440    }
1441
1442    // -- host ---------------------------------------------------------------
1443
1444    #[test]
1445    fn a_host_cannot_be_built_with_zero_capacity_or_a_blank_name() {
1446        assert!(
1447            NonZeroU16::new(0).is_none(),
1448            "zero host capacity is unrepresentable"
1449        );
1450        assert!(matches!(
1451            Host::new(
1452                HostId::from_u128(1),
1453                "  ",
1454                Os::Windows,
1455                Arch::X64,
1456                NonZeroU16::new(2).unwrap(),
1457                ts(0),
1458            ),
1459            Err(ValidationError::Empty { .. })
1460        ));
1461
1462        let host = Host::new(
1463            HostId::from_u128(1),
1464            " home-pc ",
1465            Os::Windows,
1466            Arch::X64,
1467            NonZeroU16::new(2).unwrap(),
1468            ts(0),
1469        )
1470        .unwrap();
1471        assert_eq!(host.display_name, "home-pc");
1472        assert_eq!(host.host_capacity(), 2);
1473        assert_eq!(host.service_start_mode, StartMode::Boot);
1474        assert_eq!(host.refresh_interval.as_secs(), 60);
1475    }
1476
1477    // -- runner root --------------------------------------------------------
1478
1479    fn a_host() -> Host {
1480        Host::new(
1481            HostId::from_u128(1),
1482            "home-pc",
1483            Os::Windows,
1484            Arch::X64,
1485            NonZeroU16::new(2).unwrap(),
1486            ts(0),
1487        )
1488        .expect("a valid host")
1489    }
1490
1491    #[test]
1492    fn a_new_host_uses_the_platform_default_runner_root() {
1493        // D3: nothing but an explicit `host set-runtime-root` configures one, so
1494        // a host registered by this build behaves exactly as it did before the
1495        // setting existed.
1496        let host = a_host();
1497        assert_eq!(host.runner_root_override, None);
1498        assert!(!host.has_configured_runner_root());
1499    }
1500
1501    #[test]
1502    fn a_configured_runner_root_round_trips_through_serde() {
1503        let root = LocalAbsolutePath::new(if cfg!(windows) {
1504            "C:\\rman"
1505        } else {
1506            "/srv/rman"
1507        })
1508        .expect("a valid native root");
1509        let mut host = a_host();
1510        host.runner_root_override = Some(root.clone());
1511        assert!(host.has_configured_runner_root());
1512
1513        let encoded = serde_json::to_string(&host).expect("serialisable");
1514        let decoded: Host = serde_json::from_str(&encoded).expect("deserialisable");
1515        assert_eq!(decoded, host);
1516        assert_eq!(decoded.runner_root_override, Some(root));
1517        // Placement is not authentication: the host record carries no credential
1518        // before this change and must carry none after it.
1519        for needle in ["token", "secret", "password"] {
1520            assert!(
1521                !encoded.to_ascii_lowercase().contains(needle),
1522                "the host record leaked {needle:?}: {encoded}"
1523            );
1524        }
1525    }
1526
1527    #[test]
1528    fn a_host_row_carrying_an_illegal_runner_root_fails_closed() {
1529        // The stored shape is re-validated by `LocalAbsolutePath`'s own
1530        // deserializer, so a hand-edited network share never becomes a runner
1531        // root (D10).
1532        let host = a_host();
1533        let encoded = serde_json::to_string(&host).expect("serialisable");
1534        let corrupted = encoded.replace(
1535            "\"runner_root_override\":null",
1536            "\"runner_root_override\":\"rman\"",
1537        );
1538        assert_ne!(corrupted, encoded, "the fixture must actually be corrupted");
1539        assert!(serde_json::from_str::<Host>(&corrupted).is_err());
1540    }
1541
1542    #[test]
1543    fn a_fake_clock_is_a_complete_substitute_for_the_system_clock() {
1544        // Proves the port is object safe and that nothing here needs the real
1545        // one; `crate::attempt` is where it actually matters.
1546        #[derive(Debug)]
1547        struct Fixed(Timestamp);
1548        impl Clock for Fixed {
1549            fn now(&self) -> Timestamp {
1550                self.0
1551            }
1552        }
1553        let clock: &dyn Clock = &Fixed(ts(1_700_000_000));
1554        assert_eq!(clock.now(), ts(1_700_000_000));
1555    }
1556}