Skip to main content

testing_conventions/
workflow.rs

1//! Workflow guard — flag a workflow invocation naming a subcommand the CLI no longer
2//! exposes. Extraction is a line-based, shell-aware scan, not a GitHub Actions parser.
3
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7
8use crate::violation::Violation;
9
10/// A single `testing-conventions` invocation found in a workflow file.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Invocation {
13    pub file: PathBuf,
14    /// 1-based line of the invocation.
15    pub line: usize,
16    /// Tokens after the `testing-conventions` binary name, in order.
17    pub args: Vec<String>,
18}
19
20/// Every `testing-conventions` invocation under `path` — a workflow file or a directory
21/// of them — in file-then-line order.
22pub fn invocations(path: impl AsRef<Path>) -> Result<Vec<Invocation>> {
23    let path = path.as_ref();
24    let mut files = Vec::new();
25    collect_workflow_files(path, &mut files)?;
26    files.sort();
27    let mut out = Vec::new();
28    for file in files {
29        let text = std::fs::read_to_string(&file)
30            .with_context(|| format!("reading workflow `{}`", file.display()))?;
31        for (i, line) in text.lines().enumerate() {
32            if let Some(args) = line_invocation(line) {
33                out.push(Invocation {
34                    file: file.clone(),
35                    line: i + 1,
36                    args,
37                });
38            }
39        }
40    }
41    Ok(out)
42}
43
44/// Collect workflow files under `path` into `out`: `path` itself when it is a file, else
45/// every `*.yml` / `*.yaml` under it, recursively.
46fn collect_workflow_files(path: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
47    if path.is_file() {
48        out.push(path.to_path_buf());
49        return Ok(());
50    }
51    let entries = std::fs::read_dir(path)
52        .with_context(|| format!("reading directory `{}`", path.display()))?;
53    for entry in entries {
54        let entry =
55            entry.with_context(|| format!("reading an entry under `{}`", path.display()))?;
56        let child = entry.path();
57        if child.is_dir() {
58            collect_workflow_files(&child, out)?;
59        } else if is_workflow_file(&child) {
60            out.push(child);
61        }
62    }
63    Ok(())
64}
65
66/// `true` when `path` has a `.yml` / `.yaml` extension.
67fn is_workflow_file(path: &Path) -> bool {
68    matches!(
69        path.extension().and_then(|e| e.to_str()),
70        Some("yml" | "yaml")
71    )
72}
73
74/// The args of a `testing-conventions` invocation on `line`, or `None`. Comments are
75/// ignored and surrounding quotes stripped.
76fn line_invocation(line: &str) -> Option<Vec<String>> {
77    let tokens = tokenize(line);
78    let pos = tokens.iter().position(|t| is_binary_token(t))?;
79    if !is_command_position(&tokens, pos) {
80        return None;
81    }
82    Some(tokens[pos + 1..].to_vec())
83}
84
85/// `true` when the binary token at `pos` is the command being run, not an argument to
86/// another command (`pip install testing-conventions` is an argument).
87fn is_command_position(tokens: &[String], pos: usize) -> bool {
88    let mut i = pos;
89    // Stepping back over an `npx` launcher and its flags keeps `npx -y testing-conventions`
90    // in command position.
91    if i > 0 {
92        let mut j = i;
93        while j > 0 && tokens[j - 1].starts_with('-') {
94            j -= 1;
95        }
96        if j > 0 && tokens[j - 1] == "npx" {
97            i = j - 1;
98        }
99    }
100    match i.checked_sub(1) {
101        None => true,
102        Some(prev) => is_command_boundary(&tokens[prev]),
103    }
104}
105
106/// `true` when `token` ends a command: the YAML `run:` lead-in, or a shell separator.
107fn is_command_boundary(token: &str) -> bool {
108    matches!(token, "run:" | "&&" | "||" | "|" | ";" | "&" | "(" | "{")
109}
110
111/// `true` when `token` is the bare `testing-conventions` command word, optionally
112/// version-pinned. A path-qualified token is not matched.
113fn is_binary_token(token: &str) -> bool {
114    // Strip any version pin / shell expansion suffix, then require an exact match.
115    let end = [token.find('@'), token.find("${")]
116        .into_iter()
117        .flatten()
118        .min()
119        .unwrap_or(token.len());
120    &token[..end] == "testing-conventions"
121}
122
123/// Split `line` into shell-ish tokens: whitespace separates, `'…'` and `"…"` group (and
124/// are stripped), and an unquoted `#` starting a token comments out the rest.
125fn tokenize(line: &str) -> Vec<String> {
126    let mut tokens = Vec::new();
127    let mut cur = String::new();
128    let mut started = false;
129    let mut quote: Option<char> = None;
130    for c in line.chars() {
131        match quote {
132            Some(q) => {
133                if c == q {
134                    quote = None;
135                } else {
136                    cur.push(c);
137                }
138            }
139            None => match c {
140                '#' if !started => break,
141                '\'' | '"' => {
142                    quote = Some(c);
143                    started = true;
144                }
145                c if c.is_whitespace() => {
146                    if started {
147                        tokens.push(std::mem::take(&mut cur));
148                        started = false;
149                    }
150                }
151                c => {
152                    cur.push(c);
153                    started = true;
154                }
155            },
156        }
157    }
158    if started {
159        tokens.push(cur);
160    }
161    tokens
162}
163
164/// Of `invocations`, the ones whose subcommand chain names a subcommand the clap tree
165/// `root` no longer exposes.
166pub fn unknown_subcommands(invocations: &[Invocation], root: &clap::Command) -> Vec<Violation> {
167    let mut out = Vec::new();
168    for inv in invocations {
169        let mut node = root;
170        let mut i = 0;
171        while i < inv.args.len() {
172            // Past the last subcommand the remaining tokens are positionals, so walking on
173            // would flag a path argument as an unknown subcommand.
174            if !node.has_subcommands() {
175                break;
176            }
177            let tok = &inv.args[i];
178            if tok.starts_with('-') {
179                i += if flag_takes_value(node, tok) { 2 } else { 1 };
180                continue;
181            }
182            match node.find_subcommand(tok.as_str()) {
183                Some(sub) => {
184                    node = sub;
185                    i += 1;
186                }
187                None => {
188                    out.push(Violation {
189                        file: inv.file.clone(),
190                        line: inv.line,
191                        rule: "no-unknown-subcommand",
192                        message: format!(
193                            "`{}` is not a `{}` subcommand — the published binary no longer exposes it",
194                            tok,
195                            node.get_name()
196                        ),
197                    });
198                    break;
199                }
200            }
201        }
202    }
203    out
204}
205
206/// `true` when the flag `token` is an option of `node` that consumes a following value,
207/// so the subcommand walk must skip that value too.
208fn flag_takes_value(node: &clap::Command, token: &str) -> bool {
209    if token.contains('=') {
210        return false;
211    }
212    let name = token.trim_start_matches('-');
213    node.get_arguments().any(|arg| {
214        let matches_long = arg.get_long() == Some(name);
215        let matches_short = name.len() == 1 && arg.get_short().is_some_and(|c| name.starts_with(c));
216        (matches_long || matches_short)
217            && matches!(
218                arg.get_action(),
219                clap::ArgAction::Set | clap::ArgAction::Append
220            )
221    })
222}
223
224/// One [`Violation`] per invocation under `path` naming a subcommand `root` no longer
225/// exposes.
226pub fn check(path: impl AsRef<Path>, root: &clap::Command) -> Result<Vec<Violation>> {
227    Ok(unknown_subcommands(&invocations(path)?, root))
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use std::sync::atomic::{AtomicU64, Ordering};
234
235    struct TempTree(PathBuf);
236
237    impl TempTree {
238        fn new(files: &[(&str, &str)]) -> Self {
239            static COUNTER: AtomicU64 = AtomicU64::new(0);
240            let root = std::env::temp_dir().join(format!(
241                "tc-workflow-{}-{}",
242                std::process::id(),
243                COUNTER.fetch_add(1, Ordering::Relaxed),
244            ));
245            for (rel, content) in files {
246                let path = root.join(rel);
247                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
248                std::fs::write(path, content).unwrap();
249            }
250            TempTree(root)
251        }
252
253        fn path(&self) -> &Path {
254            &self.0
255        }
256    }
257
258    impl Drop for TempTree {
259        fn drop(&mut self) {
260            let _ = std::fs::remove_dir_all(&self.0);
261        }
262    }
263
264    #[test]
265    fn tokenize_strips_quotes_and_groups() {
266        assert_eq!(
267            tokenize(r#"npx -y "testing-conventions${VERSION:+@$VERSION}" unit coverage"#),
268            vec![
269                "npx",
270                "-y",
271                "testing-conventions${VERSION:+@$VERSION}",
272                "unit",
273                "coverage",
274            ]
275        );
276    }
277
278    #[test]
279    fn tokenize_stops_at_a_comment() {
280        assert_eq!(
281            tokenize("      # run testing-conventions later"),
282            Vec::<String>::new()
283        );
284        assert_eq!(
285            tokenize("testing-conventions install  # trailing note"),
286            vec!["testing-conventions", "install"]
287        );
288    }
289
290    #[test]
291    fn is_binary_token_accepts_the_command_word() {
292        assert!(is_binary_token("testing-conventions"));
293        assert!(is_binary_token("testing-conventions@0.1.0"));
294        assert!(is_binary_token("testing-conventions${VERSION:+@$VERSION}"));
295    }
296
297    #[test]
298    fn is_binary_token_rejects_lookalikes() {
299        assert!(!is_binary_token("testing-conventions.toml"));
300        assert!(!is_binary_token("testing-conventions.yml@v0"));
301        assert!(!is_binary_token("actions/checkout@v6"));
302        assert!(!is_binary_token("npx"));
303        assert!(!is_binary_token(
304            "packages/rust/target/release/testing-conventions"
305        ));
306        assert!(!is_binary_token("$target/bin/testing-conventions"));
307        assert!(!is_binary_token("./target/release/testing-conventions"));
308    }
309
310    #[test]
311    fn line_invocation_reads_the_args_after_the_binary() {
312        assert_eq!(
313            line_invocation(
314                "- run: npx -y testing-conventions unit location --language python src"
315            ),
316            Some(vec![
317                "unit".to_string(),
318                "location".to_string(),
319                "--language".to_string(),
320                "python".to_string(),
321                "src".to_string(),
322            ])
323        );
324        assert_eq!(line_invocation("- uses: actions/checkout@v6"), None);
325    }
326
327    #[test]
328    fn line_invocation_ignores_a_package_install_line() {
329        assert_eq!(
330            line_invocation("- run: pip install testing-conventions pytest"),
331            None
332        );
333        assert_eq!(
334            line_invocation("- run: npm install -D testing-conventions"),
335            None
336        );
337        assert_eq!(
338            line_invocation("- run: cargo install testing-conventions"),
339            None
340        );
341        assert_eq!(
342            line_invocation("- run: testing-conventions install"),
343            Some(vec!["install".to_string()])
344        );
345        assert_eq!(
346            line_invocation("- run: npx -y testing-conventions install"),
347            Some(vec!["install".to_string()])
348        );
349    }
350
351    #[test]
352    fn unknown_subcommands_validates_across_leading_global_flags() {
353        let root = clap::Command::new("tc")
354            .arg(
355                clap::Arg::new("config")
356                    .long("config")
357                    .action(clap::ArgAction::Set),
358            )
359            .subcommand(clap::Command::new("unit").subcommand(clap::Command::new("coverage")));
360        let flagged = unknown_subcommands(&[inv(1, &["--config", "x", "unit", "location"])], &root);
361        assert_eq!(flagged.len(), 1, "{flagged:?}");
362        assert!(
363            flagged[0].message.contains("location"),
364            "{}",
365            flagged[0].message
366        );
367        assert!(
368            unknown_subcommands(&[inv(2, &["--config", "x", "unit", "coverage"])], &root)
369                .is_empty()
370        );
371    }
372
373    #[test]
374    fn invocations_scans_a_file_and_a_directory() {
375        let tree = TempTree::new(&[
376            ("ci.yml", "- run: testing-conventions install\n"),
377            (
378                "nested/more.yaml",
379                "- run: testing-conventions unit lint --language rust .\n",
380            ),
381            ("notes.txt", "testing-conventions install\n"),
382        ]);
383        let dir = invocations(tree.path()).unwrap();
384        assert_eq!(dir.len(), 2);
385        assert_eq!(dir[0].args, vec!["install"]);
386        assert_eq!(dir[0].line, 1);
387        let file = invocations(tree.path().join("ci.yml")).unwrap();
388        assert_eq!(file.len(), 1);
389    }
390
391    #[test]
392    fn invocations_errors_on_a_missing_path() {
393        let missing = std::env::temp_dir().join("tc-workflow-does-not-exist-2b1c");
394        assert!(invocations(&missing).is_err());
395    }
396
397    fn inv(line: usize, args: &[&str]) -> Invocation {
398        Invocation {
399            file: PathBuf::from("ci.yml"),
400            line,
401            args: args.iter().map(|s| s.to_string()).collect(),
402        }
403    }
404
405    #[test]
406    fn unknown_subcommands_flags_a_renamed_nested_rule() {
407        let v = unknown_subcommands(
408            &[inv(9, &["unit", "location", "--language", "python", "src"])],
409            &crate::command(),
410        );
411        assert_eq!(v.len(), 1);
412        assert_eq!(v[0].line, 9);
413        assert_eq!(v[0].rule, "no-unknown-subcommand");
414        assert!(v[0].message.contains("`location`"), "{}", v[0].message);
415        assert!(v[0].message.contains("`unit`"), "{}", v[0].message);
416    }
417
418    #[test]
419    fn unknown_subcommands_flags_a_removed_top_level_command() {
420        let v = unknown_subcommands(
421            &[inv(1, &["unit-location", "--lang", "python", "src"])],
422            &crate::command(),
423        );
424        assert_eq!(v.len(), 1);
425        assert!(v[0].message.contains("`unit-location`"), "{}", v[0].message);
426        assert!(
427            v[0].message.contains("`testing-conventions`"),
428            "{}",
429            v[0].message
430        );
431    }
432
433    #[test]
434    fn unknown_subcommands_accepts_every_live_invocation() {
435        let invs = [
436            inv(
437                1,
438                &["unit", "colocated-test", "--language", "python", "src"],
439            ),
440            inv(2, &["unit", "coverage", "--language", "typescript", "src"]),
441            inv(3, &["unit", "lint", "--language", "rust", "."]),
442            inv(4, &["integration", "lint", "--language", "python", "src"]),
443            inv(5, &["packaging", "--language", "python", "dist"]),
444            inv(6, &["install"]),
445            inv(7, &["--version"]),
446            inv(8, &[]),
447        ];
448        assert!(unknown_subcommands(&invs, &crate::command()).is_empty());
449    }
450}