1use std::path::Path;
39use std::process::Command;
40
41use serde::{Deserialize, Serialize};
42use tatara_lisp::DeriveTataraDomain;
43
44use crate::SpecError;
45use crate::cli::{nix_cli, sui_cli};
46use crate::exec::CapturedOutput;
47use crate::parity::{default_classify, ParityCheck, ProbeContext, ProbeKind, Verdict};
48
49#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
52#[tatara(keyword = "defprobe")]
53pub struct Probe {
54 pub name: String,
55 pub expr: String,
56 pub classify: Classify,
57 #[serde(default)]
58 pub tags: Vec<String>,
59}
60
61#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Classify {
67 JsonEqual,
69 AttrNamesEqual,
73 BothAreStorePaths,
77}
78
79impl ParityCheck for Probe {
82 fn name(&self) -> &str { &self.name }
83 fn tags(&self) -> &[String] { &self.tags }
84 fn kind(&self) -> ProbeKind { ProbeKind::Eval }
85
86 fn sui_invocation(&self, ctx: &ProbeContext, sui_bin: &Path) -> Command {
87 sui_cli::eval_expr(sui_bin, &ctx.substitute(&self.expr))
88 }
89
90 fn nix_invocation(&self, ctx: &ProbeContext, nix_bin: &Path) -> Command {
91 nix_cli::eval_expr(nix_bin, &ctx.substitute(&self.expr))
92 }
93
94 fn classify(&self, sui: &CapturedOutput, nix: &CapturedOutput) -> Verdict {
95 let mode = self.classify;
96 default_classify(sui, nix, |s, n| {
97 classify_outputs(mode, s.stdout.trim(), n.stdout.trim())
98 })
99 }
100}
101
102fn classify_outputs(mode: Classify, sui: &str, nix: &str) -> bool {
103 match mode {
104 Classify::JsonEqual => sui == nix,
105 Classify::AttrNamesEqual => {
106 let sui_v: Option<Vec<String>> = serde_json::from_str(sui).ok();
107 let nix_v: Option<Vec<String>> = serde_json::from_str(nix).ok();
108 match (sui_v, nix_v) {
109 (Some(mut a), Some(mut b)) => {
110 a.sort();
111 b.sort();
112 a == b
113 }
114 _ => false,
115 }
116 }
117 Classify::BothAreStorePaths => {
118 let is_sp = |s: &str| s.trim_matches('"').starts_with("/nix/store/");
119 is_sp(sui) && is_sp(nix)
120 }
121 }
122}
123
124pub struct BuiltinSmokeProbe(pub Probe);
131
132impl ParityCheck for BuiltinSmokeProbe {
133 fn name(&self) -> &str { self.0.name() }
134 fn tags(&self) -> &[String] { self.0.tags() }
135 fn kind(&self) -> ProbeKind { ProbeKind::BuiltinSmoke }
136 fn applies(&self, ctx: &ProbeContext) -> bool { self.0.applies(ctx) }
137 fn sui_invocation(&self, ctx: &ProbeContext, sui_bin: &Path) -> Command {
138 self.0.sui_invocation(ctx, sui_bin)
139 }
140 fn nix_invocation(&self, ctx: &ProbeContext, nix_bin: &Path) -> Command {
141 self.0.nix_invocation(ctx, nix_bin)
142 }
143 fn classify(&self, sui: &CapturedOutput, nix: &CapturedOutput) -> Verdict {
144 self.0.classify(sui, nix)
145 }
146}
147
148pub const CANONICAL_PROBES_LISP: &str = include_str!("../specs/parity_probes.lisp");
151
152pub const CANONICAL_BUILTIN_SMOKE_LISP: &str =
153 include_str!("../specs/builtin_smoke_probes.lisp");
154
155pub fn load_canonical() -> Result<Vec<Probe>, SpecError> {
162 crate::loader::load_all::<Probe>(CANONICAL_PROBES_LISP)
163}
164
165pub fn load_builtin_smoke() -> Result<Vec<Probe>, SpecError> {
171 crate::loader::load_all::<Probe>(CANONICAL_BUILTIN_SMOKE_LISP)
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn canonical_corpus_parses() {
180 let probes = load_canonical().expect("canonical probes must compile");
181 assert!(!probes.is_empty(), "corpus must contain at least one probe");
182 for p in &probes {
188 assert!(!p.name.is_empty(), "probe must have a name: {p:?}");
189 let context_free =
190 p.tags.iter().any(|t| t == "context-free");
191 assert!(p.expr.contains("$FLAKE") || context_free,
192 "probe {} must contain $FLAKE placeholder OR carry the \"context-free\" tag",
193 p.name);
194 }
195 }
196
197 #[test]
198 fn canonical_corpus_has_the_flake_shape_probes() {
199 let probes = load_canonical().unwrap();
200 let names: Vec<&str> = probes.iter().map(|p| p.name.as_str()).collect();
201 for required in ["getflake-outPath", "getflake-outputs-keys"] {
205 assert!(names.contains(&required),
206 "canonical corpus must contain {required}: have {names:?}");
207 }
208 }
209
210 #[test]
211 fn builtin_smoke_corpus_parses() {
212 let probes = load_builtin_smoke().expect("builtin-smoke corpus must compile");
213 assert!(
216 probes.len() >= 19,
217 "builtin smoke corpus is sparse — got {}, expected ≥19 (one per module)",
218 probes.len(),
219 );
220 for p in &probes {
221 assert!(
222 p.tags.iter().any(|t| t == "builtin-smoke"),
223 "builtin-smoke probe {} missing :tags (\"builtin-smoke\" …)",
224 p.name,
225 );
226 }
227 }
228
229 #[test]
230 fn parity_check_impl_constructs_typed_invocation() {
231 let probe = Probe {
232 name: "test".into(),
233 expr: "1 + 2".into(),
234 classify: Classify::JsonEqual,
235 tags: vec![],
236 };
237 let ctx = ProbeContext::current(std::path::PathBuf::from("/tmp/flake"));
238 let cmd = probe.sui_invocation(&ctx, Path::new("/usr/local/bin/sui"));
239 let argv = crate::exec::command_argv(&cmd);
240 assert_eq!(argv[0], "/usr/local/bin/sui");
241 assert_eq!(argv[1], "eval");
242 assert!(argv.contains(&"1 + 2".to_string()));
243 }
244}