Skip to main content

scute_test_utils/
lib.rs

1#![allow(
2    clippy::must_use_candidate,
3    clippy::missing_panics_doc,
4    clippy::return_self_not_must_use
5)]
6
7mod cli;
8pub mod mcp;
9mod project;
10
11use std::path::{Path, PathBuf};
12
13use cli::CliBackend;
14use mcp::McpBackend;
15pub use project::TestProject;
16use tempfile::TempDir;
17
18/// How the check process terminated, from the interface's perspective.
19///
20/// CLI maps exit codes: 0 → Success, 1 → Failure, 2 → Error.
21/// MCP maps `isError` + JSON shape: no error → Success, isError + findings → Failure,
22/// isError + error object → Error.
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub(crate) enum ExitStatus {
25    Success,
26    Failure,
27    Error,
28}
29
30#[derive(Debug, Clone, Copy)]
31pub enum Interface {
32    Cli,
33    CliStdin,
34    Mcp,
35}
36
37impl std::fmt::Display for Interface {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            Self::Cli => write!(f, "cli"),
41            Self::CliStdin => write!(f, "cli_stdin"),
42            Self::Mcp => write!(f, "mcp"),
43        }
44    }
45}
46
47trait Backend {
48    fn check(&self, dir: TempDir, working_dir: &Path, args: &[&str]) -> CheckResult;
49    fn list_checks(&self, dir: TempDir) -> ListChecksResult;
50}
51
52pub struct Scute {
53    backend: Box<dyn Backend>,
54    project: TestProject,
55    cwd: Option<String>,
56}
57
58impl Scute {
59    pub fn new(interface: Interface) -> Self {
60        match interface {
61            Interface::Cli => Self::cli(),
62            Interface::CliStdin => Self::cli_stdin(),
63            Interface::Mcp => Self::mcp(),
64        }
65    }
66
67    pub fn cli() -> Self {
68        Self {
69            backend: Box::new(CliBackend { stdin: false }),
70            project: TestProject::cargo(),
71            cwd: None,
72        }
73    }
74
75    pub fn cli_stdin() -> Self {
76        Self {
77            backend: Box::new(CliBackend { stdin: true }),
78            project: TestProject::cargo(),
79            cwd: None,
80        }
81    }
82
83    pub fn mcp() -> Self {
84        Self {
85            backend: Box::new(McpBackend),
86            project: TestProject::cargo(),
87            cwd: None,
88        }
89    }
90
91    pub fn dependency(mut self, name: &str, version: &str) -> Self {
92        self.project = self.project.dependency(name, version);
93        self
94    }
95
96    pub fn dev_dependency(mut self, name: &str, version: &str) -> Self {
97        self.project = self.project.dev_dependency(name, version);
98        self
99    }
100
101    pub fn source_file(mut self, name: &str, content: &str) -> Self {
102        self.project = self.project.source_file(name, content);
103        self
104    }
105
106    pub fn scute_config(mut self, yaml: &str) -> Self {
107        self.project = self.project.scute_config(yaml);
108        self
109    }
110
111    /// Run the check from a subdirectory instead of the project root.
112    pub fn cwd(mut self, subdir: &str) -> Self {
113        self.cwd = Some(subdir.into());
114        self
115    }
116
117    pub fn list_checks(self) -> ListChecksResult {
118        let dir = self.project.build();
119        self.backend.list_checks(dir)
120    }
121
122    pub fn check(self, args: &[&str]) -> CheckResult {
123        let mut full_args = vec!["check"];
124        full_args.extend_from_slice(args);
125        let dir = self.project.build();
126        let working_dir = match &self.cwd {
127            Some(subdir) => {
128                let path = dir.path().join(subdir);
129                std::fs::create_dir_all(&path).expect("failed to create cwd subdir");
130                path
131            }
132            None => dir.path().to_path_buf(),
133        };
134        self.backend.check(dir, &working_dir, &full_args)
135    }
136}
137
138/// The result of listing available checks. Use its methods to assert on which checks are present.
139pub struct ListChecksResult {
140    pub(crate) _dir: TempDir,
141    pub(crate) checks: Vec<String>,
142}
143
144impl ListChecksResult {
145    pub fn expect_contains(&self, name: &str) -> &Self {
146        assert!(
147            self.checks.iter().any(|c| c == name),
148            "expected check '{name}' in {:?}",
149            self.checks
150        );
151        self
152    }
153}
154
155/// The result of running a check. Use its methods to assert on status, findings, and evidence.
156pub struct CheckResult {
157    pub(crate) _dir: TempDir,
158    pub(crate) json: serde_json::Value,
159    pub(crate) project_dir: PathBuf,
160    pub(crate) exit_status: ExitStatus,
161    pub(crate) debug_info: String,
162}
163
164impl CheckResult {
165    pub fn expect_pass(&self) -> &Self {
166        let summary = self.summary();
167        assert!(
168            summary["failed"] == 0
169                && summary["errored"] == 0
170                && summary["passed"].as_u64() > Some(0),
171            "expected pass, got: {}",
172            self.json
173        );
174        self.assert_exit_status(ExitStatus::Success);
175        self
176    }
177
178    pub fn expect_warn(&self) -> &Self {
179        self.assert_summary_nonzero("warned");
180        self.assert_exit_status(ExitStatus::Success);
181        self
182    }
183
184    pub fn expect_fail(&self) -> &Self {
185        self.assert_summary_nonzero("failed");
186        self.assert_exit_status(ExitStatus::Failure);
187        self
188    }
189
190    pub fn expect_target(&self, expected: &str) -> &Self {
191        assert_eq!(self.first_finding()["target"], expected);
192        self
193    }
194
195    pub fn expect_target_contains(&self, substring: &str) -> &Self {
196        let target = self.first_finding()["target"]
197            .as_str()
198            .expect("target should be a string");
199        assert!(
200            target.contains(substring),
201            "expected target to contain '{substring}', got '{target}'"
202        );
203        self
204    }
205
206    pub fn expect_target_matches_dir(&self) -> &Self {
207        let target = self.first_finding()["target"]
208            .as_str()
209            .expect("target should be a string");
210        assert_eq!(
211            std::path::Path::new(target).canonicalize().unwrap(),
212            self.project_dir
213        );
214        self
215    }
216
217    pub fn expect_observed(&self, expected: u64) -> &Self {
218        assert_eq!(self.first_finding()["measurement"]["observed"], expected);
219        self
220    }
221
222    pub fn expect_evidence_rule(&self, index: usize, rule: &str) -> &Self {
223        assert_eq!(self.first_finding()["evidence"][index]["rule"], rule);
224        self
225    }
226
227    pub fn expect_evidence_has_expected(&self, index: usize) -> &Self {
228        assert!(
229            !self.first_finding()["evidence"][index]["expected"].is_null(),
230            "expected evidence[{index}].expected to be present"
231        );
232        self
233    }
234
235    pub fn expect_evidence_no_expected(&self, index: usize) -> &Self {
236        assert!(
237            self.first_finding()["evidence"][index]
238                .get("expected")
239                .is_none(),
240            "expected evidence[{index}].expected to be absent"
241        );
242        self
243    }
244
245    pub fn expect_finding_count(&self, expected: usize) -> &Self {
246        assert_eq!(
247            self.findings().len(),
248            expected,
249            "expected {expected} findings, got {}",
250            self.findings().len()
251        );
252        self
253    }
254
255    pub fn expect_no_findings(&self) -> &Self {
256        assert!(
257            self.findings().is_empty(),
258            "expected no findings, got: {:?}",
259            self.findings()
260        );
261        self
262    }
263
264    pub fn expect_error(&self, code: &str) -> &Self {
265        let error = &self.json["error"];
266        assert_eq!(error["code"], code, "got: {}", self.json);
267        assert!(
268            error["message"].is_string(),
269            "error.message should be present"
270        );
271        assert!(
272            error["recovery"].is_string(),
273            "error.recovery should be present"
274        );
275        self.assert_exit_status(ExitStatus::Error);
276        self
277    }
278
279    pub fn debug(&self) -> &Self {
280        eprintln!("{}", self.debug_info);
281        eprintln!("json: {}", self.json);
282        self
283    }
284
285    fn summary(&self) -> &serde_json::Value {
286        &self.json["summary"]
287    }
288
289    fn findings(&self) -> &Vec<serde_json::Value> {
290        self.json["findings"]
291            .as_array()
292            .expect("findings should be an array")
293    }
294
295    fn first_finding(&self) -> &serde_json::Value {
296        self.findings()
297            .first()
298            .expect("expected at least one finding")
299    }
300
301    fn assert_summary_nonzero(&self, field: &str) {
302        assert!(
303            self.summary()[field].as_u64() > Some(0),
304            "expected at least one {field}, got: {}",
305            self.json
306        );
307    }
308
309    fn assert_exit_status(&self, expected: ExitStatus) {
310        assert_eq!(
311            self.exit_status, expected,
312            "expected {expected:?}, got {:?}:\n{}",
313            self.exit_status, self.debug_info
314        );
315    }
316}
317
318pub fn target_bin(name: &str) -> std::path::PathBuf {
319    let mut dir = std::env::current_exe().expect("need current_exe for binary lookup");
320    dir.pop();
321    if dir.ends_with("deps") {
322        dir.pop();
323    }
324    dir.join(format!("{name}{}", std::env::consts::EXE_SUFFIX))
325}