testing_conventions/
workflow.rs1use 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 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}