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}