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