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 = 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
65fn is_workflow_file(path: &Path) -> bool {
67 matches!(
68 path.extension().and_then(|e| e.to_str()),
69 Some("yml" | "yaml")
70 )
71}
72
73fn 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
84fn is_command_position(tokens: &[String], pos: usize) -> bool {
87 let mut i = pos;
88 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
105fn is_command_boundary(token: &str) -> bool {
107 matches!(token, "run:" | "&&" | "||" | "|" | ";" | "&" | "(" | "{")
108}
109
110fn is_binary_token(token: &str) -> bool {
113 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
122fn 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
163pub 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 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
205fn 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
223pub 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}