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
18pub 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#[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 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
180pub 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
197pub 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}