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#[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 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
138pub 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
155pub 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}