Skip to main content

sui_spec/
parity.rs

1//! The [`ParityCheck`] trait — every typed parity question rides on it.
2//!
3//! Two sites today: [`crate::probe::Probe`] (eval-only, single-expression
4//! probes) and [`crate::rebuild::RebuildProbe`] (host-aware, multi-stage
5//! rebuild probes).  A third — `BuiltinSmoke` — is authored as a
6//! [`Probe`] with a `"builtin-smoke"` tag, so it reuses the same impl.
7//!
8//! The trait factors out the four invariants every parity check obeys:
9//!
10//! 1. **identity** — a stable name + tag set the report can group by;
11//! 2. **applicability** — whether the check runs in a given context
12//!    (skip Darwin-only probes on Linux without recording a failure);
13//! 3. **invocation** — typed [`Command`] construction for sui + nix
14//!    (NO SHELL strings ever leave this layer);
15//! 4. **classification** — the verdict given the two captured outputs.
16//!
17//! Sweep loops, report writers, and operator-facing wrappers are all
18//! generic over `ParityCheck`, so a new typed domain that wants to
19//! participate plugs in by implementing the trait once.
20
21use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25use serde::{Deserialize, Serialize};
26
27use crate::exec::CapturedOutput;
28
29/// Verdict for one (probe, context) combo.  The variant ordering
30/// matches the priority of attention in the sweep summary: a `Differ`
31/// is worse than a `BothFail`, etc.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Ord, PartialOrd)]
33pub enum Verdict {
34    /// Both engines succeeded and the comparison rule passed.
35    Match,
36    /// Probe declared itself non-applicable to this context (e.g. a
37    /// Darwin-only rebuild stage on a Linux host).  Counts as a pass
38    /// for summary purposes.
39    NotApplicable,
40    /// Both engines succeeded, but the comparison rule rejected.
41    Differ,
42    /// Only sui failed.  Most common failure mode while sui catches up.
43    SuiFailOnly,
44    /// Only nix (cppnix) failed.  Either sui caught a real bug nix
45    /// papers over, or — more often — the probe expression is wrong.
46    NixFailOnly,
47    /// Both failed.  Probe is malformed or the flake itself is broken.
48    BothFail,
49    /// Sui hit the watchdog.
50    SuiTimeout,
51    /// Nix hit the watchdog.
52    NixTimeout,
53}
54
55impl Verdict {
56    /// Single-character glyph for the per-probe progress line.
57    #[must_use]
58    pub fn glyph(self) -> char {
59        match self {
60            Verdict::Match         => '.',
61            Verdict::NotApplicable => 'a',
62            Verdict::Differ        => 'D',
63            Verdict::SuiFailOnly   => 'S',
64            Verdict::NixFailOnly   => 'N',
65            Verdict::BothFail      => '?',
66            Verdict::SuiTimeout    => 's',
67            Verdict::NixTimeout    => 'n',
68        }
69    }
70
71    /// Nord-styled glyph for the per-probe progress line.  Used by
72    /// `sui-sweep` (and any future operator-facing sweep surface)
73    /// to color-code the verdict tide at a glance: green dots for
74    /// matches, red glyphs for divergence, yellow for timeouts.
75    #[must_use]
76    pub fn glyph_styled(self) -> String {
77        let g = self.glyph().to_string();
78        match self {
79            Verdict::Match         => crate::style::success(&g),
80            Verdict::NotApplicable => crate::style::muted(&g),
81            Verdict::Differ        => crate::style::error(&g),
82            Verdict::SuiFailOnly   => crate::style::error(&g),
83            Verdict::NixFailOnly   => crate::style::warn(&g),
84            Verdict::BothFail      => crate::style::warn(&g),
85            Verdict::SuiTimeout    => crate::style::pending(&g),
86            Verdict::NixTimeout    => crate::style::pending(&g),
87        }
88    }
89
90    /// `true` iff the verdict counts as a pass — `Match` or
91    /// `NotApplicable`.  Used by the top-level summary line.
92    #[must_use]
93    pub fn is_pass(self) -> bool {
94        matches!(self, Verdict::Match | Verdict::NotApplicable)
95    }
96
97    /// Stable string name, suitable for JSON keys and grouping.
98    #[must_use]
99    pub fn name(self) -> &'static str {
100        match self {
101            Verdict::Match         => "Match",
102            Verdict::NotApplicable => "NotApplicable",
103            Verdict::Differ        => "Differ",
104            Verdict::SuiFailOnly   => "SuiFailOnly",
105            Verdict::NixFailOnly   => "NixFailOnly",
106            Verdict::BothFail      => "BothFail",
107            Verdict::SuiTimeout    => "SuiTimeout",
108            Verdict::NixTimeout    => "NixTimeout",
109        }
110    }
111}
112
113/// Classifies which corpus a probe came from.  Embedded in the report
114/// so operators can filter without re-parsing the original Lisp.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116pub enum ProbeKind {
117    /// `(defprobe ...)` — a single Nix expression with `$FLAKE`
118    /// substitution.
119    Eval,
120    /// `(defrebuild-probe ...)` — host-aware multi-stage rebuild
121    /// invocation.
122    Rebuild,
123    /// `(defprobe ...)` with the `builtin-smoke` tag — exercises one
124    /// of sui's builtin modules.
125    BuiltinSmoke,
126}
127
128impl ProbeKind {
129    #[must_use]
130    pub fn as_str(self) -> &'static str {
131        match self {
132            ProbeKind::Eval         => "eval",
133            ProbeKind::Rebuild      => "rebuild",
134            ProbeKind::BuiltinSmoke => "builtin-smoke",
135        }
136    }
137}
138
139/// Operator host platform — fixes the OS the probe sweep is running on.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
141pub enum TargetOs {
142    Darwin,
143    Linux,
144    Other,
145}
146
147impl TargetOs {
148    /// Read from `std::env::consts::OS`.
149    #[must_use]
150    pub fn current() -> Self {
151        match std::env::consts::OS {
152            "macos"  => TargetOs::Darwin,
153            "linux"  => TargetOs::Linux,
154            _        => TargetOs::Other,
155        }
156    }
157
158    #[must_use]
159    pub fn as_str(self) -> &'static str {
160        match self {
161            TargetOs::Darwin => "darwin",
162            TargetOs::Linux  => "linux",
163            TargetOs::Other  => "other",
164        }
165    }
166}
167
168/// Operator-machine architecture (`aarch64-darwin`, `x86_64-linux`, ...).
169///
170/// Derived from `std::env::consts::ARCH` + [`TargetOs`].  Used to pick
171/// the right `packages.<system>` / `devShells.<system>` attribute under
172/// rebuild probes.
173#[must_use]
174pub fn current_nix_system() -> String {
175    let arch = match std::env::consts::ARCH {
176        "x86_64"      => "x86_64",
177        "aarch64"     => "aarch64",
178        other          => other,
179    };
180    let os = match TargetOs::current() {
181        TargetOs::Darwin => "darwin",
182        TargetOs::Linux  => "linux",
183        TargetOs::Other  => std::env::consts::OS,
184    };
185    format!("{arch}-{os}")
186}
187
188/// Operator hostname (short form — `hostname -s` semantics).
189#[must_use]
190pub fn current_hostname() -> String {
191    // Read /etc/hostname first (Linux + nix-darwin both populate it),
192    // then fall back to the `HOSTNAME` env, then to "unknown".
193    if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
194        let trimmed = s.trim();
195        if !trimmed.is_empty() {
196            // Strip the FQDN suffix to match `hostname -s`.
197            return trimmed.split('.').next().unwrap_or(trimmed).to_string();
198        }
199    }
200    if let Ok(s) = std::env::var("HOSTNAME") {
201        if !s.is_empty() {
202            return s.split('.').next().unwrap_or(&s).to_string();
203        }
204    }
205    // Last resort: spawn `hostname -s`.  This file should never be hot,
206    // so the subprocess cost is fine.
207    if let Ok(out) = std::process::Command::new("hostname").arg("-s").output() {
208        if out.status.success() {
209            let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
210            if !s.is_empty() {
211                return s;
212            }
213        }
214    }
215    "unknown".to_string()
216}
217
218/// Operator-machine context every probe receives during a sweep.
219///
220/// All fields are populated from the current machine; the sweep doesn't
221/// (yet) iterate hosts independently of the operator running it.
222#[derive(Debug, Clone)]
223pub struct ProbeContext {
224    /// Absolute path to the flake being probed (the directory
225    /// containing `flake.nix`).
226    pub flake_path: PathBuf,
227    /// Short label for reports — typically the flake directory's
228    /// basename.
229    pub flake_label: String,
230    /// Operator hostname.
231    pub host: String,
232    /// Nix system tuple — `aarch64-darwin`, `x86_64-linux`, ...
233    pub system: String,
234    /// Operator username (`$USER` or current uid lookup).
235    pub user: String,
236    /// Operator OS.
237    pub os: TargetOs,
238}
239
240impl ProbeContext {
241    /// Build a context for the current operator + `flake_path`.
242    #[must_use]
243    pub fn current(flake_path: PathBuf) -> Self {
244        let flake_label = flake_path
245            .file_name()
246            .map(|s| s.to_string_lossy().into_owned())
247            .unwrap_or_else(|| flake_path.display().to_string());
248        Self {
249            flake_path,
250            flake_label,
251            host: current_hostname(),
252            system: current_nix_system(),
253            user: std::env::var("USER").unwrap_or_else(|_| "unknown".into()),
254            os: TargetOs::current(),
255        }
256    }
257
258    /// Substitute `$FLAKE` / `$HOST` / `$SYSTEM` / `$USER` placeholders
259    /// in `template`.  Order-insensitive; the placeholder set is
260    /// closed (no recursive expansion).
261    #[must_use]
262    pub fn substitute(&self, template: &str) -> String {
263        template
264            .replace("$FLAKE", &self.flake_path.display().to_string())
265            .replace("$HOST", &self.host)
266            .replace("$SYSTEM", &self.system)
267            .replace("$USER", &self.user)
268    }
269}
270
271/// Every typed parity domain implements this trait.  Sweep loops drive
272/// `&dyn ParityCheck`; corpora produce `Vec<Box<dyn ParityCheck>>` via
273/// trait-object boxing in the corpus loader.
274pub trait ParityCheck {
275    /// Stable probe name (must be unique within a corpus).
276    fn name(&self) -> &str;
277
278    /// Tags attached to this probe.  Sweep filters include/exclude by
279    /// tag membership.
280    fn tags(&self) -> &[String];
281
282    /// Which corpus this probe belongs to.  Embedded in the report.
283    fn kind(&self) -> ProbeKind;
284
285    /// Whether this probe applies to the given context.  Default is
286    /// always-applies; rebuild probes override to skip e.g. Darwin
287    /// stages on Linux.
288    fn applies(&self, _ctx: &ProbeContext) -> bool {
289        true
290    }
291
292    /// Construct the `sui` invocation for this probe.  Implementations
293    /// must NOT use shell strings — every argument is added via typed
294    /// [`Command`] APIs.
295    fn sui_invocation(&self, ctx: &ProbeContext, sui_bin: &Path) -> Command;
296
297    /// Construct the `nix` invocation for this probe (the cppnix oracle).
298    fn nix_invocation(&self, ctx: &ProbeContext, nix_bin: &Path) -> Command;
299
300    /// Classify the (sui, nix) output pair.  Default behavior covers
301    /// the spawn/timeout/exit-code matrix; overrides handle the
302    /// comparison-rule layer.
303    fn classify(&self, sui: &CapturedOutput, nix: &CapturedOutput) -> Verdict {
304        default_classify(sui, nix, |s, n| s.stdout.trim() == n.stdout.trim())
305    }
306}
307
308/// Shared verdict skeleton — handles spawn failure, timeout, and the
309/// exit-code matrix.  Comparison-rule logic plugs in via the
310/// `compare_ok` closure, which is only called when both engines exit 0.
311pub fn default_classify(
312    sui: &CapturedOutput,
313    nix: &CapturedOutput,
314    compare_ok: impl FnOnce(&CapturedOutput, &CapturedOutput) -> bool,
315) -> Verdict {
316    match (sui.timed_out, nix.timed_out) {
317        (true, _)  => return Verdict::SuiTimeout,
318        (_, true)  => return Verdict::NixTimeout,
319        (false, false) => {}
320    }
321    match (sui.success, nix.success) {
322        (true, true) => if compare_ok(sui, nix) { Verdict::Match } else { Verdict::Differ },
323        (false, true) => Verdict::SuiFailOnly,
324        (true, false) => Verdict::NixFailOnly,
325        (false, false) => Verdict::BothFail,
326    }
327}
328
329// ── Report types ────────────────────────────────────────────────────
330
331/// Top-level shadow-sweep report.  Serialised as JSON to
332/// `~/.cache/sui/shadow-reports/<host>-<ISO-8601>.json`.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct ShadowReport {
335    /// ISO-8601 UTC timestamp.
336    pub generated_at: String,
337    /// Tool that produced the report (`"sui-sweep <version>"`).
338    pub generator: String,
339    /// Operator hostname.
340    pub host: String,
341    /// Operator nix system tuple.
342    pub system: String,
343    /// Operator OS short name.
344    pub os: String,
345    /// Operator username.
346    pub user: String,
347    /// `sui --version` stdout if available.
348    pub sui_version: Option<String>,
349    /// `nix --version` stdout if available.
350    pub nix_version: Option<String>,
351    /// Per-(probe, flake) record.
352    pub records: Vec<ProbeRecord>,
353    /// Verdict-name → count tally.
354    pub tally: BTreeMap<String, usize>,
355}
356
357impl ShadowReport {
358    /// `true` iff every record has a passing verdict.
359    #[must_use]
360    pub fn all_pass(&self) -> bool {
361        self.records.iter().all(|r| r.verdict.is_pass())
362    }
363
364    /// Count of non-passing records.
365    #[must_use]
366    pub fn divergence_count(&self) -> usize {
367        self.records.iter().filter(|r| !r.verdict.is_pass()).count()
368    }
369}
370
371/// One probe × one flake = one record.
372#[derive(Debug, Clone, Serialize, Deserialize)]
373pub struct ProbeRecord {
374    pub name: String,
375    pub kind: ProbeKind,
376    pub tags: Vec<String>,
377    pub flake: String,
378    pub sui_argv: Vec<String>,
379    pub nix_argv: Vec<String>,
380    pub sui_exit: Option<i32>,
381    pub nix_exit: Option<i32>,
382    pub sui_stdout_excerpt: String,
383    pub nix_stdout_excerpt: String,
384    pub sui_stderr_excerpt: String,
385    pub nix_stderr_excerpt: String,
386    pub sui_duration_ms: u128,
387    pub nix_duration_ms: u128,
388    pub sui_timed_out: bool,
389    pub nix_timed_out: bool,
390    pub verdict: Verdict,
391}
392
393/// Truncate a string to `max` bytes, appending an ellipsis if cut.
394/// UTF-8-safe — cuts at the previous char boundary.
395#[must_use]
396pub fn excerpt(s: &str, max: usize) -> String {
397    if s.len() <= max {
398        return s.to_string();
399    }
400    let mut end = max;
401    while end > 0 && !s.is_char_boundary(end) {
402        end -= 1;
403    }
404    format!("{}…", &s[..end])
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn verdict_pass_set() {
413        assert!(Verdict::Match.is_pass());
414        assert!(Verdict::NotApplicable.is_pass());
415        assert!(!Verdict::Differ.is_pass());
416        assert!(!Verdict::SuiFailOnly.is_pass());
417        assert!(!Verdict::SuiTimeout.is_pass());
418    }
419
420    #[test]
421    fn substitute_replaces_all_placeholders() {
422        let ctx = ProbeContext {
423            flake_path: PathBuf::from("/tmp/myflake"),
424            flake_label: "myflake".into(),
425            host: "cid".into(),
426            system: "aarch64-darwin".into(),
427            user: "drzzln".into(),
428            os: TargetOs::Darwin,
429        };
430        let out = ctx.substitute("path:$FLAKE host=$HOST sys=$SYSTEM user=$USER");
431        assert_eq!(out, "path:/tmp/myflake host=cid sys=aarch64-darwin user=drzzln");
432    }
433
434    #[test]
435    fn excerpt_truncates_at_char_boundary() {
436        let s = "ab".repeat(100);
437        let e = excerpt(&s, 20);
438        assert!(e.ends_with('…'));
439        assert!(e.chars().count() <= 25);
440    }
441
442    #[test]
443    fn default_classify_handles_full_matrix() {
444        let ok = mk_out(true, false);
445        let fail = mk_out(false, false);
446        let timeout = mk_out(false, true);
447
448        let always_eq = |_s: &CapturedOutput, _n: &CapturedOutput| true;
449        let always_neq = |_s: &CapturedOutput, _n: &CapturedOutput| false;
450
451        assert_eq!(default_classify(&ok, &ok, always_eq), Verdict::Match);
452        assert_eq!(default_classify(&ok, &ok, always_neq), Verdict::Differ);
453        assert_eq!(default_classify(&fail, &ok, always_eq), Verdict::SuiFailOnly);
454        assert_eq!(default_classify(&ok, &fail, always_eq), Verdict::NixFailOnly);
455        assert_eq!(default_classify(&fail, &fail, always_eq), Verdict::BothFail);
456        assert_eq!(default_classify(&timeout, &ok, always_eq), Verdict::SuiTimeout);
457        assert_eq!(default_classify(&ok, &timeout, always_eq), Verdict::NixTimeout);
458    }
459
460    fn mk_out(success: bool, timed_out: bool) -> CapturedOutput {
461        CapturedOutput {
462            exit_code: if success { Some(0) } else { Some(1) },
463            success,
464            stdout: String::new(),
465            stderr: String::new(),
466            duration: std::time::Duration::from_millis(1),
467            timed_out,
468        }
469    }
470
471    #[test]
472    fn shadow_report_pass_counts_records() {
473        let rec_pass = ProbeRecord {
474            name: "p".into(), kind: ProbeKind::Eval, tags: vec![],
475            flake: "f".into(), sui_argv: vec![], nix_argv: vec![],
476            sui_exit: Some(0), nix_exit: Some(0),
477            sui_stdout_excerpt: String::new(), nix_stdout_excerpt: String::new(),
478            sui_stderr_excerpt: String::new(), nix_stderr_excerpt: String::new(),
479            sui_duration_ms: 0, nix_duration_ms: 0,
480            sui_timed_out: false, nix_timed_out: false,
481            verdict: Verdict::Match,
482        };
483        let mut rec_fail = rec_pass.clone();
484        rec_fail.verdict = Verdict::Differ;
485        let report = ShadowReport {
486            generated_at: "2026-05-22T00:00:00Z".into(),
487            generator: "sui-sweep 0.1".into(),
488            host: "cid".into(),
489            system: "aarch64-darwin".into(),
490            os: "darwin".into(),
491            user: "drzzln".into(),
492            sui_version: None, nix_version: None,
493            records: vec![rec_pass.clone(), rec_fail, rec_pass],
494            tally: BTreeMap::new(),
495        };
496        assert!(!report.all_pass());
497        assert_eq!(report.divergence_count(), 1);
498    }
499}