Skip to main content

alint_rules/
command_idempotent.rs

1//! `command_idempotent` — a declared check-mode command must be
2//! a no-op. The check-mode-idempotence sibling of
3//! `generated_file_fresh`: run a user-declared formatter/checker
4//! in its `--check` mode once (single-shot); exit `0` =
5//! formatter-clean, non-zero = violation(s). Optional
6//! `files_from` / `files_pattern` attributes per-file violations
7//! from the tool's own offender list. alint never runs a
8//! mutating formatter and never writes the working tree itself.
9//!
10//! Same trust tier as the `command` / `generated_file_fresh`
11//! rules: it spawns a user-supplied process, so it is trust-gated
12//! at config load by `alint_dsl::reject_command_rules_in` — only
13//! the user's own top-level config may declare it; an `extends:`'d
14//! ruleset (local / HTTPS / `alint://bundled/`) declaring it is
15//! refused. Design + open-question resolutions:
16//! `docs/design/v0.10/command_idempotent.md`.
17//!
18//! ```yaml
19//! - id: code-is-formatted
20//!   kind: command_idempotent
21//!   command: ["cargo", "fmt", "--all", "--", "--check"]
22//!   workdir: "."                       # default: lint root
23//!   files_from: stderr                 # none (default) | stdout | stderr
24//!   files_pattern: "Diff in (.+) at"   # optional regex, group 1 = path
25//!   level: error
26//! ```
27
28use 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
35/// Cap on the tool output captured into a fallback violation
36/// message — a noisy formatter can emit a lot; keep reports
37/// legible (mirrors the `command` rule's output cap intent).
38const OUTPUT_SNIPPET_CAP: usize = 400;
39
40#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
41#[serde(rename_all = "lowercase")]
42enum FilesFrom {
43    /// One violation for the whole invocation (default).
44    #[default]
45    None,
46    /// Parse the checker's stdout for the offender list.
47    Stdout,
48    /// Parse the checker's stderr for the offender list.
49    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    /// Regex whose capture group 1 is a file path, applied per
61    /// output line (only with `files_from`).
62    #[serde(default)]
63    files_pattern: Option<String>,
64    /// Child timeout in seconds. Default
65    /// [`crate::spawn::DEFAULT_SPAWN_TIMEOUT_SECS`].
66    #[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        // Single-shot: the verdict is one check-mode exit code,
88        // independent of which files changed (never `--changed`-
89        // filtered). `path_scope` stays `None` (default) so the
90        // engine doesn't skip-by-intersection. Same dispatch
91        // class as `pair` / `generated_file_fresh`.
92        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            // The tree is idempotent / formatter-clean.
133            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        // Non-zero exit: the tree is not formatter-clean. Either
143        // one whole-invocation violation, or — with `files_from` —
144        // one per offending file the tool itself listed.
145        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            // Non-zero exit but nothing parseable — never swallow
163            // a failure into a pass; fall back to one violation.
164            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    /// One violation per offending file extracted from `stream`
179    /// (per non-empty line: `files_pattern` group 1 if set, else
180    /// the whole trimmed line). Lines that don't match the pattern
181    /// are skipped.
182    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
210/// A short, trimmed snippet of the checker's output for a
211/// fallback message (lossy is fine for a hint; bounded so a noisy
212/// formatter can't blow up the report). Empty ⇒ no suffix.
213fn 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// Tests shell out to `/bin/sh` to exercise the spawn / exit-code
262// / offender-parsing paths without a per-OS fixture; gated to
263// Unix (no `/bin/sh` on Windows CI), mirroring the `command`
264// rule's test module.
265#[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        // gofmt -l / prettier --check shape: bare paths, one/line.
328        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        // cargo fmt --check shape: "Diff in <path> at line N".
344        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}