use std::path::Path;
use std::process::Command;
use serde::{Deserialize, Serialize};
use tatara_lisp::DeriveTataraDomain;
use crate::SpecError;
use crate::cli::{nix_cli, sui_cli};
use crate::exec::CapturedOutput;
use crate::parity::{default_classify, ParityCheck, ProbeContext, ProbeKind, Verdict};
#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
#[tatara(keyword = "defprobe")]
pub struct Probe {
pub name: String,
pub expr: String,
pub classify: Classify,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum Classify {
JsonEqual,
AttrNamesEqual,
BothAreStorePaths,
}
impl ParityCheck for Probe {
fn name(&self) -> &str { &self.name }
fn tags(&self) -> &[String] { &self.tags }
fn kind(&self) -> ProbeKind { ProbeKind::Eval }
fn sui_invocation(&self, ctx: &ProbeContext, sui_bin: &Path) -> Command {
sui_cli::eval_expr(sui_bin, &ctx.substitute(&self.expr))
}
fn nix_invocation(&self, ctx: &ProbeContext, nix_bin: &Path) -> Command {
nix_cli::eval_expr(nix_bin, &ctx.substitute(&self.expr))
}
fn classify(&self, sui: &CapturedOutput, nix: &CapturedOutput) -> Verdict {
let mode = self.classify;
default_classify(sui, nix, |s, n| {
classify_outputs(mode, s.stdout.trim(), n.stdout.trim())
})
}
}
fn classify_outputs(mode: Classify, sui: &str, nix: &str) -> bool {
match mode {
Classify::JsonEqual => sui == nix,
Classify::AttrNamesEqual => {
let sui_v: Option<Vec<String>> = serde_json::from_str(sui).ok();
let nix_v: Option<Vec<String>> = serde_json::from_str(nix).ok();
match (sui_v, nix_v) {
(Some(mut a), Some(mut b)) => {
a.sort();
b.sort();
a == b
}
_ => false,
}
}
Classify::BothAreStorePaths => {
let is_sp = |s: &str| s.trim_matches('"').starts_with("/nix/store/");
is_sp(sui) && is_sp(nix)
}
}
}
pub struct BuiltinSmokeProbe(pub Probe);
impl ParityCheck for BuiltinSmokeProbe {
fn name(&self) -> &str { self.0.name() }
fn tags(&self) -> &[String] { self.0.tags() }
fn kind(&self) -> ProbeKind { ProbeKind::BuiltinSmoke }
fn applies(&self, ctx: &ProbeContext) -> bool { self.0.applies(ctx) }
fn sui_invocation(&self, ctx: &ProbeContext, sui_bin: &Path) -> Command {
self.0.sui_invocation(ctx, sui_bin)
}
fn nix_invocation(&self, ctx: &ProbeContext, nix_bin: &Path) -> Command {
self.0.nix_invocation(ctx, nix_bin)
}
fn classify(&self, sui: &CapturedOutput, nix: &CapturedOutput) -> Verdict {
self.0.classify(sui, nix)
}
}
pub const CANONICAL_PROBES_LISP: &str = include_str!("../specs/parity_probes.lisp");
pub const CANONICAL_BUILTIN_SMOKE_LISP: &str =
include_str!("../specs/builtin_smoke_probes.lisp");
pub fn load_canonical() -> Result<Vec<Probe>, SpecError> {
crate::loader::load_all::<Probe>(CANONICAL_PROBES_LISP)
}
pub fn load_builtin_smoke() -> Result<Vec<Probe>, SpecError> {
crate::loader::load_all::<Probe>(CANONICAL_BUILTIN_SMOKE_LISP)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_corpus_parses() {
let probes = load_canonical().expect("canonical probes must compile");
assert!(!probes.is_empty(), "corpus must contain at least one probe");
for p in &probes {
assert!(!p.name.is_empty(), "probe must have a name: {p:?}");
let context_free =
p.tags.iter().any(|t| t == "context-free");
assert!(p.expr.contains("$FLAKE") || context_free,
"probe {} must contain $FLAKE placeholder OR carry the \"context-free\" tag",
p.name);
}
}
#[test]
fn canonical_corpus_has_the_flake_shape_probes() {
let probes = load_canonical().unwrap();
let names: Vec<&str> = probes.iter().map(|p| p.name.as_str()).collect();
for required in ["getflake-outPath", "getflake-outputs-keys"] {
assert!(names.contains(&required),
"canonical corpus must contain {required}: have {names:?}");
}
}
#[test]
fn builtin_smoke_corpus_parses() {
let probes = load_builtin_smoke().expect("builtin-smoke corpus must compile");
assert!(
probes.len() >= 19,
"builtin smoke corpus is sparse — got {}, expected ≥19 (one per module)",
probes.len(),
);
for p in &probes {
assert!(
p.tags.iter().any(|t| t == "builtin-smoke"),
"builtin-smoke probe {} missing :tags (\"builtin-smoke\" …)",
p.name,
);
}
}
#[test]
fn parity_check_impl_constructs_typed_invocation() {
let probe = Probe {
name: "test".into(),
expr: "1 + 2".into(),
classify: Classify::JsonEqual,
tags: vec![],
};
let ctx = ProbeContext::current(std::path::PathBuf::from("/tmp/flake"));
let cmd = probe.sui_invocation(&ctx, Path::new("/usr/local/bin/sui"));
let argv = crate::exec::command_argv(&cmd);
assert_eq!(argv[0], "/usr/local/bin/sui");
assert_eq!(argv[1], "eval");
assert!(argv.contains(&"1 + 2".to_string()));
}
}