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 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
117fn 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
140fn 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
156fn 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
164fn matches_glob(glob: &str, name: &str) -> bool {
167 let glob: Vec<char> = glob.chars().collect();
168 let name: Vec<char> = name.chars().collect();
169 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 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}