Skip to main content

sui_spec/
sweep.rs

1//! Library entry point for the shadow-sweep loop.
2//!
3//! Both the `sui-sweep` binary and the (future) `sui rebuild-shadow`
4//! subcommand wrap this module — there is exactly one driver for the
5//! "load corpus × walk flakes × run dual subprocess × classify ×
6//! report" pipeline.  Per the prime directive: solve once, in one
7//! place, at one time.
8
9use 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
18/// Size cap (bytes) for stdout/stderr excerpts kept in the report.
19pub const EXCERPT_CAP: usize = 4096;
20
21/// Default per-probe timeout in seconds.
22pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
23
24/// Which corpus (or all of them) to drive in one sweep.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Corpus {
27    /// `parity_probes.lisp` — single-expression eval probes.
28    Parity,
29    /// `builtin_smoke_probes.lisp` — per-builtin-module smoke tests.
30    BuiltinSmoke,
31    /// `rebuild_probes.lisp` — host-aware rebuild-stage probes.
32    Rebuild,
33    /// All three corpora.
34    All,
35}
36
37impl Corpus {
38    /// Parse from operator-supplied string.  Returns `None` if the
39    /// string doesn't match any known corpus.
40    #[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    /// Stable identifier for log / report consumption.
52    #[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/// Sweep configuration.  Defaults match the legacy `sui-sweep` shape;
64/// the operator overrides only what they want.
65#[derive(Debug, Clone)]
66pub struct SweepConfig {
67    /// Path to the sui binary under test.
68    pub sui_bin: PathBuf,
69    /// Path to the cppnix oracle binary.
70    pub nix_bin: PathBuf,
71    /// Root directory to walk for `flake.nix` files when no explicit
72    /// flakes are supplied.
73    pub flakes_root: PathBuf,
74    /// Explicit list of flake directories.  If non-empty, overrides
75    /// `flakes_root`.
76    pub explicit_flakes: Vec<PathBuf>,
77    /// Probes must carry at least one of these tags to be selected.
78    /// Empty = no include filter.
79    pub include_tags: Vec<String>,
80    /// Probes carrying any of these tags are excluded.
81    pub exclude_tags: Vec<String>,
82    /// Per-probe timeout.
83    pub timeout: Duration,
84    /// Which corpus (or all) to drive.
85    pub corpus: Corpus,
86    /// `true` = print per-probe diagnostics to stderr.
87    pub verbose: bool,
88    /// Where to write the JSON report.  `None` = skip write but still
89    /// return the report value to the caller.
90    pub report_path: Option<PathBuf>,
91}
92
93impl SweepConfig {
94    /// Defaults aligned with the legacy `sui-sweep` binary.
95    #[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
116/// Run the sweep, returning a [`ShadowReport`].  Writes the report to
117/// `config.report_path` if set.
118///
119/// # Errors
120///
121/// Returns an error if any corpus fails to compile, or if writing the
122/// report file fails.
123pub 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            // Honour tag filters first — cheaper than constructing an
147            // invocation we'll discard.
148            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/// Decide on a default report path: `~/.cache/sui/shadow-reports/<host>-<ts>.json`.
215#[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
227// ── Internals ──────────────────────────────────────────────────────
228
229fn 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                // builtin-smoke probes carry the "builtin-smoke" tag,
316                // but the kind is reported via the ProbeKind enum on
317                // the wrapper below.
318                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    // Tiny dependency-free formatter — good enough for filenames + sort
387    // order; full chrono would compound a giant transitive dep tree for
388    // a six-line job.  Output: `YYYY-MM-DDTHH-MM-SSZ` (colons replaced
389    // because they'd be illegal on Windows + awkward on macOS).
390    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    // 1970-01-01 is a Thursday; we walk days forward and account for
400    // leap years.  Range: 1970 .. 2999 — good enough for any operator
401    // who's ever going to read this report.
402    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        // Anchors that don't drift with the system clock:
475        // 0           = 1970-01-01 00:00:00 UTC (epoch).
476        // 946_684_800 = 2000-01-01 00:00:00 UTC (Y2K).
477        // 1_704_067_200 = 2024-01-01 00:00:00 UTC (leap year sanity).
478        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        // Mid-day formatting — 1970-01-02 12:34:56 UTC.
482        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}