use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LockKind {
NpmPackageLock,
PnpmLock,
YarnLock,
UvLock,
PoetryLock,
}
#[derive(Debug, Default, Clone)]
pub struct ProjectFacts {
pub lock_files: Vec<LockKind>,
pub current_branch: Option<String>,
pub on_protected_branch: bool,
}
pub fn collect(repo_root: Option<&Path>, protected_patterns: &[String]) -> ProjectFacts {
let Some(root) = repo_root else {
return ProjectFacts::default();
};
let lock_files = detect_lock_files(root);
let current_branch = read_current_branch(root);
let on_protected_branch = current_branch.as_deref().is_some_and(|name| {
protected_patterns
.iter()
.any(|pat| wildcard_match(pat, name))
});
ProjectFacts {
lock_files,
current_branch,
on_protected_branch,
}
}
fn detect_lock_files(root: &Path) -> Vec<LockKind> {
const TABLE: &[(&str, LockKind)] = &[
("package-lock.json", LockKind::NpmPackageLock),
("pnpm-lock.yaml", LockKind::PnpmLock),
("yarn.lock", LockKind::YarnLock),
("uv.lock", LockKind::UvLock),
("poetry.lock", LockKind::PoetryLock),
];
TABLE
.iter()
.filter(|(name, _)| root.join(name).is_file())
.map(|(_, kind)| *kind)
.collect()
}
fn read_current_branch(root: &Path) -> Option<String> {
let head = root.join(".git").join("HEAD");
let raw = std::fs::read_to_string(head).ok()?;
let trimmed = raw.trim();
let rest = trimmed.strip_prefix("ref:")?.trim_start();
let name = rest.strip_prefix("refs/heads/")?;
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
fn wildcard_match(pattern: &str, value: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix("/*") {
return value.starts_with(prefix) && value[prefix.len()..].starts_with('/');
}
if let Some(prefix) = pattern.strip_suffix('*') {
return value.starts_with(prefix);
}
pattern == value
}
#[cfg(test)]
mod tests {
use super::*;
fn tempdir(suffix: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"ptuf-project-facts-{}-{suffix}-{}",
std::process::id(),
line!()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
dir
}
#[test]
fn collect_returns_default_when_repo_root_is_none() {
let f = collect(None, &["main".into()]);
assert!(f.lock_files.is_empty());
assert!(f.current_branch.is_none());
assert!(!f.on_protected_branch);
}
#[test]
fn detects_pnpm_and_uv_lock_files_at_root() {
let dir = tempdir("locks");
std::fs::write(dir.join("pnpm-lock.yaml"), "").expect("write");
std::fs::write(dir.join("uv.lock"), "").expect("write");
let f = collect(Some(&dir), &[]);
assert!(f.lock_files.contains(&LockKind::PnpmLock));
assert!(f.lock_files.contains(&LockKind::UvLock));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ignores_lock_files_in_subdirectories() {
let dir = tempdir("nested-locks");
std::fs::create_dir_all(dir.join("packages/a")).expect("mkdir");
std::fs::write(dir.join("packages/a/pnpm-lock.yaml"), "").expect("write");
let f = collect(Some(&dir), &[]);
assert!(!f.lock_files.contains(&LockKind::PnpmLock));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reads_current_branch_from_dot_git_head() {
let dir = tempdir("branch");
std::fs::create_dir_all(dir.join(".git")).expect("mkdir");
std::fs::write(dir.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("write");
let f = collect(Some(&dir), &["main".into()]);
assert_eq!(f.current_branch.as_deref(), Some("main"));
assert!(f.on_protected_branch);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn returns_none_for_detached_head() {
let dir = tempdir("detached");
std::fs::create_dir_all(dir.join(".git")).expect("mkdir");
std::fs::write(dir.join(".git").join("HEAD"), "abcdef0123456789\n").expect("write");
let f = collect(Some(&dir), &["main".into()]);
assert!(f.current_branch.is_none());
assert!(!f.on_protected_branch);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn returns_none_when_dot_git_head_is_unreadable() {
let dir = tempdir("nogit");
let f = collect(Some(&dir), &["main".into()]);
assert!(f.current_branch.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn protected_branch_flag_uses_pattern_list() {
let dir = tempdir("protected");
std::fs::create_dir_all(dir.join(".git")).expect("mkdir");
std::fs::write(
dir.join(".git").join("HEAD"),
"ref: refs/heads/release/v2\n",
)
.expect("write");
let f = collect(Some(&dir), &["main".into(), "release/*".into()]);
assert!(f.on_protected_branch);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn protected_flag_false_when_branch_does_not_match() {
let dir = tempdir("notprotected");
std::fs::create_dir_all(dir.join(".git")).expect("mkdir");
std::fs::write(
dir.join(".git").join("HEAD"),
"ref: refs/heads/feature/new\n",
)
.expect("write");
let f = collect(Some(&dir), &["main".into(), "release/*".into()]);
assert!(!f.on_protected_branch);
let _ = std::fs::remove_dir_all(&dir);
}
use proptest::prelude::*;
proptest! {
#[test]
fn pbt_wildcard_match_is_reflexive(
p in crate::testing::proptest::arbitrary_command(),
) {
prop_assert!(wildcard_match(&p, &p));
}
#[test]
fn pbt_wildcard_match_never_panics(
pattern in crate::testing::proptest::arbitrary_command(),
value in crate::testing::proptest::arbitrary_command(),
) {
let _ = wildcard_match(&pattern, &value);
}
#[test]
fn pbt_wildcard_match_bare_star_is_prefix(
prefix in "[A-Za-z0-9_.-]{0,8}",
value in crate::testing::proptest::arbitrary_command(),
) {
let pattern = format!("{prefix}*");
prop_assert_eq!(
wildcard_match(&pattern, &value),
value.starts_with(prefix.as_str()),
);
}
#[test]
fn pbt_wildcard_match_slash_star_matches_subpaths(
prefix in "[A-Za-z0-9_.-]{0,8}",
tail in "[A-Za-z0-9_./-]{0,12}",
) {
let pattern = format!("{prefix}/*");
let subpath = format!("{prefix}/{tail}");
prop_assert!(wildcard_match(&pattern, &subpath));
prop_assert!(!wildcard_match(&pattern, &prefix));
}
#[test]
fn pbt_wildcard_match_exact_pattern_is_equality(
pattern in "[A-Za-z0-9/_.-]{0,10}",
value in crate::testing::proptest::arbitrary_command(),
) {
prop_assert_eq!(wildcard_match(&pattern, &value), pattern == value);
}
}
}