1use 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#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
44#[tatara(keyword = "defrebuild-probe")]
45pub struct RebuildProbe {
46 pub name: String,
48 #[serde(rename = "hostMode")]
50 pub host_mode: HostMode,
51 #[serde(default, rename = "hostLiteral")]
54 pub host_literal: Option<String>,
55 pub stage: RebuildStage,
57 pub compare: RebuildCompare,
59 #[serde(default)]
62 pub tags: Vec<String>,
63}
64
65#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
67pub enum HostMode {
68 Current,
70 Literal,
72 All,
75}
76
77#[derive(Serialize, Deserialize, Debug, Clone)]
80pub struct RebuildStage {
81 pub kind: RebuildStageKind,
82 #[serde(default)]
85 pub target: Option<ToplevelTarget>,
86 #[serde(default)]
89 pub user: Option<String>,
90 #[serde(default)]
92 pub input: Option<String>,
93}
94
95#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
97pub enum RebuildStageKind {
98 FlakeShowKeys,
101 FlakeCheckExit,
103 EvalToplevel,
106 EvalHomeActivation,
108 DryRunClosure,
111 InputLockHash,
113 ClosureSize,
117 ClosureReferenceGraph,
120}
121
122#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
124pub enum ToplevelTarget {
125 Darwin,
127 NixOS,
129}
130
131impl ToplevelTarget {
132 #[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 #[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#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
158pub enum RebuildCompare {
159 ExitCode,
162 JsonEqual,
164 AttrNamesEqual,
166 StorePathSet,
169 IntegerEqual,
171 GraphIsomorphic,
174}
175
176impl 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 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 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
281impl 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
306fn 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 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 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
355fn 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
369pub const CANONICAL_REBUILD_PROBES_LISP: &str =
372 include_str!("../specs/rebuild_probes.lisp");
373
374pub 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 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}