use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
pub fn scan(root: impl AsRef<Path>, globs: &[String]) -> Result<Vec<PathBuf>> {
let root = root.as_ref();
let mut offenders = Vec::new();
collect_offenders(root, globs, &mut offenders)?;
offenders.sort();
Ok(offenders)
}
pub fn inspect(path: impl AsRef<Path>, globs: &[String]) -> Result<Vec<PathBuf>> {
let path = path.as_ref();
if path.is_dir() {
return Ok(relative_to(path, scan(path, globs)?));
}
if is_zip_artifact(path) {
let unpacked = unzip_to_temp(path)?;
return Ok(relative_to(unpacked.path(), scan(unpacked.path(), globs)?));
}
bail!(
"`{}` is not a directory or a recognized built artifact \
(expected a directory or a `.whl`)",
path.display()
)
}
fn is_zip_artifact(path: &Path) -> bool {
matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("whl" | "zip")
)
}
fn relative_to(root: &Path, offenders: Vec<PathBuf>) -> Vec<PathBuf> {
offenders
.into_iter()
.map(|p| p.strip_prefix(root).map(Path::to_path_buf).unwrap_or(p))
.collect()
}
fn unzip_to_temp(archive: &Path) -> Result<TempDir> {
let file = std::fs::File::open(archive)
.with_context(|| format!("opening artifact `{}`", archive.display()))?;
let mut zip = zip::ZipArchive::new(file)
.with_context(|| format!("reading `{}` as a zip archive", archive.display()))?;
let dir = TempDir::new()?;
zip.extract(dir.path())
.with_context(|| format!("unpacking `{}`", archive.display()))?;
Ok(dir)
}
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Result<Self> {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!(
"testing-conventions-pkg-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
));
std::fs::create_dir_all(&path)
.with_context(|| format!("creating scratch directory `{}`", path.display()))?;
Ok(TempDir(path))
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn collect_offenders(dir: &Path, globs: &[String], out: &mut Vec<PathBuf>) -> Result<()> {
let entries =
std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
for entry in entries {
let path = entry
.with_context(|| format!("reading an entry under `{}`", dir.display()))?
.path();
if path.is_dir() {
collect_offenders(&path, globs, out)?;
} else if matches_any(&path, globs) {
out.push(path);
}
}
Ok(())
}
fn matches_any(path: &Path, globs: &[String]) -> bool {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
globs.iter().any(|glob| matches_glob(glob, name))
}
fn matches_glob(glob: &str, name: &str) -> bool {
let glob: Vec<char> = glob.chars().collect();
let name: Vec<char> = name.chars().collect();
let (mut g, mut n) = (0usize, 0usize);
let mut star: Option<usize> = None;
let mut consumed_by_star = 0usize;
while n < name.len() {
if g < glob.len() && glob[g] == name[n] {
g += 1;
n += 1;
} else if g < glob.len() && glob[g] == '*' {
star = Some(g);
consumed_by_star = n;
g += 1;
} else if let Some(star) = star {
g = star + 1;
consumed_by_star += 1;
n = consumed_by_star;
} else {
return false;
}
}
while g < glob.len() && glob[g] == '*' {
g += 1;
}
g == glob.len()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
struct TempTree(PathBuf);
impl TempTree {
fn new(files: &[&str]) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"tc-packaging-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
));
for rel in files {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "x").unwrap();
}
TempTree(root)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempTree {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn star_matches_any_run_including_empty() {
assert!(matches_glob("*", ""));
assert!(matches_glob("*", "anything.py"));
assert!(matches_glob("*.py", ".py"));
}
#[test]
fn the_python_test_glob_matches_only_test_files() {
assert!(matches_glob("*_test.py", "widget_test.py"));
assert!(!matches_glob("*_test.py", "widget.py"));
assert!(!matches_glob("*_test.py", "widget_test.pyc"));
}
#[test]
fn the_typescript_test_glob_matches_across_extensions() {
assert!(matches_glob("*.test.*", "button.test.ts"));
assert!(matches_glob("*.test.*", "button.test.mts"));
assert!(matches_glob("*.test.*", "button.test.tsx"));
assert!(!matches_glob("*.test.*", "button.ts"));
}
#[test]
fn a_literal_glob_must_match_exactly() {
assert!(matches_glob("conftest.py", "conftest.py"));
assert!(!matches_glob("conftest.py", "conftest.pyi"));
assert!(!matches_glob("conftest.py", "xconftest.py"));
}
#[test]
fn scan_flags_a_test_file_anywhere_in_the_tree() {
let tree = TempTree::new(&["pkg/widget.py", "pkg/sub/helper_test.py"]);
let offenders = scan(tree.path(), &["*_test.py".to_string()]).unwrap();
assert_eq!(offenders, vec![tree.path().join("pkg/sub/helper_test.py")]);
}
#[test]
fn scan_is_clean_when_nothing_matches() {
let tree = TempTree::new(&["pkg/widget.py", "pkg/helper.py"]);
let offenders = scan(tree.path(), &["*_test.py".to_string()]).unwrap();
assert!(offenders.is_empty());
}
#[test]
fn scan_matches_any_of_several_globs_and_returns_sorted() {
let tree = TempTree::new(&["a.test.ts", "b_test.py", "keep.ts"]);
let globs = vec!["*_test.py".to_string(), "*.test.*".to_string()];
let offenders = scan(tree.path(), &globs).unwrap();
assert_eq!(
offenders,
vec![tree.path().join("a.test.ts"), tree.path().join("b_test.py")],
);
}
#[test]
fn scan_errors_when_the_root_cannot_be_read() {
let missing = std::env::temp_dir().join("tc-packaging-does-not-exist-9f8e7d");
assert!(scan(&missing, &["*_test.py".to_string()]).is_err());
}
#[test]
fn inspect_scans_a_directory_artifact_with_relative_paths() {
let tree = TempTree::new(&["pkg/widget.py", "pkg/widget_test.py"]);
let offenders = inspect(tree.path(), &["*_test.py".to_string()]).unwrap();
assert_eq!(offenders, vec![PathBuf::from("pkg/widget_test.py")]);
}
#[test]
fn inspect_rejects_an_unrecognized_artifact() {
let tree = TempTree::new(&["not-an-archive.txt"]);
let err = inspect(
tree.path().join("not-an-archive.txt"),
&["*_test.py".to_string()],
)
.unwrap_err();
assert!(
err.to_string().contains("not a directory or a recognized"),
"got: {err}"
);
}
}