1use std::path::PathBuf;
29use std::time::Duration;
30
31use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Violation};
32use regex::Regex;
33use serde::Deserialize;
34
35const OUTPUT_SNIPPET_CAP: usize = 400;
39
40#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
41#[serde(rename_all = "lowercase")]
42enum FilesFrom {
43 #[default]
45 None,
46 Stdout,
48 Stderr,
50}
51
52#[derive(Debug, Deserialize)]
53#[serde(deny_unknown_fields)]
54struct Options {
55 command: Vec<String>,
56 #[serde(default)]
57 workdir: Option<String>,
58 #[serde(default)]
59 files_from: FilesFrom,
60 #[serde(default)]
63 files_pattern: Option<String>,
64 #[serde(default)]
67 timeout: Option<u64>,
68}
69
70#[derive(Debug)]
71pub struct CommandIdempotentRule {
72 id: String,
73 level: Level,
74 policy_url: Option<String>,
75 message: Option<String>,
76 command: Vec<String>,
77 workdir: String,
78 files_from: FilesFrom,
79 files_pattern: Option<Regex>,
80 timeout: u64,
81}
82
83impl Rule for CommandIdempotentRule {
84 alint_core::rule_common_impl!();
85
86 fn requires_full_index(&self) -> bool {
87 true
93 }
94
95 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
96 let env = [
97 ("ALINT_ROOT", ctx.root.to_string_lossy().into_owned()),
98 ("ALINT_RULE_ID", self.id.clone()),
99 ("ALINT_LEVEL", self.level.as_str().to_string()),
100 ];
101 let (status, stdout_b, stderr_b) = match crate::spawn::run_capturing(
102 &self.command,
103 &ctx.root.join(&self.workdir),
104 &env,
105 Duration::from_secs(self.timeout),
106 ) {
107 crate::spawn::SpawnOutcome::Exited {
108 status,
109 stdout,
110 stderr,
111 } => (status, stdout, stderr),
112 crate::spawn::SpawnOutcome::SpawnError(e) => {
113 let program = self.command.first().map_or("", String::as_str);
114 return Ok(vec![self.violation(
115 &self.workdir,
116 &format!("checker `{program}` could not be spawned: {e}"),
117 )]);
118 }
119 crate::spawn::SpawnOutcome::TimedOut { secs } => {
120 return Ok(vec![self.violation(
121 &self.workdir,
122 &format!(
123 "`{}` did not exit within {secs}s \
124 (raise `timeout:` on the rule to extend)",
125 self.command.join(" ")
126 ),
127 )]);
128 }
129 };
130
131 if status.success() {
132 return Ok(Vec::new());
134 }
135
136 let stdout = String::from_utf8_lossy(&stdout_b);
137 let stderr = String::from_utf8_lossy(&stderr_b);
138 let code = status
139 .code()
140 .map_or_else(|| "a signal".to_string(), |c| c.to_string());
141
142 let stream = match self.files_from {
146 FilesFrom::None => {
147 return Ok(vec![self.violation(
148 &self.workdir,
149 &format!(
150 "`{}` exited with {code} — the tree is not formatter-clean{}",
151 self.command.join(" "),
152 snippet(&stdout, &stderr),
153 ),
154 )]);
155 }
156 FilesFrom::Stdout => &stdout,
157 FilesFrom::Stderr => &stderr,
158 };
159
160 let violations = self.parse_offenders(stream);
161 if violations.is_empty() {
162 return Ok(vec![self.violation(
165 &self.workdir,
166 &format!(
167 "`{}` exited with {code} but no files matched `files_pattern`{}",
168 self.command.join(" "),
169 snippet(&stdout, &stderr),
170 ),
171 )]);
172 }
173 Ok(violations)
174 }
175}
176
177impl CommandIdempotentRule {
178 fn parse_offenders(&self, stream: &str) -> Vec<Violation> {
183 let mut out = Vec::new();
184 for line in stream.lines() {
185 let line = line.trim();
186 if line.is_empty() {
187 continue;
188 }
189 let path = match &self.files_pattern {
190 Some(re) => match re.captures(line).and_then(|c| c.get(1)) {
191 Some(m) => m.as_str(),
192 None => continue,
193 },
194 None => line,
195 };
196 out.push(self.violation(path, "not formatter-clean"));
197 }
198 out
199 }
200
201 fn violation(&self, path: &str, desc: &str) -> Violation {
202 let msg = self
203 .message
204 .clone()
205 .unwrap_or_else(|| format!("{path}: {desc}"));
206 Violation::new(msg).with_path(PathBuf::from(path))
207 }
208}
209
210fn snippet(stdout: &str, stderr: &str) -> String {
214 let joined = format!("{}\n{}", stdout.trim(), stderr.trim());
215 let s = joined.trim();
216 if s.is_empty() {
217 return String::new();
218 }
219 let snip: String = s.chars().take(OUTPUT_SNIPPET_CAP).collect();
220 format!(": {snip}")
221}
222
223pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
224 let opts: Options = spec
225 .deserialize_options()
226 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
227 if opts.command.is_empty() {
228 return Err(Error::rule_config(
229 &spec.id,
230 "command_idempotent requires a non-empty `command` argv \
231 (the checker to run in its --check / idempotence mode)",
232 ));
233 }
234 if opts.files_pattern.is_some() && opts.files_from == FilesFrom::None {
235 return Err(Error::rule_config(
236 &spec.id,
237 "command_idempotent `files_pattern` requires `files_from: stdout|stderr`",
238 ));
239 }
240 let files_pattern = match &opts.files_pattern {
241 Some(p) => Some(Regex::new(p).map_err(|e| {
242 Error::rule_config(&spec.id, format!("invalid `files_pattern` regex: {e}"))
243 })?),
244 None => None,
245 };
246 Ok(Box::new(CommandIdempotentRule {
247 id: spec.id.clone(),
248 level: spec.level,
249 policy_url: spec.policy_url.clone(),
250 message: spec.message.clone(),
251 command: opts.command,
252 workdir: opts.workdir.unwrap_or_else(|| ".".to_string()),
253 files_from: opts.files_from,
254 files_pattern,
255 timeout: opts
256 .timeout
257 .unwrap_or(crate::spawn::DEFAULT_SPAWN_TIMEOUT_SECS),
258 }))
259}
260
261#[cfg(all(test, unix))]
266mod tests {
267 use super::*;
268 use std::path::Path;
269
270 fn rule(
271 command: &[&str],
272 files_from: FilesFrom,
273 files_pattern: Option<&str>,
274 ) -> CommandIdempotentRule {
275 CommandIdempotentRule {
276 id: "t".into(),
277 level: Level::Error,
278 policy_url: None,
279 message: None,
280 command: command.iter().map(ToString::to_string).collect(),
281 workdir: ".".into(),
282 files_from,
283 files_pattern: files_pattern.map(|p| Regex::new(p).unwrap()),
284 timeout: 60,
285 }
286 }
287
288 fn eval(r: &CommandIdempotentRule, root: &Path) -> Vec<Violation> {
289 let idx = alint_core::FileIndex::from_entries(Vec::new());
290 let ctx = Context {
291 root,
292 index: &idx,
293 registry: None,
294 facts: None,
295 vars: None,
296 git_tracked: None,
297 git_blame: None,
298 };
299 r.evaluate(&ctx).unwrap()
300 }
301
302 #[test]
303 fn zero_exit_is_silent() {
304 let dir = tempfile::tempdir().unwrap();
305 let r = rule(&["/bin/sh", "-c", "exit 0"], FilesFrom::None, None);
306 assert!(eval(&r, dir.path()).is_empty());
307 }
308
309 #[test]
310 fn nonzero_exit_none_is_one_violation_with_output() {
311 let dir = tempfile::tempdir().unwrap();
312 let r = rule(
313 &["/bin/sh", "-c", "echo 'would reformat' >&2; exit 1"],
314 FilesFrom::None,
315 None,
316 );
317 let v = eval(&r, dir.path());
318 assert_eq!(v.len(), 1);
319 assert_eq!(v[0].path.as_deref(), Some(Path::new(".")));
320 assert!(v[0].message.contains("not formatter-clean"));
321 assert!(v[0].message.contains("would reformat"), "{:?}", v[0]);
322 }
323
324 #[test]
325 fn files_from_stdout_bare_paths_one_violation_each() {
326 let dir = tempfile::tempdir().unwrap();
327 let r = rule(
329 &["/bin/sh", "-c", "printf 'src/a.rs\\nsrc/b.rs\\n'; exit 1"],
330 FilesFrom::Stdout,
331 None,
332 );
333 let v = eval(&r, dir.path());
334 assert_eq!(v.len(), 2, "{v:?}");
335 let paths: Vec<_> = v.iter().filter_map(|x| x.path.as_deref()).collect();
336 assert!(paths.contains(&Path::new("src/a.rs")));
337 assert!(paths.contains(&Path::new("src/b.rs")));
338 }
339
340 #[test]
341 fn files_from_stderr_with_pattern_extracts_group_one() {
342 let dir = tempfile::tempdir().unwrap();
343 let script = "echo 'Diff in src/x.rs at line 4' >&2; \
345 echo 'noise that is not a path' >&2; \
346 echo 'Diff in src/y.rs at line 9' >&2; exit 1";
347 let r = rule(
348 &["/bin/sh", "-c", script],
349 FilesFrom::Stderr,
350 Some(r"Diff in (.+) at"),
351 );
352 let v = eval(&r, dir.path());
353 assert_eq!(v.len(), 2, "non-matching line skipped: {v:?}");
354 let paths: Vec<_> = v.iter().filter_map(|x| x.path.as_deref()).collect();
355 assert!(paths.contains(&Path::new("src/x.rs")));
356 assert!(paths.contains(&Path::new("src/y.rs")));
357 }
358
359 #[test]
360 fn nonzero_but_no_parseable_files_falls_back_not_silent() {
361 let dir = tempfile::tempdir().unwrap();
362 let r = rule(
363 &["/bin/sh", "-c", "echo 'totally unstructured' >&2; exit 1"],
364 FilesFrom::Stderr,
365 Some(r"^MATCH (.+)$"),
366 );
367 let v = eval(&r, dir.path());
368 assert_eq!(v.len(), 1, "must not swallow a failure: {v:?}");
369 assert!(v[0].message.contains("no files matched"));
370 }
371
372 #[test]
373 fn spawn_failure_is_a_violation() {
374 let dir = tempfile::tempdir().unwrap();
375 let r = rule(&["alint-no-such-checker-xyz"], FilesFrom::None, None);
376 let v = eval(&r, dir.path());
377 assert_eq!(v.len(), 1);
378 assert!(v[0].message.contains("could not be spawned"));
379 }
380
381 #[test]
382 fn build_errors_on_empty_command_and_pattern_without_files_from() {
383 let spec = crate::test_support::spec_yaml(
384 "id: t\nkind: command_idempotent\ncommand: []\nlevel: error\n",
385 );
386 assert!(
387 build(&spec)
388 .unwrap_err()
389 .to_string()
390 .contains("non-empty `command`")
391 );
392 let spec = crate::test_support::spec_yaml(
393 "id: t\nkind: command_idempotent\ncommand: [\"true\"]\n\
394 files_pattern: \"(.+)\"\nlevel: error\n",
395 );
396 assert!(
397 build(&spec)
398 .unwrap_err()
399 .to_string()
400 .contains("`files_pattern` requires `files_from")
401 );
402 }
403
404 #[test]
405 fn bad_files_pattern_regex_is_a_build_error() {
406 let spec = crate::test_support::spec_yaml(
407 "id: t\nkind: command_idempotent\ncommand: [\"true\"]\n\
408 files_from: stdout\nfiles_pattern: \"[\"\nlevel: error\n",
409 );
410 assert!(
411 build(&spec)
412 .unwrap_err()
413 .to_string()
414 .contains("invalid `files_pattern` regex")
415 );
416 }
417
418 #[test]
419 fn hung_checker_times_out_with_one_violation() {
420 let dir = tempfile::tempdir().unwrap();
421 let mut r = rule(&["sh", "-c", "sleep 5"], FilesFrom::None, None);
422 r.timeout = 1;
423 let v = eval(&r, dir.path());
424 assert_eq!(v.len(), 1, "a hung checker must yield one violation");
425 assert!(
426 v[0].message.contains("did not exit within 1s"),
427 "{:?}",
428 v[0].message
429 );
430 }
431}