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.
3
4use std::path::{Path, PathBuf};
5
6use anyhow::{bail, Context, Result};
7
8/// Every file under `root` — the root of an unpacked built artifact — whose name matches one
9/// of `globs`, sorted. `globs` are file-name globs where `*` matches any run of characters;
10/// each is matched against an entry's file name, not its full path.
11pub 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
19/// Inspect a built artifact at `path` for files matching `globs`. `path` is a directory (an
20/// already-unpacked artifact) or an archive this rule unpacks first — `.whl`, `.tgz`/`.tar.gz`,
21/// `.crate`. Offenders come back as paths **relative to the artifact root**.
22pub 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
41/// `true` for an artifact this rule unpacks as a zip: a Python wheel (`.whl`) or a `.zip`.
42fn is_zip_artifact(path: &Path) -> bool {
43    matches!(
44        path.extension().and_then(|ext| ext.to_str()),
45        Some("whl" | "zip")
46    )
47}
48
49/// Re-express each offender as a path relative to `root`; an unexpected path is kept as-is.
50fn 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
57/// Unpack a zip artifact into a fresh scratch directory (removed on drop).
58fn 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
69/// `true` for an artifact this rule unpacks as a gzipped tar: `.tgz`, `.tar.gz`, `.crate`.
70fn 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
78/// Unpack a gzipped-tar artifact into a fresh scratch directory (removed on drop).
79fn 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
89/// A scratch directory removed on drop, unique per call so parallel checks never collide.
90struct 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
121/// Recursively collect every file under `dir` (within the artifact `root`) that
122/// matches one of `patterns`.
123fn 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
142/// `true` when `path` matches any of `patterns`. A pattern ending in `/` is a **directory**
143/// pattern — it matches when `path` (relative to `root`) lives under a directory of that name;
144/// every other pattern is a file-name glob (`*` wildcards) matched against the entry's name.
145fn 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
158/// `true` when `path` (relative to `root`) has an **ancestor** directory named `dir`.
159fn 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
166/// Match `name` against a file-name `glob` where `*` matches any run of characters (including
167/// none) and every other character is literal. Matching is over Unicode scalar values.
168fn matches_glob(glob: &str, name: &str) -> bool {
169    let glob: Vec<char> = glob.chars().collect();
170    let name: Vec<char> = name.chars().collect();
171    // Linear wildcard match: on a mismatch, backtrack to the most recent `*` and extend
172    // what it consumed by one character.
173    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    // The pattern matches iff what's left is only trailing `*`s (each empty).
193    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}