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        use std::sync::atomic::{AtomicU64, Ordering};
95        static COUNTER: AtomicU64 = AtomicU64::new(0);
96        let path = std::env::temp_dir().join(format!(
97            "testing-conventions-pkg-{}-{}",
98            std::process::id(),
99            COUNTER.fetch_add(1, Ordering::Relaxed),
100        ));
101        std::fs::create_dir_all(&path)
102            .with_context(|| format!("creating scratch directory `{}`", path.display()))?;
103        Ok(TempDir(path))
104    }
105
106    fn path(&self) -> &Path {
107        &self.0
108    }
109}
110
111impl Drop for TempDir {
112    fn drop(&mut self) {
113        let _ = std::fs::remove_dir_all(&self.0);
114    }
115}
116
117/// Recursively collect every file under `dir` (within the artifact `root`) that
118/// matches one of `patterns`.
119fn collect_offenders(
120    dir: &Path,
121    root: &Path,
122    patterns: &[String],
123    out: &mut Vec<PathBuf>,
124) -> Result<()> {
125    let entries =
126        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
127    for entry in entries {
128        let path = entry
129            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
130            .path();
131        if path.is_dir() {
132            collect_offenders(&path, root, patterns, out)?;
133        } else if matches_any(&path, root, patterns) {
134            out.push(path);
135        }
136    }
137    Ok(())
138}
139
140/// `true` when `path` matches any of `patterns`. A pattern ending in `/` is a **directory**
141/// pattern — it matches when `path` (relative to `root`) lives under a directory of that name;
142/// every other pattern is a file-name glob (`*` wildcards) matched against the entry's name.
143fn matches_any(path: &Path, root: &Path, patterns: &[String]) -> bool {
144    let name = path
145        .file_name()
146        .and_then(|n| n.to_str())
147        .unwrap_or_default();
148    patterns
149        .iter()
150        .any(|pattern| match pattern.strip_suffix('/') {
151            Some(dir) => path_under_dir(path, root, dir),
152            None => matches_glob(pattern, name),
153        })
154}
155
156/// `true` when `path` (relative to `root`) has an **ancestor** directory named `dir`.
157fn path_under_dir(path: &Path, root: &Path, dir: &str) -> bool {
158    let relative = path.strip_prefix(root).unwrap_or(path);
159    relative
160        .parent()
161        .is_some_and(|parents| parents.components().any(|c| c.as_os_str() == dir))
162}
163
164/// Match `name` against a file-name `glob` where `*` matches any run of characters (including
165/// none) and every other character is literal. Matching is over Unicode scalar values.
166fn matches_glob(glob: &str, name: &str) -> bool {
167    let glob: Vec<char> = glob.chars().collect();
168    let name: Vec<char> = name.chars().collect();
169    // Linear wildcard match: on a mismatch, backtrack to the most recent `*` and extend
170    // what it consumed by one character.
171    let (mut g, mut n) = (0usize, 0usize);
172    let mut star: Option<usize> = None;
173    let mut consumed_by_star = 0usize;
174    while n < name.len() {
175        if g < glob.len() && glob[g] == name[n] {
176            g += 1;
177            n += 1;
178        } else if g < glob.len() && glob[g] == '*' {
179            star = Some(g);
180            consumed_by_star = n;
181            g += 1;
182        } else if let Some(star) = star {
183            g = star + 1;
184            consumed_by_star += 1;
185            n = consumed_by_star;
186        } else {
187            return false;
188        }
189    }
190    // The pattern matches iff what's left is only trailing `*`s (each empty).
191    while g < glob.len() && glob[g] == '*' {
192        g += 1;
193    }
194    g == glob.len()
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use std::sync::atomic::{AtomicU64, Ordering};
201
202    struct TempTree(PathBuf);
203
204    impl TempTree {
205        fn new(files: &[&str]) -> Self {
206            static COUNTER: AtomicU64 = AtomicU64::new(0);
207            let root = std::env::temp_dir().join(format!(
208                "tc-packaging-{}-{}",
209                std::process::id(),
210                COUNTER.fetch_add(1, Ordering::Relaxed),
211            ));
212            for rel in files {
213                let path = root.join(rel);
214                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
215                std::fs::write(path, "x").unwrap();
216            }
217            TempTree(root)
218        }
219
220        fn path(&self) -> &Path {
221            &self.0
222        }
223    }
224
225    impl Drop for TempTree {
226        fn drop(&mut self) {
227            let _ = std::fs::remove_dir_all(&self.0);
228        }
229    }
230
231    #[test]
232    fn star_matches_any_run_including_empty() {
233        assert!(matches_glob("*", ""));
234        assert!(matches_glob("*", "anything.py"));
235        assert!(matches_glob("*.py", ".py"));
236    }
237
238    #[test]
239    fn the_python_test_glob_matches_only_test_files() {
240        assert!(matches_glob("*_test.py", "widget_test.py"));
241        assert!(!matches_glob("*_test.py", "widget.py"));
242        assert!(!matches_glob("*_test.py", "widget_test.pyc"));
243    }
244
245    #[test]
246    fn the_typescript_test_glob_matches_across_extensions() {
247        assert!(matches_glob("*.test.*", "button.test.ts"));
248        assert!(matches_glob("*.test.*", "button.test.mts"));
249        assert!(matches_glob("*.test.*", "button.test.tsx"));
250        assert!(!matches_glob("*.test.*", "button.ts"));
251    }
252
253    #[test]
254    fn a_literal_glob_must_match_exactly() {
255        assert!(matches_glob("conftest.py", "conftest.py"));
256        assert!(!matches_glob("conftest.py", "conftest.pyi"));
257        assert!(!matches_glob("conftest.py", "xconftest.py"));
258    }
259
260    #[test]
261    fn scan_flags_a_test_file_anywhere_in_the_tree() {
262        let tree = TempTree::new(&["pkg/widget.py", "pkg/sub/helper_test.py"]);
263        let offenders = scan(tree.path(), &["*_test.py".to_string()]).unwrap();
264        assert_eq!(offenders, vec![tree.path().join("pkg/sub/helper_test.py")]);
265    }
266
267    #[test]
268    fn a_directory_pattern_flags_files_under_that_dir() {
269        let tree = TempTree::new(&["tests/integration.rs", "src/lib.rs", "src/tests/nested.rs"]);
270        let offenders = scan(tree.path(), &["tests/".to_string()]).unwrap();
271        assert_eq!(
272            offenders,
273            vec![
274                tree.path().join("src/tests/nested.rs"),
275                tree.path().join("tests/integration.rs"),
276            ],
277        );
278    }
279
280    #[test]
281    fn recognizes_a_dot_crate_as_a_gzipped_tar() {
282        assert!(is_tar_gz_artifact(Path::new("widget-0.1.0.crate")));
283        assert!(is_tar_gz_artifact(Path::new("pkg.tgz")));
284        assert!(is_tar_gz_artifact(Path::new("pkg.tar.gz")));
285        assert!(!is_tar_gz_artifact(Path::new("pkg.whl")));
286    }
287
288    #[test]
289    fn scan_is_clean_when_nothing_matches() {
290        let tree = TempTree::new(&["pkg/widget.py", "pkg/helper.py"]);
291        let offenders = scan(tree.path(), &["*_test.py".to_string()]).unwrap();
292        assert!(offenders.is_empty());
293    }
294
295    #[test]
296    fn scan_matches_any_of_several_globs_and_returns_sorted() {
297        let tree = TempTree::new(&["a.test.ts", "b_test.py", "keep.ts"]);
298        let globs = vec!["*_test.py".to_string(), "*.test.*".to_string()];
299        let offenders = scan(tree.path(), &globs).unwrap();
300        assert_eq!(
301            offenders,
302            vec![tree.path().join("a.test.ts"), tree.path().join("b_test.py")],
303        );
304    }
305
306    #[test]
307    fn scan_errors_when_the_root_cannot_be_read() {
308        let missing = std::env::temp_dir().join("tc-packaging-does-not-exist-9f8e7d");
309        assert!(scan(&missing, &["*_test.py".to_string()]).is_err());
310    }
311
312    #[test]
313    fn inspect_scans_a_directory_artifact_with_relative_paths() {
314        let tree = TempTree::new(&["pkg/widget.py", "pkg/widget_test.py"]);
315        let offenders = inspect(tree.path(), &["*_test.py".to_string()]).unwrap();
316        assert_eq!(offenders, vec![PathBuf::from("pkg/widget_test.py")]);
317    }
318
319    #[test]
320    fn inspect_rejects_an_unrecognized_artifact() {
321        let tree = TempTree::new(&["not-an-archive.txt"]);
322        let err = inspect(
323            tree.path().join("not-an-archive.txt"),
324            &["*_test.py".to_string()],
325        )
326        .unwrap_err();
327        assert!(
328            err.to_string().contains("not a directory or a recognized"),
329            "got: {err}"
330        );
331    }
332}