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