1use std::path::{Path, PathBuf};
6
7use anyhow::{bail, Context, Result};
8
9use crate::colocated_test::Language;
10
11pub fn scan(root: impl AsRef<Path>, globs: &[String]) -> Result<Vec<PathBuf>> {
15 let root = root.as_ref();
16 let mut offenders = Vec::new();
17 collect_offenders(root, root, globs, &mut offenders)?;
18 offenders.sort();
19 Ok(offenders)
20}
21
22pub fn inspect(path: impl AsRef<Path>, globs: &[String]) -> Result<Vec<PathBuf>> {
26 let path = path.as_ref();
27 if path.is_dir() {
28 return Ok(relative_to(path, scan(path, globs)?));
29 }
30 let unpacked = if is_zip_artifact(path) {
31 unzip_to_temp(path)?
32 } else if is_tar_gz_artifact(path) {
33 untar_gz_to_temp(path)?
34 } else {
35 bail!(
36 "`{}` is not a directory or a recognized built artifact \
37 (expected a directory, a `.whl`, a `.tgz`/`.tar.gz`, or a `.crate`)",
38 path.display()
39 )
40 };
41 Ok(relative_to(unpacked.path(), scan(unpacked.path(), globs)?))
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Distribution {
47 pub path: PathBuf,
48 pub language: Language,
49}
50
51const DISTRIBUTIONS: [(&str, Language); 4] = [
54 (".whl", Language::Python),
55 (".tar.gz", Language::Python),
56 (".tgz", Language::TypeScript),
57 (".crate", Language::Rust),
58];
59
60pub fn discover(path: &Path) -> Result<Vec<Distribution>> {
63 if !path.is_dir() {
64 return Ok(as_distribution(path.to_path_buf()).into_iter().collect());
65 }
66 let globs: Vec<String> = DISTRIBUTIONS
67 .iter()
68 .map(|(suffix, _)| format!("*{suffix}"))
69 .collect();
70 Ok(scan(path, &globs)?
71 .into_iter()
72 .filter_map(as_distribution)
73 .collect())
74}
75
76fn as_distribution(path: PathBuf) -> Option<Distribution> {
78 let name = path
79 .file_name()
80 .and_then(|n| n.to_str())
81 .unwrap_or_default();
82 let language = distribution_language(name)?;
83 Some(Distribution { path, language })
84}
85
86fn distribution_language(name: &str) -> Option<Language> {
88 DISTRIBUTIONS
89 .iter()
90 .find(|(suffix, _)| name.ends_with(suffix))
91 .map(|(_, language)| *language)
92}
93
94fn is_zip_artifact(path: &Path) -> bool {
96 matches!(
97 path.extension().and_then(|ext| ext.to_str()),
98 Some("whl" | "zip")
99 )
100}
101
102fn relative_to(root: &Path, offenders: Vec<PathBuf>) -> Vec<PathBuf> {
104 offenders
105 .into_iter()
106 .map(|p| p.strip_prefix(root).map(Path::to_path_buf).unwrap_or(p))
107 .collect()
108}
109
110fn unzip_to_temp(archive: &Path) -> Result<TempDir> {
112 let file = std::fs::File::open(archive)
113 .with_context(|| format!("opening artifact `{}`", archive.display()))?;
114 let mut zip = zip::ZipArchive::new(file)
115 .with_context(|| format!("reading `{}` as a zip archive", archive.display()))?;
116 let dir = TempDir::new()?;
117 zip.extract(dir.path())
118 .with_context(|| format!("unpacking `{}`", archive.display()))?;
119 Ok(dir)
120}
121
122fn is_tar_gz_artifact(path: &Path) -> bool {
124 let name = path
125 .file_name()
126 .and_then(|n| n.to_str())
127 .unwrap_or_default();
128 name.ends_with(".tgz") || name.ends_with(".tar.gz") || name.ends_with(".crate")
129}
130
131fn untar_gz_to_temp(archive: &Path) -> Result<TempDir> {
133 let file = std::fs::File::open(archive)
134 .with_context(|| format!("opening artifact `{}`", archive.display()))?;
135 let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(file));
136 let dir = TempDir::new()?;
137 tar.unpack(dir.path())
138 .with_context(|| format!("unpacking `{}`", archive.display()))?;
139 Ok(dir)
140}
141
142struct TempDir(PathBuf);
144
145impl TempDir {
146 fn new() -> Result<Self> {
147 Self::new_in(&std::env::temp_dir())
148 }
149
150 fn new_in(base: &Path) -> Result<Self> {
151 use std::sync::atomic::{AtomicU64, Ordering};
152 static COUNTER: AtomicU64 = AtomicU64::new(0);
153 let path = base.join(format!(
154 "testing-conventions-pkg-{}-{}",
155 std::process::id(),
156 COUNTER.fetch_add(1, Ordering::Relaxed),
157 ));
158 std::fs::create_dir_all(&path)
159 .with_context(|| format!("creating scratch directory `{}`", path.display()))?;
160 Ok(TempDir(path))
161 }
162
163 fn path(&self) -> &Path {
164 &self.0
165 }
166}
167
168impl Drop for TempDir {
169 fn drop(&mut self) {
170 let _ = std::fs::remove_dir_all(&self.0);
171 }
172}
173
174fn collect_offenders(
177 dir: &Path,
178 root: &Path,
179 patterns: &[String],
180 out: &mut Vec<PathBuf>,
181) -> Result<()> {
182 let entries =
183 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
184 for entry in entries {
185 let path = crate::walk::dir_entry(entry, dir)?.path();
186 if path.is_dir() {
187 collect_offenders(&path, root, patterns, out)?;
188 } else if matches_any(&path, root, patterns) {
189 out.push(path);
190 }
191 }
192 Ok(())
193}
194
195fn matches_any(path: &Path, root: &Path, patterns: &[String]) -> bool {
199 let name = path
200 .file_name()
201 .and_then(|n| n.to_str())
202 .unwrap_or_default();
203 patterns
204 .iter()
205 .any(|pattern| match pattern.strip_suffix('/') {
206 Some(dir) => path_under_dir(path, root, dir),
207 None => matches_glob(pattern, name),
208 })
209}
210
211fn path_under_dir(path: &Path, root: &Path, dir: &str) -> bool {
213 let relative = path.strip_prefix(root).unwrap_or(path);
214 relative
215 .parent()
216 .is_some_and(|parents| parents.components().any(|c| c.as_os_str() == dir))
217}
218
219fn matches_glob(glob: &str, name: &str) -> bool {
222 let glob: Vec<char> = glob.chars().collect();
223 let name: Vec<char> = name.chars().collect();
224 let (mut g, mut n) = (0usize, 0usize);
227 let mut star: Option<usize> = None;
228 let mut consumed_by_star = 0usize;
229 while n < name.len() {
230 if g < glob.len() && glob[g] == name[n] {
231 g += 1;
232 n += 1;
233 } else if g < glob.len() && glob[g] == '*' {
234 star = Some(g);
235 consumed_by_star = n;
236 g += 1;
237 } else if let Some(star) = star {
238 g = star + 1;
239 consumed_by_star += 1;
240 n = consumed_by_star;
241 } else {
242 return false;
243 }
244 }
245 while g < glob.len() && glob[g] == '*' {
247 g += 1;
248 }
249 g == glob.len()
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use std::sync::atomic::{AtomicU64, Ordering};
256
257 struct TempTree(PathBuf);
258
259 impl TempTree {
260 fn new(files: &[&str]) -> Self {
261 static COUNTER: AtomicU64 = AtomicU64::new(0);
262 let root = std::env::temp_dir().join(format!(
263 "tc-packaging-{}-{}",
264 std::process::id(),
265 COUNTER.fetch_add(1, Ordering::Relaxed),
266 ));
267 for rel in files {
268 let path = root.join(rel);
269 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
270 std::fs::write(path, "x").unwrap();
271 }
272 TempTree(root)
273 }
274
275 fn path(&self) -> &Path {
276 &self.0
277 }
278 }
279
280 impl Drop for TempTree {
281 fn drop(&mut self) {
282 let _ = std::fs::remove_dir_all(&self.0);
283 }
284 }
285
286 #[test]
287 fn star_matches_any_run_including_empty() {
288 assert!(matches_glob("*", ""));
289 assert!(matches_glob("*", "anything.py"));
290 assert!(matches_glob("*.py", ".py"));
291 }
292
293 #[test]
294 fn the_python_test_glob_matches_only_test_files() {
295 assert!(matches_glob("*_test.py", "widget_test.py"));
296 assert!(!matches_glob("*_test.py", "widget.py"));
297 assert!(!matches_glob("*_test.py", "widget_test.pyc"));
298 }
299
300 #[test]
301 fn the_typescript_test_glob_matches_across_extensions() {
302 assert!(matches_glob("*.test.*", "button.test.ts"));
303 assert!(matches_glob("*.test.*", "button.test.mts"));
304 assert!(matches_glob("*.test.*", "button.test.tsx"));
305 assert!(!matches_glob("*.test.*", "button.ts"));
306 }
307
308 #[test]
309 fn a_literal_glob_must_match_exactly() {
310 assert!(matches_glob("conftest.py", "conftest.py"));
311 assert!(!matches_glob("conftest.py", "conftest.pyi"));
312 assert!(!matches_glob("conftest.py", "xconftest.py"));
313 }
314
315 #[test]
316 fn scan_flags_a_test_file_anywhere_in_the_tree() {
317 let tree = TempTree::new(&["pkg/widget.py", "pkg/sub/helper_test.py"]);
318 let offenders = scan(tree.path(), &["*_test.py".to_string()]).unwrap();
319 assert_eq!(offenders, vec![tree.path().join("pkg/sub/helper_test.py")]);
320 }
321
322 #[test]
323 fn a_directory_pattern_flags_files_under_that_dir() {
324 let tree = TempTree::new(&["tests/integration.rs", "src/lib.rs", "src/tests/nested.rs"]);
325 let offenders = scan(tree.path(), &["tests/".to_string()]).unwrap();
326 assert_eq!(
327 offenders,
328 vec![
329 tree.path().join("src/tests/nested.rs"),
330 tree.path().join("tests/integration.rs"),
331 ],
332 );
333 }
334
335 #[test]
336 fn recognizes_a_dot_crate_as_a_gzipped_tar() {
337 assert!(is_tar_gz_artifact(Path::new("widget-0.1.0.crate")));
338 assert!(is_tar_gz_artifact(Path::new("pkg.tgz")));
339 assert!(is_tar_gz_artifact(Path::new("pkg.tar.gz")));
340 assert!(!is_tar_gz_artifact(Path::new("pkg.whl")));
341 }
342
343 #[test]
344 fn scan_is_clean_when_nothing_matches() {
345 let tree = TempTree::new(&["pkg/widget.py", "pkg/helper.py"]);
346 let offenders = scan(tree.path(), &["*_test.py".to_string()]).unwrap();
347 assert!(offenders.is_empty());
348 }
349
350 #[test]
351 fn scan_matches_any_of_several_globs_and_returns_sorted() {
352 let tree = TempTree::new(&["a.test.ts", "b_test.py", "keep.ts"]);
353 let globs = vec!["*_test.py".to_string(), "*.test.*".to_string()];
354 let offenders = scan(tree.path(), &globs).unwrap();
355 assert_eq!(
356 offenders,
357 vec![tree.path().join("a.test.ts"), tree.path().join("b_test.py")],
358 );
359 }
360
361 #[test]
362 fn scan_errors_when_the_root_cannot_be_read() {
363 let missing = std::env::temp_dir().join("tc-packaging-does-not-exist-9f8e7d");
364 assert!(scan(&missing, &["*_test.py".to_string()]).is_err());
365 }
366
367 #[test]
368 fn inspect_scans_a_directory_artifact_with_relative_paths() {
369 let tree = TempTree::new(&["pkg/widget.py", "pkg/widget_test.py"]);
370 let offenders = inspect(tree.path(), &["*_test.py".to_string()]).unwrap();
371 assert_eq!(offenders, vec![PathBuf::from("pkg/widget_test.py")]);
372 }
373
374 #[test]
375 fn inspect_rejects_an_unrecognized_artifact() {
376 let tree = TempTree::new(&["not-an-archive.txt"]);
377 let artifact = tree.path().join("not-an-archive.txt");
378 let err = inspect(artifact.as_path(), &["*_test.py".to_string()]).unwrap_err();
379 assert!(
380 err.to_string().contains("not a directory or a recognized"),
381 "got: {err}"
382 );
383 }
384
385 fn write_zip(path: &Path, entries: &[&str]) {
386 use std::io::Write;
387 let file = std::fs::File::create(path).unwrap();
388 let mut writer = zip::ZipWriter::new(file);
389 let options = zip::write::SimpleFileOptions::default()
390 .compression_method(zip::CompressionMethod::Stored);
391 for entry in entries {
392 writer.start_file(*entry, options).unwrap();
393 writer.write_all(b"x").unwrap();
394 }
395 writer.finish().unwrap();
396 }
397
398 fn write_tar_gz(path: &Path, entries: &[&str]) {
399 let file = std::fs::File::create(path).unwrap();
400 let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
401 let mut builder = tar::Builder::new(encoder);
402 for entry in entries {
403 let mut header = tar::Header::new_gnu();
404 header.set_size(1);
405 header.set_mode(0o644);
406 header.set_cksum();
407 builder.append_data(&mut header, *entry, &b"x"[..]).unwrap();
408 }
409 builder.into_inner().unwrap().finish().unwrap();
410 }
411
412 #[test]
413 fn inspect_unpacks_a_wheel_and_reports_relative_offenders() {
414 let tree = TempTree::new(&[]);
415 std::fs::create_dir_all(tree.path()).unwrap();
416 let wheel = tree.path().join("pkg.whl");
417 write_zip(&wheel, &["pkg/widget.py", "pkg/widget_test.py"]);
418 let offenders = inspect(wheel.as_path(), &["*_test.py".to_string()]).unwrap();
419 assert_eq!(offenders, vec![PathBuf::from("pkg/widget_test.py")]);
420 }
421
422 #[test]
423 fn inspect_unpacks_a_tarball_and_reports_relative_offenders() {
424 let tree = TempTree::new(&[]);
425 std::fs::create_dir_all(tree.path()).unwrap();
426 let tarball = tree.path().join("pkg.tgz");
427 write_tar_gz(&tarball, &["pkg/widget.py", "pkg/widget_test.py"]);
428 let offenders = inspect(tarball.as_path(), &["*_test.py".to_string()]).unwrap();
429 assert_eq!(offenders, vec![PathBuf::from("pkg/widget_test.py")]);
430 }
431
432 #[test]
433 fn a_missing_wheel_reports_the_open_failure() {
434 let err = unzip_to_temp(Path::new("/nonexistent-tc-packaging/pkg.whl"))
435 .err()
436 .expect("the missing wheel errors");
437 assert!(err.to_string().contains("opening artifact"), "got: {err}");
438 }
439
440 #[test]
441 fn a_wheel_that_is_not_a_zip_reports_the_read_failure() {
442 let tree = TempTree::new(&["pkg.whl"]);
443 let err = unzip_to_temp(&tree.path().join("pkg.whl"))
444 .err()
445 .expect("the non-zip wheel errors");
446 assert!(err.to_string().contains("as a zip archive"), "got: {err}");
447 }
448
449 #[test]
450 fn a_wheel_that_cannot_be_unpacked_reports_the_failure() {
451 let tree = TempTree::new(&[]);
452 std::fs::create_dir_all(tree.path()).unwrap();
453 let wheel = tree.path().join("pkg.whl");
454 write_zip(&wheel, &["a", "a/b"]);
455 let err = unzip_to_temp(&wheel).err().expect("the unpack errors");
456 assert!(err.to_string().contains("unpacking"), "got: {err}");
457 }
458
459 #[test]
460 fn a_missing_tarball_reports_the_open_failure() {
461 let err = untar_gz_to_temp(Path::new("/nonexistent-tc-packaging/pkg.tgz"))
462 .err()
463 .expect("the missing tarball errors");
464 assert!(err.to_string().contains("opening artifact"), "got: {err}");
465 }
466
467 #[test]
468 fn a_tarball_that_cannot_be_unpacked_reports_the_failure() {
469 let tree = TempTree::new(&["pkg.tgz"]);
470 let err = untar_gz_to_temp(&tree.path().join("pkg.tgz"))
471 .err()
472 .expect("the unpack errors");
473 assert!(err.to_string().contains("unpacking"), "got: {err}");
474 }
475
476 #[test]
477 fn an_uncreatable_scratch_directory_is_an_error() {
478 let tree = TempTree::new(&["occupied"]);
479 let err = TempDir::new_in(&tree.path().join("occupied"))
480 .err()
481 .expect("the occupied base errors");
482 assert!(
483 err.to_string().contains("creating scratch directory"),
484 "got: {err}"
485 );
486 }
487}