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