1use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25use serde::{Deserialize, Serialize};
26
27use crate::exec::CapturedOutput;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Ord, PartialOrd)]
33pub enum Verdict {
34 Match,
36 NotApplicable,
40 Differ,
42 SuiFailOnly,
44 NixFailOnly,
47 BothFail,
49 SuiTimeout,
51 NixTimeout,
53}
54
55impl Verdict {
56 #[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 #[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 #[must_use]
93 pub fn is_pass(self) -> bool {
94 matches!(self, Verdict::Match | Verdict::NotApplicable)
95 }
96
97 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116pub enum ProbeKind {
117 Eval,
120 Rebuild,
123 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
141pub enum TargetOs {
142 Darwin,
143 Linux,
144 Other,
145}
146
147impl TargetOs {
148 #[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#[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#[must_use]
190pub fn current_hostname() -> String {
191 if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
194 let trimmed = s.trim();
195 if !trimmed.is_empty() {
196 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 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#[derive(Debug, Clone)]
223pub struct ProbeContext {
224 pub flake_path: PathBuf,
227 pub flake_label: String,
230 pub host: String,
232 pub system: String,
234 pub user: String,
236 pub os: TargetOs,
238}
239
240impl ProbeContext {
241 #[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 #[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
271pub trait ParityCheck {
275 fn name(&self) -> &str;
277
278 fn tags(&self) -> &[String];
281
282 fn kind(&self) -> ProbeKind;
284
285 fn applies(&self, _ctx: &ProbeContext) -> bool {
289 true
290 }
291
292 fn sui_invocation(&self, ctx: &ProbeContext, sui_bin: &Path) -> Command;
296
297 fn nix_invocation(&self, ctx: &ProbeContext, nix_bin: &Path) -> Command;
299
300 fn classify(&self, sui: &CapturedOutput, nix: &CapturedOutput) -> Verdict {
304 default_classify(sui, nix, |s, n| s.stdout.trim() == n.stdout.trim())
305 }
306}
307
308pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct ShadowReport {
335 pub generated_at: String,
337 pub generator: String,
339 pub host: String,
341 pub system: String,
343 pub os: String,
345 pub user: String,
347 pub sui_version: Option<String>,
349 pub nix_version: Option<String>,
351 pub records: Vec<ProbeRecord>,
353 pub tally: BTreeMap<String, usize>,
355}
356
357impl ShadowReport {
358 #[must_use]
360 pub fn all_pass(&self) -> bool {
361 self.records.iter().all(|r| r.verdict.is_pass())
362 }
363
364 #[must_use]
366 pub fn divergence_count(&self) -> usize {
367 self.records.iter().filter(|r| !r.verdict.is_pass()).count()
368 }
369}
370
371#[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#[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}