Skip to main content

cargoless_proto/
lib.rs

1//! `cargoless-proto` — the cross-crate contract for cargoless.
2//!
3//! This crate is the seam the daemon (`watcher`/`analyzer`/`model`), the build
4//! pipeline + CAS (`build`/`cargoless-cas`), the dev server (`server`), the CLI, and
5//! future remote backends communicate through. Cross-boundary data flows as
6//! these types; nobody reaches across a module boundary with a direct call.
7//! Authoring this jointly and freezing it early is the whole point of Plane
8//! **CWDL-19 (D8)** — the two-engineer split silently diverges otherwise.
9//!
10//! ## Why dependency-free and serde-free in v0 (decision of record)
11//!
12//! v0 is single-machine, single-process: every consumer of these types links
13//! `cargoless-proto` directly and passes them in-memory (channels / function args).
14//! Nothing crosses a process or network boundary, so nothing needs to be
15//! serialized. Adding `serde` now would (a) put a non-trivial dependency in the
16//! crate every other crate depends on, slowing the cold build that AC#1/#2 are
17//! measured against, and (b) freeze a wire format we have no v0 consumer for.
18//!
19//! When a boundary genuinely needs serialization (the dev-server↔browser reload
20//! channel speaks WebSocket — decision **D3** — and remote CAS is a v1 want),
21//! the owning crate adds `serde` here behind an off-by-default `serde` feature
22//! and derives it on exactly the types that cross that boundary. The contract
23//! shapes below are designed so that bolt-on is additive, never a reshape.
24//!
25//! ## The data-flow at a glance
26//!
27//! ```text
28//!   watcher → analyzer → model ──StateEvent──▶ everyone (verdict stream)
29//!                          │
30//!                          └─on BecameGreen──▶ BuildTrigger ─▶ build/CAS
31//!                                                                  │
32//!                          server ◀──BuildResult── build/CAS ◀─────┘
33//! ```
34//!
35//! The model is the single source of truth for "what works"; the build/CAS
36//! layer is the single source of truth for "is this exact input already
37//! built". Everything else subscribes.
38
39#![forbid(unsafe_code)]
40
41use core::fmt;
42
43// ---------------------------------------------------------------------------
44// Content identity
45// ---------------------------------------------------------------------------
46
47/// An opaque content hash, rendered as a hex string.
48///
49/// The *algorithm* (blake3, sha256, …) and the *hashing implementation* are
50/// deliberately **not** part of this contract — they belong to the CAS owner
51/// (`cargoless-cas`). `cargoless-proto` only carries the resulting identity so producers and
52/// consumers agree on what equality means without agreeing on how it is
53/// computed. Comparison is byte-exact on the hex string.
54#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
55pub struct ContentHash(String);
56
57impl ContentHash {
58    pub fn new(hex: impl Into<String>) -> Self {
59        Self(hex.into())
60    }
61
62    pub fn as_str(&self) -> &str {
63        &self.0
64    }
65}
66
67impl fmt::Display for ContentHash {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.write_str(&self.0)
70    }
71}
72
73/// The target triple a build is produced for (e.g. `wasm32-unknown-unknown`).
74///
75/// A newtype rather than a bare `String` so it cannot be transposed with the
76/// profile or a path at a call site.
77#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
78pub struct TargetTriple(String);
79
80impl TargetTriple {
81    pub fn new(triple: impl Into<String>) -> Self {
82        Self(triple.into())
83    }
84
85    pub fn as_str(&self) -> &str {
86        &self.0
87    }
88}
89
90impl fmt::Display for TargetTriple {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        f.write_str(&self.0)
93    }
94}
95
96/// Cargo build profile. v0 inner-loop builds are always [`Profile::Dev`]
97/// (workspace `[profile.dev]` pins `opt-level = 0`, no `wasm-opt`, per the
98/// AC#3 latency constraint); [`Profile::Release`] exists in the contract so
99/// the identity is honest and a release build can never alias a dev artifact.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
101pub enum Profile {
102    Dev,
103    Release,
104}
105
106impl Profile {
107    /// Cargo's name for this profile (`dev` / `release`).
108    pub fn as_str(self) -> &'static str {
109        match self {
110            Profile::Dev => "dev",
111            Profile::Release => "release",
112        }
113    }
114}
115
116impl fmt::Display for Profile {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        f.write_str(self.as_str())
119    }
120}
121
122/// The complete set of inputs whose identity determines a build artifact.
123///
124/// This is the dedupe key behind **AC#5** (identical source state ⇒ cache hit,
125/// build skipped) and the provenance record behind **AC#4** (never serve red:
126/// the server only ever swaps to an artifact whose `BuildIdentity` it can name).
127/// Each component is carried as its own type so the contract is explicit about
128/// *what* makes a build distinct; folding these into the single [`InputHash`]
129/// CAS key is the CAS owner's job and is intentionally not specified here.
130///
131/// Two builds with an `Eq` `BuildIdentity` MUST be substitutable. If a real
132/// input is not represented here, identical-key collisions become wrong-artifact
133/// bugs — so additions to this struct are a deliberate contract change, not an
134/// implementation detail.
135#[derive(Debug, Clone, PartialEq, Eq, Hash)]
136pub struct BuildIdentity {
137    /// Hash over every tracked source file in the crate/workspace tree.
138    pub source_tree: ContentHash,
139    /// Hash of `Cargo.lock` — pins the exact resolved dependency graph.
140    pub cargo_lock: ContentHash,
141    /// Hash of the resolved Rust toolchain (`rust-toolchain.toml` content /
142    /// pinned channel + version). A toolchain bump must invalidate the cache.
143    pub rust_toolchain: ContentHash,
144    /// Hash of the cargoless config file (`tf.toml`, decision **D6**). Config
145    /// changes the build, so it is part of the identity.
146    pub tf_config: ContentHash,
147    /// The target triple (typically `wasm32-unknown-unknown`).
148    pub target: TargetTriple,
149    /// The cargo profile (always [`Profile::Dev`] for the v0 inner loop).
150    pub profile: Profile,
151}
152
153/// The CAS key: the single digest derived from a [`BuildIdentity`].
154///
155/// Opaque newtype so a caller cannot pass a raw string where a verified key is
156/// expected. The reduction `BuildIdentity → InputHash` is performed by the CAS
157/// owner; `cargoless-proto` only guarantees that equal `BuildIdentity` ⇒ equal
158/// `InputHash` is the invariant every consumer may rely on.
159#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
160pub struct InputHash(String);
161
162impl InputHash {
163    pub fn new(hex: impl Into<String>) -> Self {
164        Self(hex.into())
165    }
166
167    pub fn as_str(&self) -> &str {
168        &self.0
169    }
170}
171
172impl fmt::Display for InputHash {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        f.write_str(&self.0)
175    }
176}
177
178// ---------------------------------------------------------------------------
179// Green/red state model
180// ---------------------------------------------------------------------------
181
182/// Per-file compile verdict.
183///
184/// v0 granularity is **file-level** (decision **D4**). Symbol-level tracking is
185/// what rust-analyzer does internally and is an explicit v1 want — out of v0 by
186/// construction. The verdict itself *is* the signal here; a `Red` deliberately
187/// carries no diagnostic payload in v0 so this type stays `Copy` and
188/// dependency-free. Human-readable detail is the daemon/CLI's job to surface
189/// from its own analyzer state, not something every contract consumer must
190/// thread through.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
192pub enum FileState {
193    Green,
194    Red,
195}
196
197/// Aggregate verdict for the whole watched tree.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
199pub enum TreeState {
200    /// Every tracked file is green — safe to build and serve.
201    Green,
202    /// At least one tracked file is red — keep serving last-green (AC#4).
203    Red,
204}
205
206/// The event stream emitted by the daemon's green/red model. Every other
207/// subsystem *subscribes* to this; nothing calls the model directly.
208///
209/// Two flavours, deliberately distinct:
210/// * [`FileVerdict`](StateEvent::FileVerdict) — level: "this file is now X".
211///   Idempotent; fine to re-emit the same state.
212/// * [`BecameGreen`](StateEvent::BecameGreen) /
213///   [`BecameRed`](StateEvent::BecameRed) — *edges*: the tree just crossed the
214///   green⇄red boundary. These are the latency-to-signal events the product is
215///   built around ("tells you the moment it doesn't"): `BecameRed` is the
216///   instant the server must freeze on last-green; `BecameGreen` is the only
217///   thing that may trigger a build.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub enum StateEvent {
220    /// A single file's verdict (re)settled. Level-triggered.
221    FileVerdict { path: String, state: FileState },
222    /// The tree transitioned red → green. Carries the identity of the now-green
223    /// input set so the build can be triggered without a second round-trip to
224    /// the model. Edge-triggered: emitted once per crossing.
225    BecameGreen { identity: BuildIdentity },
226    /// The tree transitioned green → red. The dev server must immediately stop
227    /// advancing and keep serving the last green artifact. Edge-triggered.
228    BecameRed,
229}
230
231// ---------------------------------------------------------------------------
232// Build trigger / result
233// ---------------------------------------------------------------------------
234
235/// Sent by the daemon to the build/CAS layer to request that a green input set
236/// be made servable. The only legitimate cause of a `BuildTrigger` is a
237/// [`StateEvent::BecameGreen`] — red inputs are never built (AC#4).
238///
239/// It carries the full [`BuildIdentity`] (not just the derived [`InputHash`])
240/// so the CAS can both compute its key *and* persist honest provenance for the
241/// resulting [`ArtifactMeta`].
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct BuildTrigger {
244    pub identity: BuildIdentity,
245}
246
247/// What the build/CAS layer did with a [`BuildTrigger`].
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub enum BuildOutcome {
250    /// The input set was already in the CAS — no compile ran. This variant
251    /// existing and being observable is what proves **AC#5**.
252    Deduplicated,
253    /// A fresh compile produced the artifact.
254    Compiled,
255    /// The build failed despite a green verdict (e.g. a toolchain/link error
256    /// the analyzer cannot see). The server keeps serving last-green; the
257    /// `reason` is a human-readable one-liner for the CLI/log, not a structured
258    /// diagnostic (kept dependency-free and v0-simple, like [`FileState`]).
259    Failed { reason: String },
260}
261
262impl BuildOutcome {
263    /// Did this outcome yield a servable artifact?
264    pub fn is_servable(&self) -> bool {
265        matches!(self, BuildOutcome::Deduplicated | BuildOutcome::Compiled)
266    }
267}
268
269/// Metadata persisted alongside every cached artifact in the CAS, and the
270/// payload the dev server consumes to decide whether to hot-reload the browser.
271///
272/// Holds the full [`BuildIdentity`] (provenance: answers "what exactly is this")
273/// plus the derived [`InputHash`] (the CAS key it is stored under).
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct ArtifactMeta {
276    /// The CAS key this artifact is stored under.
277    pub input_hash: InputHash,
278    /// The full input identity that produced it (provenance).
279    pub identity: BuildIdentity,
280}
281
282/// Returned by the build/CAS layer for each [`BuildTrigger`]; consumed by the
283/// daemon (logging/state) and the dev server (reload decision — decisions
284/// **D3** WebSocket signaling and **D5** full-reload-not-hot-swap govern *how*
285/// the browser is told, not this contract).
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct BuildResult {
288    pub outcome: BuildOutcome,
289    /// Present iff [`BuildOutcome::is_servable`] — the artifact the server may
290    /// now advance to. `None` on `Failed`, where the server holds last-green.
291    pub artifact: Option<ArtifactMeta>,
292}
293
294// ---------------------------------------------------------------------------
295// Diagnostics — additive CLI-facing surface (FIELD FINDING #2 fix)
296//
297// The boolean `TreeState` answers "should the publisher advance?" (AC#4 —
298// load-bearing for v0 and STAYS BYTE-FROZEN); but a user staring at a red
299// tree needs to know *which* file, *which* line, *what* the rustc said. The
300// existing frozen seams (`StateEvent` / `TreeState` / `BuildTrigger` /
301// `BuildResult` / `ArtifactMeta`) deliberately carry no diagnostic payload —
302// adding one in place would break every wired consumer. So this is an
303// ADJACENT, additive surface: a parallel rich verdict the CLI may opt into
304// without touching the existing API anyone else binds to.
305//
306// Same discipline as the latest-green publisher: serde-free, no new deps,
307// the existing types are unchanged. Pairing the boolean tree with the
308// diagnostic list is what restores the README promise that "the codebase
309// always knows what works, *and tells you the moment it doesn't*".
310// ---------------------------------------------------------------------------
311
312/// Diagnostic severity, derived from the LSP `DiagnosticSeverity` integers
313/// (1=Error, 2=Warning, 3=Information, 4=Hint). A typed enum so the CLI
314/// renders `error`/`warning`/`info`/`hint` headers without re-deriving from
315/// raw numbers, and so a future consumer can pattern-match exhaustively.
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
317pub enum Severity {
318    Error,
319    Warning,
320    Info,
321    Hint,
322}
323
324impl Severity {
325    /// Lowercase tag used by `rustc`-style display (`error[E0277]: …`).
326    pub fn as_str(self) -> &'static str {
327        match self {
328            Severity::Error => "error",
329            Severity::Warning => "warning",
330            Severity::Info => "info",
331            Severity::Hint => "hint",
332        }
333    }
334}
335
336impl fmt::Display for Severity {
337    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
338        f.write_str(self.as_str())
339    }
340}
341
342/// One actionable diagnostic, surfaced by the LSP layer and aggregated by
343/// the daemon model. Carries the absolute file path, 1-based (file:line:col)
344/// position, severity, optional compiler/lint code, the human message, and
345/// the diagnostic `source` string verbatim (`"rustc"` for cargo-check
346/// authoritative, `"rust-analyzer"` for native advisory, anything else for
347/// future tiers) so the CLI can both show provenance and the model can keep
348/// classifying authoritative-vs-advisory off the same value.
349///
350/// Additive alongside the frozen `StateEvent`/`TreeState`/`check_once`
351/// surfaces — those keep their byte-frozen shapes; this is the parallel
352/// detail channel the CLI subscribes to.
353#[derive(Debug, Clone, PartialEq, Eq, Hash)]
354pub struct Diagnostic {
355    /// Absolute file path as reported by rust-analyzer (`file://` URI
356    /// stripped to its path). `PathBuf` (not `String`) so callers can render
357    /// it relative to the project root with `Path::strip_prefix` without
358    /// re-parsing.
359    pub file_path: std::path::PathBuf,
360    /// 1-based line number. The LSP wire is 0-based; the extraction
361    /// converts at the boundary so every consumer sees the same convention
362    /// (matches `cargo`/`rustc` display).
363    pub line: u32,
364    /// 1-based column number, same convention as `line`.
365    pub col: u32,
366    /// LSP severity, mapped to [`Severity`].
367    pub severity: Severity,
368    /// Diagnostic code, e.g. `"E0277"` (rustc) or `"unused_imports"` (lint).
369    /// `None` when the LSP omitted it (some advisory native diagnostics).
370    pub code: Option<String>,
371    /// Human-readable message text exactly as reported by the LSP. May be
372    /// multi-line — the CLI renderer is responsible for any indentation.
373    pub message: String,
374    /// `source` field verbatim — `"rustc"` for cargo-check authoritative
375    /// diagnostics, `"rust-analyzer"` for native advisory. `None` if the
376    /// LSP did not tag the diagnostic with a source.
377    pub source: Option<String>,
378}
379
380/// The rich one-shot check verdict: the existing boolean [`TreeState`] paired
381/// with the full diagnostic list a user needs to fix a red tree. Returned by
382/// the adjacent `cargoless_core::model::check_once_with_diagnostics`; existing
383/// callers of [`TreeState`]-returning APIs (`check_once`, frozen for cli-ux
384/// and the bench harness) are byte-unaffected.
385#[derive(Debug, Clone, PartialEq, Eq)]
386pub struct CheckResult {
387    /// The same authoritative tree verdict `check_once` would return.
388    pub tree: TreeState,
389    /// Every diagnostic the model knew about at the moment the verdict was
390    /// finalised — every severity, both rustc-authoritative and RA-advisory.
391    /// The CLI renderer is free to filter (e.g. errors-only) by `severity`
392    /// and/or `source`.
393    pub diagnostics: Vec<Diagnostic>,
394}
395
396// ---------------------------------------------------------------------------
397// Latest-green publisher seam (the ONLY additive v0 surface — D-A1 / AC#4)
398// ---------------------------------------------------------------------------
399
400/// Wall-clock seconds since the Unix epoch (UTC). A newtype so a timestamp
401/// cannot be transposed with any other `u64` at a call site. `cargoless-proto` is
402/// deliberately dependency-free, so there is no `chrono`/`time` here: the
403/// producer (`cargoless-core::build`) fills this from `std::time::SystemTime`; this
404/// crate only carries the value.
405#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
406pub struct UnixSeconds(pub u64);
407
408impl fmt::Display for UnixSeconds {
409    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410        write!(f, "{}", self.0)
411    }
412}
413
414/// The latest-green publisher record (decision D-A1; AC#4 "never publish
415/// red"). The build/CAS layer writes this beside the canonical pointer file
416/// `.cargoless/latest-green` on every servable green build; the CLI `status`
417/// reads it back. This is the **only additive v0 contract surface** — it does
418/// not touch the four frozen seams (`StateEvent` / `BuildTrigger` /
419/// `BuildResult` / `ArtifactMeta`) and adds no dependency: the on-disk form is
420/// a hand-rolled, versioned text codec ([`render`](Self::render) /
421/// [`parse`](Self::parse)).
422#[derive(Debug, Clone, PartialEq, Eq)]
423pub struct PublishedArtifact {
424    /// What was published: the CAS key + full input provenance. `profile` and
425    /// `target` live inside `artifact.identity` — not duplicated here.
426    pub artifact: ArtifactMeta,
427    /// When the pointer was advanced to this artifact.
428    pub published_at: UnixSeconds,
429}
430
431/// Returned by [`PublishedArtifact::parse`] when the pointer file is not the
432/// expected `cargoless-latest-green/v1` shape. Dependency-free (no
433/// `thiserror`); a corrupt pointer is treated as "no green yet", never
434/// half-decoded into a wrong artifact.
435#[derive(Debug, Clone, PartialEq, Eq)]
436pub struct PointerFormatError(pub String);
437
438impl fmt::Display for PointerFormatError {
439    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
440        write!(f, "invalid latest-green pointer: {}", self.0)
441    }
442}
443
444impl std::error::Error for PointerFormatError {}
445
446/// The frozen on-disk schema version. Bumping it is a deliberate, repo-visible
447/// contract change — old pointer files then fail [`parse`](PublishedArtifact::parse)
448/// loudly rather than being silently misread.
449const POINTER_SCHEME: &str = "cargoless-latest-green/v1";
450
451impl PublishedArtifact {
452    /// Serialize to the canonical pointer-file text: a scheme-version header
453    /// line, then `key=value` lines. Deliberately flat and human-inspectable
454    /// (the nested type is the in-memory contract; the file is its faithful,
455    /// stable projection). Every value (hex hash, target triple,
456    /// `dev`/`release`, decimal `u64`) is free of `=`/newline, so the framing
457    /// is unambiguous.
458    pub fn render(&self) -> String {
459        use core::fmt::Write as _;
460        let id = &self.artifact.identity;
461        let mut s = String::new();
462        s.push_str(POINTER_SCHEME);
463        s.push('\n');
464        // Infallible: writing to a String never errors.
465        let _ = writeln!(s, "input_hash={}", self.artifact.input_hash.as_str());
466        let _ = writeln!(s, "source_tree={}", id.source_tree.as_str());
467        let _ = writeln!(s, "cargo_lock={}", id.cargo_lock.as_str());
468        let _ = writeln!(s, "rust_toolchain={}", id.rust_toolchain.as_str());
469        let _ = writeln!(s, "tf_config={}", id.tf_config.as_str());
470        let _ = writeln!(s, "target={}", id.target.as_str());
471        let _ = writeln!(s, "profile={}", id.profile.as_str());
472        let _ = writeln!(s, "published_at={}", self.published_at.0);
473        s
474    }
475
476    /// Inverse of [`render`](Self::render). Strict: wrong header, a missing
477    /// key, a non-numeric timestamp, or an unknown profile all ⇒ `Err`.
478    pub fn parse(text: &str) -> Result<Self, PointerFormatError> {
479        let err = |m: &str| PointerFormatError(m.to_string());
480        let mut lines = text.lines();
481        match lines.next() {
482            Some(h) if h == POINTER_SCHEME => {}
483            _ => return Err(err("missing or unknown scheme header")),
484        }
485        let mut map: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
486        for line in lines {
487            if line.is_empty() {
488                continue;
489            }
490            let (k, v) = line
491                .split_once('=')
492                .ok_or_else(|| err("line is not key=value"))?;
493            map.insert(k.to_string(), v.to_string());
494        }
495        let get = |k: &str| -> Result<String, PointerFormatError> {
496            map.get(k)
497                .cloned()
498                .ok_or_else(|| err(&format!("missing key `{k}`")))
499        };
500        let profile = match get("profile")?.as_str() {
501            "dev" => Profile::Dev,
502            "release" => Profile::Release,
503            other => return Err(err(&format!("unknown profile `{other}`"))),
504        };
505        let published_at = get("published_at")?
506            .parse::<u64>()
507            .map_err(|_| err("published_at is not a u64"))?;
508        Ok(Self {
509            artifact: ArtifactMeta {
510                input_hash: InputHash::new(get("input_hash")?),
511                identity: BuildIdentity {
512                    source_tree: ContentHash::new(get("source_tree")?),
513                    cargo_lock: ContentHash::new(get("cargo_lock")?),
514                    rust_toolchain: ContentHash::new(get("rust_toolchain")?),
515                    tf_config: ContentHash::new(get("tf_config")?),
516                    target: TargetTriple::new(get("target")?),
517                    profile,
518                },
519            },
520            published_at: UnixSeconds(published_at),
521        })
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    fn sample_identity() -> BuildIdentity {
530        BuildIdentity {
531            source_tree: ContentHash::new("aaaa"),
532            cargo_lock: ContentHash::new("bbbb"),
533            rust_toolchain: ContentHash::new("cccc"),
534            tf_config: ContentHash::new("dddd"),
535            target: TargetTriple::new("wasm32-unknown-unknown"),
536            profile: Profile::Dev,
537        }
538    }
539
540    #[test]
541    fn input_hash_roundtrips_and_displays() {
542        let h = InputHash::new("deadbeef");
543        assert_eq!(h.as_str(), "deadbeef");
544        assert_eq!(h, InputHash::new("deadbeef".to_string()));
545        assert_eq!(h.to_string(), "deadbeef");
546    }
547
548    #[test]
549    fn identity_equality_is_componentwise() {
550        let a = sample_identity();
551        let b = sample_identity();
552        assert_eq!(
553            a, b,
554            "equal components ⇒ equal identity (the AC#5 invariant)"
555        );
556
557        let mut c = sample_identity();
558        c.profile = Profile::Release;
559        assert_ne!(a, c, "a release build must never alias a dev artifact");
560
561        let mut d = sample_identity();
562        d.source_tree = ContentHash::new("ffff");
563        assert_ne!(a, d, "a source change must invalidate the cache key");
564    }
565
566    #[test]
567    fn became_green_carries_identity_for_one_shot_build_trigger() {
568        let ev = StateEvent::BecameGreen {
569            identity: sample_identity(),
570        };
571        match ev {
572            StateEvent::BecameGreen { identity } => {
573                let trigger = BuildTrigger { identity };
574                assert_eq!(trigger.identity, sample_identity());
575            }
576            _ => unreachable!(),
577        }
578    }
579
580    #[test]
581    fn state_events_are_distinct() {
582        assert_ne!(
583            StateEvent::BecameRed,
584            StateEvent::BecameGreen {
585                identity: sample_identity()
586            }
587        );
588        let v = StateEvent::FileVerdict {
589            path: "src/lib.rs".into(),
590            state: FileState::Red,
591        };
592        assert_ne!(v, StateEvent::BecameRed);
593    }
594
595    #[test]
596    fn outcome_servability_drives_artifact_presence() {
597        assert!(BuildOutcome::Deduplicated.is_servable());
598        assert!(BuildOutcome::Compiled.is_servable());
599        assert!(
600            !BuildOutcome::Failed {
601                reason: "linker exploded".into()
602            }
603            .is_servable()
604        );
605
606        let ok = BuildResult {
607            outcome: BuildOutcome::Compiled,
608            artifact: Some(ArtifactMeta {
609                input_hash: InputHash::new("0123"),
610                identity: sample_identity(),
611            }),
612        };
613        assert!(ok.outcome.is_servable() && ok.artifact.is_some());
614
615        let bad = BuildResult {
616            outcome: BuildOutcome::Failed {
617                reason: "rustc ICE".into(),
618            },
619            artifact: None,
620        };
621        assert!(!bad.outcome.is_servable() && bad.artifact.is_none());
622    }
623
624    #[test]
625    fn profile_and_tree_state_render() {
626        assert_eq!(Profile::Dev.as_str(), "dev");
627        assert_eq!(Profile::Release.to_string(), "release");
628        assert_ne!(TreeState::Green, TreeState::Red);
629    }
630
631    fn sample_published() -> PublishedArtifact {
632        PublishedArtifact {
633            artifact: ArtifactMeta {
634                input_hash: InputHash::new("0123abcd"),
635                identity: sample_identity(),
636            },
637            published_at: UnixSeconds(1_747_000_000),
638        }
639    }
640
641    #[test]
642    fn published_artifact_round_trips_through_the_pointer_codec() {
643        let p = sample_published();
644        let text = p.render();
645        // Human-inspectable, versioned, flat.
646        assert!(text.starts_with("cargoless-latest-green/v1\n"));
647        assert!(text.contains("input_hash=0123abcd\n"));
648        assert!(text.contains("profile=dev\n"));
649        assert!(text.contains("published_at=1747000000\n"));
650        // Exact inverse: parse(render(x)) == x (the producer/reader contract).
651        assert_eq!(PublishedArtifact::parse(&text).unwrap(), p);
652    }
653
654    #[test]
655    fn pointer_parse_is_strict() {
656        // Wrong/absent header ⇒ Err (never a half-decoded artifact).
657        assert!(PublishedArtifact::parse("").is_err());
658        assert!(PublishedArtifact::parse("not-a-pointer\ninput_hash=x\n").is_err());
659        // Missing a required key ⇒ Err.
660        assert!(PublishedArtifact::parse("cargoless-latest-green/v1\ninput_hash=x\n").is_err());
661        // Unknown profile / non-numeric timestamp ⇒ Err.
662        let mut bad = sample_published()
663            .render()
664            .replace("profile=dev", "profile=fast");
665        assert!(PublishedArtifact::parse(&bad).is_err());
666        bad = sample_published()
667            .render()
668            .replace("published_at=1747000000", "published_at=soon");
669        assert!(PublishedArtifact::parse(&bad).is_err());
670    }
671
672    #[test]
673    fn unix_seconds_is_a_distinct_newtype() {
674        assert_eq!(UnixSeconds(42).to_string(), "42");
675        assert!(UnixSeconds(1) < UnixSeconds(2));
676        assert_eq!(UnixSeconds(7), UnixSeconds(7));
677    }
678
679    // -----------------------------------------------------------------------
680    // Diagnostics — additive CLI-facing surface (FIELD FINDING #2 fix)
681    // -----------------------------------------------------------------------
682
683    #[test]
684    fn severity_renders_lowercase_and_is_exhaustive() {
685        assert_eq!(Severity::Error.as_str(), "error");
686        assert_eq!(Severity::Warning.as_str(), "warning");
687        assert_eq!(Severity::Info.as_str(), "info");
688        assert_eq!(Severity::Hint.as_str(), "hint");
689        assert_eq!(Severity::Error.to_string(), "error");
690        // Distinct values — the four variants do not collide.
691        let s: std::collections::BTreeSet<_> = [
692            Severity::Error,
693            Severity::Warning,
694            Severity::Info,
695            Severity::Hint,
696        ]
697        .into_iter()
698        .collect();
699        assert_eq!(s.len(), 4);
700    }
701
702    #[test]
703    fn diagnostic_carries_position_code_and_source() {
704        let d = Diagnostic {
705            file_path: std::path::PathBuf::from("/repo/src/lib.rs"),
706            line: 42,
707            col: 5,
708            severity: Severity::Error,
709            code: Some("E0277".to_string()),
710            message: "the trait bound `T: Foo` is not satisfied".to_string(),
711            source: Some("rustc".to_string()),
712        };
713        // Field accesses (the CLI's binding surface) compile and round-trip.
714        assert_eq!(d.line, 42);
715        assert_eq!(d.col, 5);
716        assert_eq!(d.code.as_deref(), Some("E0277"));
717        assert_eq!(d.source.as_deref(), Some("rustc"));
718        assert_eq!(d.severity, Severity::Error);
719        assert!(d.message.contains("trait bound"));
720        // Eq is value-equality (the AC: two identical diagnostics dedupe).
721        let d2 = d.clone();
722        assert_eq!(d, d2);
723    }
724
725    #[test]
726    fn check_result_pairs_tree_with_diagnostics() {
727        // Empty diagnostics + green = the canonical "happy path".
728        let green = CheckResult {
729            tree: TreeState::Green,
730            diagnostics: Vec::new(),
731        };
732        assert_eq!(green.tree, TreeState::Green);
733        assert!(green.diagnostics.is_empty());
734
735        // Red verdict ⇒ at least one diagnostic the CLI will print. The
736        // FIELD FINDING #2 invariant: a red tree carries its evidence.
737        let red = CheckResult {
738            tree: TreeState::Red,
739            diagnostics: vec![Diagnostic {
740                file_path: std::path::PathBuf::from("/r/src/lib.rs"),
741                line: 1,
742                col: 1,
743                severity: Severity::Error,
744                code: Some("E0599".to_string()),
745                message: "no method named `frob` found".to_string(),
746                source: Some("rustc".to_string()),
747            }],
748        };
749        assert_eq!(red.tree, TreeState::Red);
750        assert_eq!(red.diagnostics.len(), 1);
751        assert_eq!(red.diagnostics[0].code.as_deref(), Some("E0599"));
752    }
753
754    #[test]
755    fn diagnostic_path_is_relativisable_against_a_root() {
756        // The CLI takes the absolute file_path and renders relative to the
757        // project root with std::path::Path::strip_prefix — verify the shape
758        // supports that (PathBuf, not String).
759        let d = Diagnostic {
760            file_path: std::path::PathBuf::from("/repo/src/lib.rs"),
761            line: 1,
762            col: 1,
763            severity: Severity::Warning,
764            code: None,
765            message: "x".to_string(),
766            source: None,
767        };
768        let rel = d
769            .file_path
770            .strip_prefix("/repo")
771            .expect("strips the root cleanly");
772        assert_eq!(rel, std::path::Path::new("src/lib.rs"));
773    }
774}