Skip to main content

testing_conventions/
packaging.rs

1//! Packaging rule — the deterministic core: given the root of an unpacked built artifact and
2//! the test-file globs that must not appear in it, [`scan`] returns every offending file, and
3//! [`discover`] finds the built distributions under a root.
4
5use std::path::{Path, PathBuf};
6
7use anyhow::{bail, Context, Result};
8
9use crate::colocated_test::Language;
10
11/// Every file under `root` — the root of an unpacked built artifact — whose name matches one
12/// of `globs`, sorted. `globs` are file-name globs where `*` matches any run of characters;
13/// each is matched against an entry's file name, not its full path.
14pub 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
22/// Inspect a built artifact at `path` for files matching `globs`. `path` is a directory (an
23/// already-unpacked artifact) or an archive this rule unpacks first — `.whl`, `.tgz`/`.tar.gz`,
24/// `.crate`. Offenders come back as paths **relative to the artifact root**.
25pub 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/// A built distribution, with the language its file name names.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Distribution {
47    pub path: PathBuf,
48    pub language: Language,
49}
50
51/// Every built distribution the file name of a `.whl`, `.tar.gz`, `.tgz` or `.crate` announces,
52/// and the language that ecosystem's packer writes it from.
53const DISTRIBUTIONS: [(&str, Language); 4] = [
54    (".whl", Language::Python),
55    (".tar.gz", Language::Python),
56    (".tgz", Language::TypeScript),
57    (".crate", Language::Rust),
58];
59
60/// The built distributions at `path`, sorted: a directory is searched recursively, and any other
61/// path is the single candidate. Empty when nothing there is a recognized distribution.
62pub 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
76/// `path` as the distribution its file name names, or `None` when it names none.
77fn 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
86/// The language a built distribution's file name names, or `None` for any other name.
87fn distribution_language(name: &str) -> Option<Language> {
88    DISTRIBUTIONS
89        .iter()
90        .find(|(suffix, _)| name.ends_with(suffix))
91        .map(|(_, language)| *language)
92}
93
94/// `true` for an artifact this rule unpacks as a zip: a Python wheel (`.whl`) or a `.zip`.
95fn is_zip_artifact(path: &Path) -> bool {
96    matches!(
97        path.extension().and_then(|ext| ext.to_str()),
98        Some("whl" | "zip")
99    )
100}
101
102/// Re-express each offender as a path relative to `root`; an unexpected path is kept as-is.
103fn 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
110/// Unpack a zip artifact into a fresh scratch directory (removed on drop).
111fn 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
122/// `true` for an artifact this rule unpacks as a gzipped tar: `.tgz`, `.tar.gz`, `.crate`.
123fn 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
131/// Unpack a gzipped-tar artifact into a fresh scratch directory (removed on drop).
132fn 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
142/// A scratch directory removed on drop, unique per call so parallel checks never collide.
143struct 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
174/// Recursively collect every file under `dir` (within the artifact `root`) that
175/// matches one of `patterns`.
176fn 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
195/// `true` when `path` matches any of `patterns`. A pattern ending in `/` is a **directory**
196/// pattern — it matches when `path` (relative to `root`) lives under a directory of that name;
197/// every other pattern is a file-name glob (`*` wildcards) matched against the entry's name.
198fn 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
211/// `true` when `path` (relative to `root`) has an **ancestor** directory named `dir`.
212fn 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
219/// Match `name` against a file-name `glob` where `*` matches any run of characters (including
220/// none) and every other character is literal. Matching is over Unicode scalar values.
221fn matches_glob(glob: &str, name: &str) -> bool {
222    let glob: Vec<char> = glob.chars().collect();
223    let name: Vec<char> = name.chars().collect();
224    // Linear wildcard match: on a mismatch, backtrack to the most recent `*` and extend
225    // what it consumed by one character.
226    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    // The pattern matches iff what's left is only trailing `*`s (each empty).
246    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}