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/// One [`Violation`] per invocation under `path` naming a subcommand `root` no longer
164/// exposes.
165pub fn check(path: impl AsRef<Path>, root: &clap::Command) -> Result<Vec<Violation>> {
166    Ok(crate::subcommand_walk::unknown_subcommands(
167        &invocations(path)?,
168        root,
169    ))
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use std::sync::atomic::{AtomicU64, Ordering};
176
177    struct TempTree(PathBuf);
178
179    impl TempTree {
180        fn new(files: &[(&str, &str)]) -> Self {
181            static COUNTER: AtomicU64 = AtomicU64::new(0);
182            let root = std::env::temp_dir().join(format!(
183                "tc-workflow-{}-{}",
184                std::process::id(),
185                COUNTER.fetch_add(1, Ordering::Relaxed),
186            ));
187            for (rel, content) in files {
188                let path = root.join(rel);
189                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
190                std::fs::write(path, content).unwrap();
191            }
192            TempTree(root)
193        }
194
195        fn path(&self) -> &Path {
196            &self.0
197        }
198    }
199
200    impl Drop for TempTree {
201        fn drop(&mut self) {
202            let _ = std::fs::remove_dir_all(&self.0);
203        }
204    }
205
206    #[test]
207    fn tokenize_strips_quotes_and_groups() {
208        assert_eq!(
209            tokenize(r#"npx -y "testing-conventions${VERSION:+@$VERSION}" unit coverage"#),
210            vec![
211                "npx",
212                "-y",
213                "testing-conventions${VERSION:+@$VERSION}",
214                "unit",
215                "coverage",
216            ]
217        );
218    }
219
220    #[test]
221    fn tokenize_stops_at_a_comment() {
222        assert_eq!(
223            tokenize("      # run testing-conventions later"),
224            Vec::<String>::new()
225        );
226        assert_eq!(
227            tokenize("testing-conventions install  # trailing note"),
228            vec!["testing-conventions", "install"]
229        );
230    }
231
232    #[test]
233    fn is_binary_token_accepts_the_command_word() {
234        assert!(is_binary_token("testing-conventions"));
235        assert!(is_binary_token("testing-conventions@0.1.0"));
236        assert!(is_binary_token("testing-conventions${VERSION:+@$VERSION}"));
237    }
238
239    #[test]
240    fn is_binary_token_rejects_lookalikes() {
241        assert!(!is_binary_token("testing-conventions.toml"));
242        assert!(!is_binary_token("testing-conventions.yml@v0"));
243        assert!(!is_binary_token("actions/checkout@v6"));
244        assert!(!is_binary_token("npx"));
245        assert!(!is_binary_token(
246            "packages/rust/target/release/testing-conventions"
247        ));
248        assert!(!is_binary_token("$target/bin/testing-conventions"));
249        assert!(!is_binary_token("./target/release/testing-conventions"));
250    }
251
252    #[test]
253    fn line_invocation_reads_the_args_after_the_binary() {
254        assert_eq!(
255            line_invocation(
256                "- run: npx -y testing-conventions unit location --language python src"
257            ),
258            Some(vec![
259                "unit".to_string(),
260                "location".to_string(),
261                "--language".to_string(),
262                "python".to_string(),
263                "src".to_string(),
264            ])
265        );
266        assert_eq!(line_invocation("- uses: actions/checkout@v6"), None);
267    }
268
269    #[test]
270    fn line_invocation_ignores_a_package_install_line() {
271        assert_eq!(
272            line_invocation("- run: pip install testing-conventions pytest"),
273            None
274        );
275        assert_eq!(
276            line_invocation("- run: npm install -D testing-conventions"),
277            None
278        );
279        assert_eq!(
280            line_invocation("- run: cargo install testing-conventions"),
281            None
282        );
283        assert_eq!(
284            line_invocation("- run: testing-conventions install"),
285            Some(vec!["install".to_string()])
286        );
287        assert_eq!(
288            line_invocation("- run: npx -y testing-conventions install"),
289            Some(vec!["install".to_string()])
290        );
291    }
292
293    #[test]
294    fn a_line_starting_with_the_binary_is_an_invocation() {
295        let line = "testing-conventions install";
296        assert_eq!(line_invocation(line), Some(vec!["install".to_string()]));
297    }
298
299    #[test]
300    fn invocations_scans_a_file_and_a_directory() {
301        let tree = TempTree::new(&[
302            ("ci.yml", "- run: testing-conventions install\n"),
303            (
304                "nested/more.yaml",
305                "- run: testing-conventions unit lint --language rust .\n",
306            ),
307            ("notes.txt", "testing-conventions install\n"),
308        ]);
309        let dir = invocations(tree.path()).unwrap();
310        assert_eq!(dir.len(), 2);
311        assert_eq!(dir[0].args, vec!["install"]);
312        assert_eq!(dir[0].line, 1);
313        let file = invocations(tree.path().join("ci.yml")).unwrap();
314        assert_eq!(file.len(), 1);
315    }
316
317    #[test]
318    fn invocations_errors_on_a_missing_path() {
319        let missing = std::env::temp_dir().join("tc-workflow-does-not-exist-2b1c");
320        assert!(invocations(&missing).is_err());
321    }
322
323    #[test]
324    fn an_unreadable_workflow_names_the_file() {
325        let tree = TempTree::new(&[("ci.yml", "")]);
326        std::fs::write(tree.path().join("ci.yml"), [0xFF, 0xFE]).unwrap();
327        let err = invocations(tree.path()).unwrap_err();
328        assert!(
329            format!("{err:#}").contains("reading workflow"),
330            "got: {err:#}"
331        );
332    }
333
334    #[test]
335    fn check_flags_an_invocation_naming_a_subcommand_the_cli_dropped() {
336        let root = clap::Command::new("tc")
337            .subcommand(clap::Command::new("unit").subcommand(clap::Command::new("coverage")));
338
339        let dropped = TempTree::new(&[("ci.yml", "- run: testing-conventions unit location .\n")]);
340        let flagged = check(dropped.path(), &root).unwrap();
341        assert_eq!(flagged.len(), 1, "{flagged:?}");
342        assert!(flagged[0].message.contains("location"), "{flagged:?}");
343
344        let kept = TempTree::new(&[("ci.yml", "- run: testing-conventions unit coverage .\n")]);
345        assert!(check(kept.path(), &root).unwrap().is_empty());
346    }
347}