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 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#[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 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
184pub 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
201pub 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}