Skip to main content

sui_spec/
rebuild.rs

1//! `(defrebuild-probe …)` — host-aware multi-stage rebuild parity probes.
2//!
3//! Where [`crate::probe::Probe`] tests a single Nix expression for parity,
4//! a [`RebuildProbe`] tests *one stage of the rebuild pipeline* for parity
5//! between sui and cppnix.  The set of stages was distilled from the
6//! actual `fleet rebuild` driver in `pleme-io/fleet`:
7//!
8//! ```text
9//! sui run .#rebuild
10//!   ├── flake show          (FlakeShowKeys)
11//!   ├── flake check         (FlakeCheckExit)
12//!   ├── eval toplevel       (EvalToplevel { Darwin | NixOS })
13//!   ├── eval home-manager   (EvalHomeActivation { user })
14//!   ├── dry-run build       (DryRunClosure { Darwin | NixOS })
15//!   ├── input lock hashes   (InputLockHash { input })
16//!   ├── closure size        (ClosureSize { Darwin | NixOS })
17//!   └── closure references  (ClosureReferenceGraph { Darwin | NixOS })
18//! ```
19//!
20//! Each stage carries the kwargs it needs flatly on the probe — there is
21//! no nested enum-with-data here, which keeps the Lisp authoring shape
22//! consistent with [`crate::derivation::Phase`].  Stages that don't apply
23//! to the operator's OS (e.g. `Darwin`-targeted probes on a Linux host)
24//! self-skip via [`crate::parity::ParityCheck::applies`] and land as
25//! `NotApplicable` records in the report.
26
27use std::path::Path;
28use std::process::Command;
29
30use serde::{Deserialize, Serialize};
31use tatara_lisp::DeriveTataraDomain;
32
33use crate::SpecError;
34use crate::cli::{nix_cli, sui_cli};
35use crate::exec::CapturedOutput;
36use crate::parity::{
37    default_classify, ParityCheck, ProbeContext, ProbeKind, TargetOs, Verdict,
38};
39
40// ── Typed border ───────────────────────────────────────────────────
41
42/// One rebuild-stage parity probe.  Authored as `(defrebuild-probe ...)`.
43#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
44#[tatara(keyword = "defrebuild-probe")]
45pub struct RebuildProbe {
46    /// Stable name (must be unique within the rebuild corpus).
47    pub name: String,
48    /// Which hosts this probe applies to.
49    #[serde(rename = "hostMode")]
50    pub host_mode: HostMode,
51    /// Required when `host_mode = Literal` — the hostname this probe
52    /// is pinned to.
53    #[serde(default, rename = "hostLiteral")]
54    pub host_literal: Option<String>,
55    /// Which stage of the rebuild pipeline this probe exercises.
56    pub stage: RebuildStage,
57    /// How sui's output is compared to nix's.
58    pub compare: RebuildCompare,
59    /// Tags for include/exclude filtering.  Conventional tags:
60    /// `"smoke"`, `"rebuild-phase-1"`..`"rebuild-phase-4"`, `"expensive"`.
61    #[serde(default)]
62    pub tags: Vec<String>,
63}
64
65/// Host applicability selector.
66#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
67pub enum HostMode {
68    /// Applies on whichever host the sweep is running on.
69    Current,
70    /// Applies only when `ctx.host == host_literal`.
71    Literal,
72    /// Applies on every host.  (Today the sweep only runs against the
73    /// current host; this is the forward-looking case.)
74    All,
75}
76
77/// Per-stage kwargs container.  Flat fields keep the Lisp authoring
78/// surface simple — no nested attrset construction required.
79#[derive(Serialize, Deserialize, Debug, Clone)]
80pub struct RebuildStage {
81    pub kind: RebuildStageKind,
82    /// For `EvalToplevel` / `DryRunClosure` / `ClosureSize` /
83    /// `ClosureReferenceGraph`: which top-level config to ask about.
84    #[serde(default)]
85    pub target: Option<ToplevelTarget>,
86    /// For `EvalHomeActivation`: which user's `homeConfigurations.<user>`
87    /// to evaluate.  Substituted from `ctx.user` if absent.
88    #[serde(default)]
89    pub user: Option<String>,
90    /// For `InputLockHash`: which flake input to compare.
91    #[serde(default)]
92    pub input: Option<String>,
93}
94
95/// The set of rebuild stages the sweep knows how to drive.
96#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
97pub enum RebuildStageKind {
98    /// `flake show --json` on the flake.  Compare the top-level
99    /// attribute name set.
100    FlakeShowKeys,
101    /// `flake check` on the flake.  Compare exit code.
102    FlakeCheckExit,
103    /// `eval --json '.<flake>#darwinConfigurations.<host>.system.build.toplevel.outPath'`
104    /// (or the NixOS equivalent).  Compare as JSON.
105    EvalToplevel,
106    /// `eval --json '.<flake>#homeConfigurations.<user>.activationPackage.outPath'`.
107    EvalHomeActivation,
108    /// `build --dry-run --print-out-paths .<flake>#<toplevel>`.  Compare
109    /// the printed store-path set.
110    DryRunClosure,
111    /// `eval --json '(getFlake "path:<flake>").inputs.<input>.narHash'`.
112    InputLockHash,
113    /// `eval --json 'builtins.length (builtins.attrNames ...)'` — a
114    /// rough closure-size sentinel that exercises the module system
115    /// without requiring a full build.  Compare as integer JSON.
116    ClosureSize,
117    /// Full closure reference set (after eval+build).  Marked
118    /// `expensive` because it builds the toplevel derivation.
119    ClosureReferenceGraph,
120}
121
122/// Which top-level system configuration to ask about.
123#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
124pub enum ToplevelTarget {
125    /// `darwinConfigurations.<host>.system.build.toplevel`
126    Darwin,
127    /// `nixosConfigurations.<host>.config.system.build.toplevel`
128    NixOS,
129}
130
131impl ToplevelTarget {
132    /// Map to the [`TargetOs`] this target requires.
133    #[must_use]
134    pub fn required_os(self) -> TargetOs {
135        match self {
136            ToplevelTarget::Darwin => TargetOs::Darwin,
137            ToplevelTarget::NixOS  => TargetOs::Linux,
138        }
139    }
140
141    /// Render the `<flake>#<attr>` selector string (without the
142    /// `outPath` / `.<sub>` suffix).
143    #[must_use]
144    pub fn flake_attr(self, host: &str) -> String {
145        match self {
146            ToplevelTarget::Darwin => {
147                format!("darwinConfigurations.{host}.system.build.toplevel")
148            }
149            ToplevelTarget::NixOS => {
150                format!("nixosConfigurations.{host}.config.system.build.toplevel")
151            }
152        }
153    }
154}
155
156/// How the rebuild probe compares sui's output to nix's.
157#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
158pub enum RebuildCompare {
159    /// Exit-code equality — succeed iff both engines exited with the
160    /// same `success` status.
161    ExitCode,
162    /// JSON byte-equality.
163    JsonEqual,
164    /// Sort-order-insensitive comparison of a JSON array of strings.
165    AttrNamesEqual,
166    /// Both engines must produce the same set of `/nix/store/...`
167    /// paths (sorted comparison).
168    StorePathSet,
169    /// Both engines must produce equal integer outputs.
170    IntegerEqual,
171    /// Both engines must produce an isomorphic reference graph.  M0:
172    /// graph parity is approximated by store-path-set parity.
173    GraphIsomorphic,
174}
175
176// ── ParityCheck impl ───────────────────────────────────────────────
177
178impl ParityCheck for RebuildProbe {
179    fn name(&self) -> &str { &self.name }
180    fn tags(&self) -> &[String] { &self.tags }
181    fn kind(&self) -> ProbeKind { ProbeKind::Rebuild }
182
183    fn applies(&self, ctx: &ProbeContext) -> bool {
184        // 1. host filter
185        match self.host_mode {
186            HostMode::Current | HostMode::All => {}
187            HostMode::Literal => {
188                let Some(literal) = self.host_literal.as_deref() else { return false; };
189                if literal != ctx.host { return false; }
190            }
191        }
192        // 2. stage's required OS, if any
193        if let Some(target) = self.stage.target {
194            if target.required_os() != ctx.os { return false; }
195        }
196        true
197    }
198
199    fn sui_invocation(&self, ctx: &ProbeContext, sui_bin: &Path) -> Command {
200        match self.stage.kind {
201            RebuildStageKind::FlakeShowKeys =>
202                sui_cli::flake_show(sui_bin, &flake_ref(ctx)),
203            RebuildStageKind::FlakeCheckExit =>
204                sui_cli::flake_check(sui_bin, &flake_ref(ctx)),
205            RebuildStageKind::EvalToplevel => {
206                let attr = self.toplevel_attr(ctx);
207                sui_cli::eval_installable(sui_bin, &installable(ctx, &format!("{attr}.outPath")))
208            }
209            RebuildStageKind::EvalHomeActivation => {
210                let attr = self.home_activation_attr(ctx);
211                sui_cli::eval_installable(sui_bin, &installable(ctx, &format!("{attr}.outPath")))
212            }
213            RebuildStageKind::DryRunClosure | RebuildStageKind::ClosureReferenceGraph => {
214                let attr = self.toplevel_attr(ctx);
215                sui_cli::build_dry_run(sui_bin, &installable(ctx, &attr))
216            }
217            RebuildStageKind::InputLockHash => {
218                let input = self.stage.input.as_deref().unwrap_or("nixpkgs");
219                let expr = format!(
220                    "(builtins.getFlake \"path:{}\").inputs.{input}.narHash",
221                    ctx.flake_path.display(),
222                );
223                sui_cli::eval_expr_explicit(sui_bin, &expr)
224            }
225            RebuildStageKind::ClosureSize => {
226                let attr = self.toplevel_attr(ctx);
227                let expr = format!(
228                    "builtins.length (builtins.attrNames ((builtins.getFlake \"path:{}\").{attr}))",
229                    ctx.flake_path.display(),
230                );
231                sui_cli::eval_expr_explicit(sui_bin, &expr)
232            }
233        }
234    }
235
236    fn nix_invocation(&self, ctx: &ProbeContext, nix_bin: &Path) -> Command {
237        match self.stage.kind {
238            RebuildStageKind::FlakeShowKeys =>
239                nix_cli::flake_show(nix_bin, &flake_ref(ctx)),
240            RebuildStageKind::FlakeCheckExit =>
241                nix_cli::flake_check(nix_bin, &flake_ref(ctx)),
242            RebuildStageKind::EvalToplevel => {
243                let attr = self.toplevel_attr(ctx);
244                nix_cli::eval_installable(nix_bin, &installable(ctx, &format!("{attr}.outPath")))
245            }
246            RebuildStageKind::EvalHomeActivation => {
247                let attr = self.home_activation_attr(ctx);
248                nix_cli::eval_installable(nix_bin, &installable(ctx, &format!("{attr}.outPath")))
249            }
250            RebuildStageKind::DryRunClosure | RebuildStageKind::ClosureReferenceGraph => {
251                let attr = self.toplevel_attr(ctx);
252                nix_cli::build_dry_run(nix_bin, &installable(ctx, &attr))
253            }
254            RebuildStageKind::InputLockHash => {
255                let input = self.stage.input.as_deref().unwrap_or("nixpkgs");
256                let expr = format!(
257                    "(builtins.getFlake \"path:{}\").inputs.{input}.narHash",
258                    ctx.flake_path.display(),
259                );
260                nix_cli::eval_expr(nix_bin, &expr)
261            }
262            RebuildStageKind::ClosureSize => {
263                let attr = self.toplevel_attr(ctx);
264                let expr = format!(
265                    "builtins.length (builtins.attrNames ((builtins.getFlake \"path:{}\").{attr}))",
266                    ctx.flake_path.display(),
267                );
268                nix_cli::eval_expr(nix_bin, &expr)
269            }
270        }
271    }
272
273    fn classify(&self, sui: &CapturedOutput, nix: &CapturedOutput) -> Verdict {
274        let compare = self.compare;
275        default_classify(sui, nix, |s, n| {
276            compare_outputs(compare, s.stdout.trim(), n.stdout.trim(), s, n)
277        })
278    }
279}
280
281// ── Selector helpers ───────────────────────────────────────────────
282
283impl RebuildProbe {
284    fn toplevel_attr(&self, ctx: &ProbeContext) -> String {
285        let target = self.stage.target.unwrap_or_else(|| match ctx.os {
286            TargetOs::Darwin => ToplevelTarget::Darwin,
287            _                => ToplevelTarget::NixOS,
288        });
289        target.flake_attr(&ctx.host)
290    }
291
292    fn home_activation_attr(&self, ctx: &ProbeContext) -> String {
293        let user = self.stage.user.as_deref().unwrap_or(ctx.user.as_str());
294        format!("homeConfigurations.\"{user}\".activationPackage")
295    }
296}
297
298fn flake_ref(ctx: &ProbeContext) -> String {
299    format!("path:{}", ctx.flake_path.display())
300}
301
302fn installable(ctx: &ProbeContext, attr: &str) -> String {
303    format!("path:{}#{attr}", ctx.flake_path.display())
304}
305
306// ── Comparison dispatch ────────────────────────────────────────────
307
308fn compare_outputs(
309    mode: RebuildCompare,
310    sui: &str,
311    nix: &str,
312    sui_out: &CapturedOutput,
313    nix_out: &CapturedOutput,
314) -> bool {
315    match mode {
316        RebuildCompare::ExitCode => sui_out.success == nix_out.success,
317        RebuildCompare::JsonEqual => sui == nix,
318        RebuildCompare::AttrNamesEqual => {
319            // `nix flake show --json` emits a JSON OBJECT keyed by
320            // output name (e.g. `{"nixosConfigurations":{...}}`);
321            // the parity test is over the top-level key set.  We also
322            // accept the flat `["k1","k2"]` shape for back-compat with
323            // older callers that pre-extracted the names.
324            let sui_keys = top_level_keys(sui);
325            let nix_keys = top_level_keys(nix);
326            match (sui_keys, nix_keys) {
327                (Some(mut a), Some(mut b)) => {
328                    a.sort();
329                    b.sort();
330                    a == b
331                }
332                _ => false,
333            }
334        }
335        RebuildCompare::StorePathSet | RebuildCompare::GraphIsomorphic => {
336            // Both engines print one path per line; we set-compare.
337            let mut s: Vec<&str> = sui.lines()
338                .filter(|l| l.starts_with("/nix/store/"))
339                .collect();
340            let mut n: Vec<&str> = nix.lines()
341                .filter(|l| l.starts_with("/nix/store/"))
342                .collect();
343            s.sort();
344            n.sort();
345            s == n
346        }
347        RebuildCompare::IntegerEqual => {
348            let sn: Option<i64> = serde_json::from_str(sui).ok();
349            let nn: Option<i64> = serde_json::from_str(nix).ok();
350            matches!((sn, nn), (Some(a), Some(b)) if a == b)
351        }
352    }
353}
354
355/// Extract the top-level attribute names from one of cppnix's
356/// `--json` outputs.  Accepts both:
357///   - `{"k1": ..., "k2": ...}`  → `["k1", "k2"]`
358///   - `["k1", "k2"]`            → `["k1", "k2"]`
359/// Returns `None` for any other JSON shape (number, string, etc.).
360fn top_level_keys(s: &str) -> Option<Vec<String>> {
361    let v: serde_json::Value = serde_json::from_str(s).ok()?;
362    if let Some(arr) = v.as_array() {
363        return arr.iter().map(|x| x.as_str().map(String::from)).collect();
364    }
365    let obj = v.as_object()?;
366    Some(obj.keys().cloned().collect())
367}
368
369// ── Canonical corpus, compiled in ──────────────────────────────────
370
371pub const CANONICAL_REBUILD_PROBES_LISP: &str =
372    include_str!("../specs/rebuild_probes.lisp");
373
374/// Compile the canonical rebuild-probe corpus.
375///
376/// # Errors
377///
378/// Returns an error if the Lisp source fails to parse.
379pub fn load_canonical() -> Result<Vec<RebuildProbe>, SpecError> {
380    crate::loader::load_all::<RebuildProbe>(CANONICAL_REBUILD_PROBES_LISP)
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use std::path::PathBuf;
387
388    #[test]
389    fn canonical_rebuild_corpus_parses() {
390        let probes = load_canonical().expect("canonical rebuild probes must compile");
391        assert!(!probes.is_empty(), "rebuild corpus must contain at least one probe");
392        for p in &probes {
393            assert!(!p.name.is_empty(), "probe must have a name: {p:?}");
394        }
395    }
396
397    #[test]
398    fn canonical_rebuild_corpus_has_expected_stages() {
399        let probes = load_canonical().unwrap();
400        let stages: std::collections::HashSet<RebuildStageKind> =
401            probes.iter().map(|p| p.stage.kind).collect();
402        // The five baselines the operator picked + the three extra
403        // categories.  If any of these is missing, the M0 corpus
404        // regressed.
405        for required in [
406            RebuildStageKind::FlakeShowKeys,
407            RebuildStageKind::FlakeCheckExit,
408            RebuildStageKind::EvalToplevel,
409            RebuildStageKind::DryRunClosure,
410            RebuildStageKind::InputLockHash,
411        ] {
412            assert!(stages.contains(&required), "missing stage {required:?}");
413        }
414    }
415
416    #[test]
417    fn host_literal_filters() {
418        let probe = RebuildProbe {
419            name: "rio-only".into(),
420            host_mode: HostMode::Literal,
421            host_literal: Some("rio".into()),
422            stage: RebuildStage {
423                kind: RebuildStageKind::FlakeShowKeys,
424                target: None,
425                user: None,
426                input: None,
427            },
428            compare: RebuildCompare::AttrNamesEqual,
429            tags: vec![],
430        };
431        let ctx_rio = mk_ctx("rio", TargetOs::Linux);
432        let ctx_cid = mk_ctx("cid", TargetOs::Darwin);
433        assert!(probe.applies(&ctx_rio));
434        assert!(!probe.applies(&ctx_cid));
435    }
436
437    #[test]
438    fn target_os_filters_skip_mismatch() {
439        let probe = RebuildProbe {
440            name: "darwin-only".into(),
441            host_mode: HostMode::Current,
442            host_literal: None,
443            stage: RebuildStage {
444                kind: RebuildStageKind::EvalToplevel,
445                target: Some(ToplevelTarget::Darwin),
446                user: None,
447                input: None,
448            },
449            compare: RebuildCompare::JsonEqual,
450            tags: vec![],
451        };
452        assert!(probe.applies(&mk_ctx("cid", TargetOs::Darwin)));
453        assert!(!probe.applies(&mk_ctx("rio", TargetOs::Linux)));
454    }
455
456    #[test]
457    fn toplevel_attr_renders_correctly() {
458        let darwin = ToplevelTarget::Darwin.flake_attr("cid");
459        let linux = ToplevelTarget::NixOS.flake_attr("rio");
460        assert_eq!(darwin, "darwinConfigurations.cid.system.build.toplevel");
461        assert_eq!(linux, "nixosConfigurations.rio.config.system.build.toplevel");
462    }
463
464    #[test]
465    fn sui_invocation_includes_flake_path() {
466        let probe = mk_probe(RebuildStageKind::FlakeShowKeys, None);
467        let ctx = mk_ctx("cid", TargetOs::Darwin);
468        let cmd = probe.sui_invocation(&ctx, Path::new("/usr/local/bin/sui"));
469        let argv = crate::exec::command_argv(&cmd);
470        assert_eq!(argv[1], "flake");
471        assert_eq!(argv[2], "show");
472        assert!(argv.iter().any(|a| a.contains("/tmp/flake")));
473    }
474
475    #[test]
476    fn nix_invocation_includes_experimental_features() {
477        let probe = mk_probe(RebuildStageKind::FlakeShowKeys, None);
478        let ctx = mk_ctx("cid", TargetOs::Darwin);
479        let cmd = probe.nix_invocation(&ctx, Path::new("/usr/bin/nix"));
480        let argv = crate::exec::command_argv(&cmd);
481        assert!(argv.iter().any(|a| a == "--extra-experimental-features"));
482    }
483
484    fn mk_ctx(host: &str, os: TargetOs) -> ProbeContext {
485        ProbeContext {
486            flake_path: PathBuf::from("/tmp/flake"),
487            flake_label: "flake".into(),
488            host: host.into(),
489            system: match os {
490                TargetOs::Darwin => "aarch64-darwin".into(),
491                TargetOs::Linux  => "x86_64-linux".into(),
492                TargetOs::Other  => "unknown".into(),
493            },
494            user: "drzzln".into(),
495            os,
496        }
497    }
498
499    fn mk_probe(kind: RebuildStageKind, target: Option<ToplevelTarget>) -> RebuildProbe {
500        RebuildProbe {
501            name: "test".into(),
502            host_mode: HostMode::Current,
503            host_literal: None,
504            stage: RebuildStage { kind, target, user: None, input: None },
505            compare: RebuildCompare::JsonEqual,
506            tags: vec!["test".into()],
507        }
508    }
509
510    #[test]
511    fn integer_equal_compare() {
512        let sui = CapturedOutput {
513            exit_code: Some(0), success: true,
514            stdout: "42".into(), stderr: String::new(),
515            duration: std::time::Duration::ZERO, timed_out: false,
516        };
517        let nix = sui.clone();
518        let mut nix_diff = nix.clone();
519        nix_diff.stdout = "43".into();
520        assert!(compare_outputs(RebuildCompare::IntegerEqual, "42", "42", &sui, &nix));
521        assert!(!compare_outputs(RebuildCompare::IntegerEqual, "42", "43", &sui, &nix_diff));
522    }
523
524    #[test]
525    fn attr_names_equal_matches_objects_with_same_keys() {
526        let sui = r#"{"nixosConfigurations":{"rio":{"type":"nixos-configuration"}}}"#;
527        let nix = r#"{"nixosConfigurations":{"rio":{"type":"nixos-configuration"}}}"#;
528        let dummy = CapturedOutput {
529            exit_code: Some(0), success: true,
530            stdout: String::new(), stderr: String::new(),
531            duration: std::time::Duration::ZERO, timed_out: false,
532        };
533        assert!(compare_outputs(RebuildCompare::AttrNamesEqual, sui, nix, &dummy, &dummy));
534    }
535
536    #[test]
537    fn attr_names_equal_diverges_on_extra_key() {
538        let sui = r#"{"nixosConfigurations":{},"darwinConfigurations":{}}"#;
539        let nix = r#"{"nixosConfigurations":{}}"#;
540        let dummy = CapturedOutput {
541            exit_code: Some(0), success: true,
542            stdout: String::new(), stderr: String::new(),
543            duration: std::time::Duration::ZERO, timed_out: false,
544        };
545        assert!(!compare_outputs(RebuildCompare::AttrNamesEqual, sui, nix, &dummy, &dummy));
546    }
547
548    #[test]
549    fn attr_names_equal_accepts_legacy_array_shape() {
550        let sui = r#"["nixosConfigurations"]"#;
551        let nix = r#"{"nixosConfigurations":{}}"#;
552        let dummy = CapturedOutput {
553            exit_code: Some(0), success: true,
554            stdout: String::new(), stderr: String::new(),
555            duration: std::time::Duration::ZERO, timed_out: false,
556        };
557        assert!(compare_outputs(RebuildCompare::AttrNamesEqual, sui, nix, &dummy, &dummy));
558    }
559
560    #[test]
561    fn store_path_set_compare_sorts() {
562        let sui_out = "/nix/store/aaaa-x\n/nix/store/bbbb-y\n";
563        let nix_out = "/nix/store/bbbb-y\n/nix/store/aaaa-x\n";
564        let dummy = CapturedOutput {
565            exit_code: Some(0), success: true,
566            stdout: String::new(), stderr: String::new(),
567            duration: std::time::Duration::ZERO, timed_out: false,
568        };
569        assert!(compare_outputs(RebuildCompare::StorePathSet, sui_out, nix_out, &dummy, &dummy));
570    }
571}