1use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11use std::time::Duration;
12
13use crate::exec::{command_argv, dual_run, run_with_timeout, CapturedOutput};
14use crate::parity::{
15 excerpt, ParityCheck, ProbeContext, ProbeRecord, ShadowReport, Verdict,
16};
17
18pub const EXCERPT_CAP: usize = 4096;
20
21pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Corpus {
27 Parity,
29 BuiltinSmoke,
31 Rebuild,
33 All,
35}
36
37impl Corpus {
38 #[must_use]
41 pub fn from_str(s: &str) -> Option<Self> {
42 match s {
43 "parity" => Some(Corpus::Parity),
44 "builtins" | "builtin-smoke" => Some(Corpus::BuiltinSmoke),
45 "rebuild" => Some(Corpus::Rebuild),
46 "all" => Some(Corpus::All),
47 _ => None,
48 }
49 }
50
51 #[must_use]
53 pub fn name(self) -> &'static str {
54 match self {
55 Corpus::Parity => "parity",
56 Corpus::BuiltinSmoke => "builtins",
57 Corpus::Rebuild => "rebuild",
58 Corpus::All => "all",
59 }
60 }
61}
62
63#[derive(Debug, Clone)]
66pub struct SweepConfig {
67 pub sui_bin: PathBuf,
69 pub nix_bin: PathBuf,
71 pub flakes_root: PathBuf,
74 pub explicit_flakes: Vec<PathBuf>,
77 pub include_tags: Vec<String>,
80 pub exclude_tags: Vec<String>,
82 pub timeout: Duration,
84 pub corpus: Corpus,
86 pub verbose: bool,
88 pub report_path: Option<PathBuf>,
91}
92
93impl SweepConfig {
94 #[must_use]
96 pub fn defaults() -> Self {
97 let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
98 let sui_bin = std::env::current_dir()
99 .map(|p| p.join("target/release/sui"))
100 .unwrap_or_else(|_| PathBuf::from("sui"));
101 Self {
102 sui_bin,
103 nix_bin: PathBuf::from("nix"),
104 flakes_root: PathBuf::from(format!("{home}/code/github/pleme-io")),
105 explicit_flakes: Vec::new(),
106 include_tags: Vec::new(),
107 exclude_tags: Vec::new(),
108 timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
109 corpus: Corpus::All,
110 verbose: false,
111 report_path: None,
112 }
113 }
114}
115
116pub fn run(config: &SweepConfig) -> Result<ShadowReport, crate::SpecError> {
124 let probes = load_selected(config.corpus)?;
125 let flakes = resolve_flakes(config);
126
127 eprintln!(
128 "{} {} {} {} {}",
129 crate::style::glyph_snowflake(),
130 crate::style::header("sui-sweep"),
131 crate::style::muted(&format!("corpus={}", config.corpus.name())),
132 crate::style::muted(&format!("probes={}", probes.len())),
133 crate::style::muted(&format!(
134 "flakes={} timeout={}s",
135 flakes.len(),
136 config.timeout.as_secs(),
137 )),
138 );
139
140 let mut records = Vec::new();
141 let mut tally: BTreeMap<String, usize> = BTreeMap::new();
142
143 for flake in &flakes {
144 let ctx = ProbeContext::current(flake.clone());
145 for probe in &probes {
146 if !tag_filter_admits(probe.tags(), &config.include_tags, &config.exclude_tags) {
149 continue;
150 }
151 let verdict = if probe.applies(&ctx) {
152 run_one(&**probe, &ctx, config, &mut records)
153 } else {
154 push_record(&**probe, &ctx, None, None, Verdict::NotApplicable, &mut records);
155 Verdict::NotApplicable
156 };
157 *tally.entry(verdict.name().to_string()).or_default() += 1;
158 eprint!("{}", verdict.glyph_styled());
159 if config.verbose {
160 eprintln!(" {} :: {}",
161 crate::style::ident(probe.name()),
162 crate::style::muted(&ctx.flake_label),
163 );
164 }
165 }
166 eprintln!(" {}", crate::style::muted(&ctx.flake_label));
167 }
168
169 let report = ShadowReport {
170 generated_at: utc_now_iso8601(),
171 generator: format!("sui-sweep {}", env!("CARGO_PKG_VERSION")),
172 host: crate::parity::current_hostname(),
173 system: crate::parity::current_nix_system(),
174 os: crate::parity::TargetOs::current().as_str().to_string(),
175 user: std::env::var("USER").unwrap_or_else(|_| "unknown".into()),
176 sui_version: detect_version(&config.sui_bin),
177 nix_version: detect_version(&config.nix_bin),
178 records,
179 tally,
180 };
181
182 if let Some(path) = config.report_path.as_deref() {
183 write_report(path, &report).map_err(|e| crate::SpecError::Interp {
184 phase: "sweep::report-write".into(),
185 message: format!("{}: {e}", path.display()),
186 })?;
187 eprintln!("\nreport: {}", path.display());
188 }
189
190 let runs = report.records.len();
191 let diverged = report.divergence_count();
192 let passed = runs - diverged;
193 let summary_glyph = if diverged == 0 {
194 crate::style::glyph_ok()
195 } else {
196 crate::style::glyph_fail()
197 };
198 println!(
199 "\n{} {} {} {} {}",
200 summary_glyph,
201 crate::style::header("sui-sweep complete"),
202 crate::style::ident(&format!("{runs} runs")),
203 crate::style::success(&format!("{passed} passed")),
204 if diverged == 0 {
205 crate::style::muted("0 diverged")
206 } else {
207 crate::style::error(&format!("{diverged} diverged"))
208 },
209 );
210
211 Ok(report)
212}
213
214#[must_use]
216pub fn default_report_path() -> PathBuf {
217 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
218 let host = crate::parity::current_hostname();
219 let ts = utc_now_iso8601().replace(':', "-");
220 PathBuf::from(home)
221 .join(".cache")
222 .join("sui")
223 .join("shadow-reports")
224 .join(format!("{host}-{ts}.json"))
225}
226
227fn run_one(
230 probe: &dyn ParityCheck,
231 ctx: &ProbeContext,
232 config: &SweepConfig,
233 records: &mut Vec<ProbeRecord>,
234) -> Verdict {
235 let mut sui_cmd = probe.sui_invocation(ctx, &config.sui_bin);
236 let mut nix_cmd = probe.nix_invocation(ctx, &config.nix_bin);
237 let sui_argv = command_argv(&sui_cmd);
238 let nix_argv = command_argv(&nix_cmd);
239 let (sui_out, nix_out) = dual_run(&mut sui_cmd, &mut nix_cmd, config.timeout);
240 let verdict = probe.classify(&sui_out, &nix_out);
241 push_full_record(
242 probe, ctx, sui_argv, nix_argv, &sui_out, &nix_out, verdict, records,
243 );
244 verdict
245}
246
247fn push_record(
248 probe: &dyn ParityCheck,
249 ctx: &ProbeContext,
250 sui_argv: Option<Vec<String>>,
251 nix_argv: Option<Vec<String>>,
252 verdict: Verdict,
253 records: &mut Vec<ProbeRecord>,
254) {
255 records.push(ProbeRecord {
256 name: probe.name().to_string(),
257 kind: probe.kind(),
258 tags: probe.tags().to_vec(),
259 flake: ctx.flake_label.clone(),
260 sui_argv: sui_argv.unwrap_or_default(),
261 nix_argv: nix_argv.unwrap_or_default(),
262 sui_exit: None, nix_exit: None,
263 sui_stdout_excerpt: String::new(),
264 nix_stdout_excerpt: String::new(),
265 sui_stderr_excerpt: String::new(),
266 nix_stderr_excerpt: String::new(),
267 sui_duration_ms: 0, nix_duration_ms: 0,
268 sui_timed_out: false, nix_timed_out: false,
269 verdict,
270 });
271}
272
273#[allow(clippy::too_many_arguments)]
274fn push_full_record(
275 probe: &dyn ParityCheck,
276 ctx: &ProbeContext,
277 sui_argv: Vec<String>,
278 nix_argv: Vec<String>,
279 sui: &CapturedOutput,
280 nix: &CapturedOutput,
281 verdict: Verdict,
282 records: &mut Vec<ProbeRecord>,
283) {
284 records.push(ProbeRecord {
285 name: probe.name().to_string(),
286 kind: probe.kind(),
287 tags: probe.tags().to_vec(),
288 flake: ctx.flake_label.clone(),
289 sui_argv,
290 nix_argv,
291 sui_exit: sui.exit_code,
292 nix_exit: nix.exit_code,
293 sui_stdout_excerpt: excerpt(&sui.stdout, EXCERPT_CAP),
294 nix_stdout_excerpt: excerpt(&nix.stdout, EXCERPT_CAP),
295 sui_stderr_excerpt: excerpt(&sui.stderr, EXCERPT_CAP),
296 nix_stderr_excerpt: excerpt(&nix.stderr, EXCERPT_CAP),
297 sui_duration_ms: sui.duration.as_millis(),
298 nix_duration_ms: nix.duration.as_millis(),
299 sui_timed_out: sui.timed_out,
300 nix_timed_out: nix.timed_out,
301 verdict,
302 });
303}
304
305fn load_selected(corpus: Corpus) -> Result<Vec<Box<dyn ParityCheck>>, crate::SpecError> {
306 let mut out: Vec<Box<dyn ParityCheck>> = Vec::new();
307 match corpus {
308 Corpus::Parity => {
309 for p in crate::probe::load_canonical()? {
310 out.push(Box::new(p));
311 }
312 }
313 Corpus::BuiltinSmoke => {
314 for p in crate::probe::load_builtin_smoke()? {
315 out.push(Box::new(crate::probe::BuiltinSmokeProbe(p)));
319 }
320 }
321 Corpus::Rebuild => {
322 for p in crate::rebuild::load_canonical()? {
323 out.push(Box::new(p));
324 }
325 }
326 Corpus::All => {
327 for p in crate::probe::load_canonical()? {
328 out.push(Box::new(p));
329 }
330 for p in crate::probe::load_builtin_smoke()? {
331 out.push(Box::new(crate::probe::BuiltinSmokeProbe(p)));
332 }
333 for p in crate::rebuild::load_canonical()? {
334 out.push(Box::new(p));
335 }
336 }
337 }
338 Ok(out)
339}
340
341fn resolve_flakes(config: &SweepConfig) -> Vec<PathBuf> {
342 if !config.explicit_flakes.is_empty() {
343 return config.explicit_flakes.clone();
344 }
345 let mut out = Vec::new();
346 let Ok(read) = std::fs::read_dir(&config.flakes_root) else { return out; };
347 for entry in read.flatten() {
348 let p = entry.path();
349 if p.is_dir() && p.join("flake.nix").exists() {
350 out.push(p);
351 }
352 }
353 out.sort();
354 out
355}
356
357fn tag_filter_admits(tags: &[String], include: &[String], exclude: &[String]) -> bool {
358 if !exclude.is_empty() && tags.iter().any(|t| exclude.iter().any(|x| x == t)) {
359 return false;
360 }
361 if include.is_empty() {
362 return true;
363 }
364 tags.iter().any(|t| include.iter().any(|x| x == t))
365}
366
367fn detect_version(bin: &Path) -> Option<String> {
368 let mut cmd = std::process::Command::new(bin);
369 cmd.arg("--version");
370 match run_with_timeout(&mut cmd, Duration::from_secs(5)) {
371 Ok(out) if out.success => Some(out.stdout.trim().to_string()),
372 _ => None,
373 }
374}
375
376fn write_report(path: &Path, report: &ShadowReport) -> std::io::Result<()> {
377 if let Some(parent) = path.parent() {
378 std::fs::create_dir_all(parent)?;
379 }
380 let body = serde_json::to_string_pretty(report)
381 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
382 std::fs::write(path, body)
383}
384
385fn utc_now_iso8601() -> String {
386 let now = std::time::SystemTime::now()
391 .duration_since(std::time::UNIX_EPOCH)
392 .unwrap_or_default()
393 .as_secs() as i64;
394 format_unix_seconds(now)
395}
396
397#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::similar_names)]
398fn format_unix_seconds(mut secs: i64) -> String {
399 let sec_of_day = (secs % 86_400) as u32;
403 secs /= 86_400;
404 let hour = sec_of_day / 3600;
405 let min = (sec_of_day % 3600) / 60;
406 let sec = sec_of_day % 60;
407 let (year, month, day) = epoch_days_to_ymd(secs as i32);
408 format!("{year:04}-{month:02}-{day:02}T{hour:02}-{min:02}-{sec:02}Z")
409}
410
411fn epoch_days_to_ymd(mut days: i32) -> (i32, u32, u32) {
412 let mut year = 1970;
413 while days >= year_len(year) {
414 days -= year_len(year);
415 year += 1;
416 }
417 let mut month = 1u32;
418 while days >= month_len(year, month) as i32 {
419 days -= month_len(year, month) as i32;
420 month += 1;
421 }
422 (year, month, (days + 1) as u32)
423}
424
425fn year_len(year: i32) -> i32 {
426 if is_leap(year) { 366 } else { 365 }
427}
428
429fn month_len(year: i32, month: u32) -> u32 {
430 match month {
431 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
432 4 | 6 | 9 | 11 => 30,
433 2 if is_leap(year) => 29,
434 2 => 28,
435 _ => 0,
436 }
437}
438
439fn is_leap(year: i32) -> bool {
440 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446
447 #[test]
448 fn corpus_from_str_canonical_names() {
449 assert_eq!(Corpus::from_str("parity"), Some(Corpus::Parity));
450 assert_eq!(Corpus::from_str("builtins"), Some(Corpus::BuiltinSmoke));
451 assert_eq!(Corpus::from_str("builtin-smoke"), Some(Corpus::BuiltinSmoke));
452 assert_eq!(Corpus::from_str("rebuild"), Some(Corpus::Rebuild));
453 assert_eq!(Corpus::from_str("all"), Some(Corpus::All));
454 assert_eq!(Corpus::from_str("nope"), None);
455 }
456
457 #[test]
458 fn tag_filter_include_only() {
459 let tags = vec!["smoke".to_string(), "regression".to_string()];
460 assert!(tag_filter_admits(&tags, &["smoke".into()], &[]));
461 assert!(!tag_filter_admits(&tags, &["other".into()], &[]));
462 assert!(tag_filter_admits(&tags, &[], &[]));
463 }
464
465 #[test]
466 fn tag_filter_exclude_takes_priority() {
467 let tags = vec!["smoke".to_string(), "expensive".to_string()];
468 assert!(!tag_filter_admits(&tags, &[], &["expensive".into()]));
469 assert!(!tag_filter_admits(&tags, &["smoke".into()], &["expensive".into()]));
470 }
471
472 #[test]
473 fn timestamp_renders_iso_shape() {
474 assert_eq!(format_unix_seconds(0), "1970-01-01T00-00-00Z");
479 assert_eq!(format_unix_seconds(946_684_800), "2000-01-01T00-00-00Z");
480 assert_eq!(format_unix_seconds(1_704_067_200), "2024-01-01T00-00-00Z");
481 let mid = 86_400 + 12 * 3600 + 34 * 60 + 56;
483 assert_eq!(format_unix_seconds(mid), "1970-01-02T12-34-56Z");
484 }
485
486 #[test]
487 fn default_report_path_is_under_cache_dir() {
488 let p = default_report_path();
489 let s = p.display().to_string();
490 assert!(s.contains(".cache/sui/shadow-reports"));
491 assert!(s.ends_with(".json"));
492 }
493
494 #[test]
495 fn writing_report_creates_parent_dirs() {
496 let dir = tempfile::tempdir().unwrap();
497 let path = dir.path().join("nested/dir/report.json");
498 let report = ShadowReport {
499 generated_at: "ts".into(), generator: "gen".into(),
500 host: "h".into(), system: "s".into(),
501 os: "linux".into(), user: "u".into(),
502 sui_version: None, nix_version: None,
503 records: Vec::new(), tally: BTreeMap::new(),
504 };
505 write_report(&path, &report).unwrap();
506 assert!(path.exists());
507 }
508}