use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::exec::CapturedOutput;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Ord, PartialOrd)]
pub enum Verdict {
Match,
NotApplicable,
Differ,
SuiFailOnly,
NixFailOnly,
BothFail,
SuiTimeout,
NixTimeout,
}
impl Verdict {
#[must_use]
pub fn glyph(self) -> char {
match self {
Verdict::Match => '.',
Verdict::NotApplicable => 'a',
Verdict::Differ => 'D',
Verdict::SuiFailOnly => 'S',
Verdict::NixFailOnly => 'N',
Verdict::BothFail => '?',
Verdict::SuiTimeout => 's',
Verdict::NixTimeout => 'n',
}
}
#[must_use]
pub fn glyph_styled(self) -> String {
let g = self.glyph().to_string();
match self {
Verdict::Match => crate::style::success(&g),
Verdict::NotApplicable => crate::style::muted(&g),
Verdict::Differ => crate::style::error(&g),
Verdict::SuiFailOnly => crate::style::error(&g),
Verdict::NixFailOnly => crate::style::warn(&g),
Verdict::BothFail => crate::style::warn(&g),
Verdict::SuiTimeout => crate::style::pending(&g),
Verdict::NixTimeout => crate::style::pending(&g),
}
}
#[must_use]
pub fn is_pass(self) -> bool {
matches!(self, Verdict::Match | Verdict::NotApplicable)
}
#[must_use]
pub fn name(self) -> &'static str {
match self {
Verdict::Match => "Match",
Verdict::NotApplicable => "NotApplicable",
Verdict::Differ => "Differ",
Verdict::SuiFailOnly => "SuiFailOnly",
Verdict::NixFailOnly => "NixFailOnly",
Verdict::BothFail => "BothFail",
Verdict::SuiTimeout => "SuiTimeout",
Verdict::NixTimeout => "NixTimeout",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProbeKind {
Eval,
Rebuild,
BuiltinSmoke,
}
impl ProbeKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
ProbeKind::Eval => "eval",
ProbeKind::Rebuild => "rebuild",
ProbeKind::BuiltinSmoke => "builtin-smoke",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TargetOs {
Darwin,
Linux,
Other,
}
impl TargetOs {
#[must_use]
pub fn current() -> Self {
match std::env::consts::OS {
"macos" => TargetOs::Darwin,
"linux" => TargetOs::Linux,
_ => TargetOs::Other,
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
TargetOs::Darwin => "darwin",
TargetOs::Linux => "linux",
TargetOs::Other => "other",
}
}
}
#[must_use]
pub fn current_nix_system() -> String {
let arch = match std::env::consts::ARCH {
"x86_64" => "x86_64",
"aarch64" => "aarch64",
other => other,
};
let os = match TargetOs::current() {
TargetOs::Darwin => "darwin",
TargetOs::Linux => "linux",
TargetOs::Other => std::env::consts::OS,
};
format!("{arch}-{os}")
}
#[must_use]
pub fn current_hostname() -> String {
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
let trimmed = s.trim();
if !trimmed.is_empty() {
return trimmed.split('.').next().unwrap_or(trimmed).to_string();
}
}
if let Ok(s) = std::env::var("HOSTNAME") {
if !s.is_empty() {
return s.split('.').next().unwrap_or(&s).to_string();
}
}
if let Ok(out) = std::process::Command::new("hostname").arg("-s").output() {
if out.status.success() {
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !s.is_empty() {
return s;
}
}
}
"unknown".to_string()
}
#[derive(Debug, Clone)]
pub struct ProbeContext {
pub flake_path: PathBuf,
pub flake_label: String,
pub host: String,
pub system: String,
pub user: String,
pub os: TargetOs,
}
impl ProbeContext {
#[must_use]
pub fn current(flake_path: PathBuf) -> Self {
let flake_label = flake_path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| flake_path.display().to_string());
Self {
flake_path,
flake_label,
host: current_hostname(),
system: current_nix_system(),
user: std::env::var("USER").unwrap_or_else(|_| "unknown".into()),
os: TargetOs::current(),
}
}
#[must_use]
pub fn substitute(&self, template: &str) -> String {
template
.replace("$FLAKE", &self.flake_path.display().to_string())
.replace("$HOST", &self.host)
.replace("$SYSTEM", &self.system)
.replace("$USER", &self.user)
}
}
pub trait ParityCheck {
fn name(&self) -> &str;
fn tags(&self) -> &[String];
fn kind(&self) -> ProbeKind;
fn applies(&self, _ctx: &ProbeContext) -> bool {
true
}
fn sui_invocation(&self, ctx: &ProbeContext, sui_bin: &Path) -> Command;
fn nix_invocation(&self, ctx: &ProbeContext, nix_bin: &Path) -> Command;
fn classify(&self, sui: &CapturedOutput, nix: &CapturedOutput) -> Verdict {
default_classify(sui, nix, |s, n| s.stdout.trim() == n.stdout.trim())
}
}
pub fn default_classify(
sui: &CapturedOutput,
nix: &CapturedOutput,
compare_ok: impl FnOnce(&CapturedOutput, &CapturedOutput) -> bool,
) -> Verdict {
match (sui.timed_out, nix.timed_out) {
(true, _) => return Verdict::SuiTimeout,
(_, true) => return Verdict::NixTimeout,
(false, false) => {}
}
match (sui.success, nix.success) {
(true, true) => if compare_ok(sui, nix) { Verdict::Match } else { Verdict::Differ },
(false, true) => Verdict::SuiFailOnly,
(true, false) => Verdict::NixFailOnly,
(false, false) => Verdict::BothFail,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShadowReport {
pub generated_at: String,
pub generator: String,
pub host: String,
pub system: String,
pub os: String,
pub user: String,
pub sui_version: Option<String>,
pub nix_version: Option<String>,
pub records: Vec<ProbeRecord>,
pub tally: BTreeMap<String, usize>,
}
impl ShadowReport {
#[must_use]
pub fn all_pass(&self) -> bool {
self.records.iter().all(|r| r.verdict.is_pass())
}
#[must_use]
pub fn divergence_count(&self) -> usize {
self.records.iter().filter(|r| !r.verdict.is_pass()).count()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeRecord {
pub name: String,
pub kind: ProbeKind,
pub tags: Vec<String>,
pub flake: String,
pub sui_argv: Vec<String>,
pub nix_argv: Vec<String>,
pub sui_exit: Option<i32>,
pub nix_exit: Option<i32>,
pub sui_stdout_excerpt: String,
pub nix_stdout_excerpt: String,
pub sui_stderr_excerpt: String,
pub nix_stderr_excerpt: String,
pub sui_duration_ms: u128,
pub nix_duration_ms: u128,
pub sui_timed_out: bool,
pub nix_timed_out: bool,
pub verdict: Verdict,
}
#[must_use]
pub fn excerpt(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &s[..end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verdict_pass_set() {
assert!(Verdict::Match.is_pass());
assert!(Verdict::NotApplicable.is_pass());
assert!(!Verdict::Differ.is_pass());
assert!(!Verdict::SuiFailOnly.is_pass());
assert!(!Verdict::SuiTimeout.is_pass());
}
#[test]
fn substitute_replaces_all_placeholders() {
let ctx = ProbeContext {
flake_path: PathBuf::from("/tmp/myflake"),
flake_label: "myflake".into(),
host: "cid".into(),
system: "aarch64-darwin".into(),
user: "drzzln".into(),
os: TargetOs::Darwin,
};
let out = ctx.substitute("path:$FLAKE host=$HOST sys=$SYSTEM user=$USER");
assert_eq!(out, "path:/tmp/myflake host=cid sys=aarch64-darwin user=drzzln");
}
#[test]
fn excerpt_truncates_at_char_boundary() {
let s = "ab".repeat(100);
let e = excerpt(&s, 20);
assert!(e.ends_with('…'));
assert!(e.chars().count() <= 25);
}
#[test]
fn default_classify_handles_full_matrix() {
let ok = mk_out(true, false);
let fail = mk_out(false, false);
let timeout = mk_out(false, true);
let always_eq = |_s: &CapturedOutput, _n: &CapturedOutput| true;
let always_neq = |_s: &CapturedOutput, _n: &CapturedOutput| false;
assert_eq!(default_classify(&ok, &ok, always_eq), Verdict::Match);
assert_eq!(default_classify(&ok, &ok, always_neq), Verdict::Differ);
assert_eq!(default_classify(&fail, &ok, always_eq), Verdict::SuiFailOnly);
assert_eq!(default_classify(&ok, &fail, always_eq), Verdict::NixFailOnly);
assert_eq!(default_classify(&fail, &fail, always_eq), Verdict::BothFail);
assert_eq!(default_classify(&timeout, &ok, always_eq), Verdict::SuiTimeout);
assert_eq!(default_classify(&ok, &timeout, always_eq), Verdict::NixTimeout);
}
fn mk_out(success: bool, timed_out: bool) -> CapturedOutput {
CapturedOutput {
exit_code: if success { Some(0) } else { Some(1) },
success,
stdout: String::new(),
stderr: String::new(),
duration: std::time::Duration::from_millis(1),
timed_out,
}
}
#[test]
fn shadow_report_pass_counts_records() {
let rec_pass = ProbeRecord {
name: "p".into(), kind: ProbeKind::Eval, tags: vec![],
flake: "f".into(), sui_argv: vec![], nix_argv: vec![],
sui_exit: Some(0), nix_exit: Some(0),
sui_stdout_excerpt: String::new(), nix_stdout_excerpt: String::new(),
sui_stderr_excerpt: String::new(), nix_stderr_excerpt: String::new(),
sui_duration_ms: 0, nix_duration_ms: 0,
sui_timed_out: false, nix_timed_out: false,
verdict: Verdict::Match,
};
let mut rec_fail = rec_pass.clone();
rec_fail.verdict = Verdict::Differ;
let report = ShadowReport {
generated_at: "2026-05-22T00:00:00Z".into(),
generator: "sui-sweep 0.1".into(),
host: "cid".into(),
system: "aarch64-darwin".into(),
os: "darwin".into(),
user: "drzzln".into(),
sui_version: None, nix_version: None,
records: vec![rec_pass.clone(), rec_fail, rec_pass],
tally: BTreeMap::new(),
};
assert!(!report.all_pass());
assert_eq!(report.divergence_count(), 1);
}
}