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