1use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7
8use crate::violation::Violation;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Invocation {
13 pub file: PathBuf,
14 pub line: usize,
16 pub args: Vec<String>,
18}
19
20pub 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
44fn 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
66fn is_workflow_file(path: &Path) -> bool {
68 matches!(
69 path.extension().and_then(|e| e.to_str()),
70 Some("yml" | "yaml")
71 )
72}
73
74fn 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
85fn is_command_position(tokens: &[String], pos: usize) -> bool {
88 let mut i = pos;
89 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
106fn is_command_boundary(token: &str) -> bool {
108 matches!(token, "run:" | "&&" | "||" | "|" | ";" | "&" | "(" | "{")
109}
110
111fn is_binary_token(token: &str) -> bool {
114 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
123fn 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
164pub 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 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
206fn 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
224pub 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}