use regex::Regex;
use std::path::{Path, PathBuf};
use varar_config::Config;
pub fn glob_to_regex(pattern: &str) -> Regex {
let chars: Vec<char> = pattern.chars().collect();
let n = chars.len();
let starts = |i: usize, pat: &str| {
pat.chars()
.enumerate()
.all(|(k, pc)| chars.get(i + k) == Some(&pc))
};
let mut out = String::from("^");
let mut i = 0;
while i < n {
if chars[i] == '/' && starts(i, "/**/") {
out.push_str("/(?:.+/)?");
i += 4;
} else if chars[i] == '/' && starts(i, "/**") && i + 3 == n {
out.push_str("(?:/.*)?");
i += 3;
} else if chars[i] == '*' && starts(i, "**/") {
out.push_str("(?:.*/)?");
i += 3;
} else if chars[i] == '*' && starts(i, "**") {
out.push_str(".*");
i += 2;
} else if chars[i] == '*' {
out.push_str("[^/]*");
i += 1;
} else if chars[i] == '?' {
out.push_str("[^/]");
i += 1;
} else {
out.push_str(®ex::escape(&chars[i].to_string()));
i += 1;
}
}
out.push('$');
Regex::new(&out).expect("valid glob regex")
}
fn matches_any(rel: &str, globs: &[String]) -> bool {
globs.iter().any(|g| glob_to_regex(g).is_match(rel))
}
fn rel_posix(path: &Path, root: &Path) -> String {
let rel = path.strip_prefix(root).unwrap_or(path);
rel.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
fn glob_literal_dir(glob: &str) -> &str {
let wild = glob.find(['*', '?']).unwrap_or(glob.len());
match glob[..wild].rfind('/') {
Some(pos) => &glob[..pos],
None => "",
}
}
fn dir_could_match_include(dir_rel: &str, include: &[String]) -> bool {
include.iter().any(|g| {
let lit = glob_literal_dir(g);
if lit.is_empty() {
g.contains('/') || g.contains("**")
} else {
lit == dir_rel
|| lit.starts_with(&format!("{dir_rel}/")) || dir_rel.starts_with(&format!("{lit}/")) }
})
}
fn dir_is_excluded(dir_rel: &str, exclude: &[String]) -> bool {
let shallow = format!("{dir_rel}/x");
let deep = format!("{dir_rel}/x/y");
exclude.iter().any(|g| {
let re = glob_to_regex(g);
re.is_match(&shallow) && re.is_match(&deep)
})
}
fn walk(dir: &Path, root: &Path, include: &[String], exclude: &[String], out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let child_rel = rel_posix(&path, root);
if dir_could_match_include(&child_rel, include) && !dir_is_excluded(&child_rel, exclude)
{
walk(&path, root, include, exclude, out);
}
} else if path.is_file() {
out.push(path);
}
}
}
pub fn match_oath(path: &Path, include: &[String], exclude: &[String], root: &Path) -> bool {
let rel = rel_posix(path, root);
matches_any(&rel, include) && !matches_any(&rel, exclude)
}
pub fn find_oaths(config: &Config, root: &Path) -> Vec<PathBuf> {
if config.docs_include.is_empty() {
return Vec::new();
}
let mut files = Vec::new();
walk(root, root, &config.docs_include, &config.docs_exclude, &mut files);
let mut kept: Vec<PathBuf> = files
.into_iter()
.filter(|p| match_oath(p, &config.docs_include, &config.docs_exclude, root))
.collect();
kept.sort();
kept
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn tmp(suffix: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("varar-discovery-{}-{suffix}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn walk_skips_dir_outside_literal_include_prefix() {
let root = tmp("walk-prune");
std::fs::write(root.join("README.md"), "x").unwrap();
std::fs::create_dir_all(root.join("docs")).unwrap();
std::fs::write(root.join("docs/loop.md"), "x").unwrap();
std::fs::create_dir_all(root.join("target/debug")).unwrap();
std::fs::write(root.join("target/debug/not_a_doc.md"), "x").unwrap();
let include = vec!["README.md".to_string(), "docs/loop.md".to_string()];
let exclude: Vec<String> = vec![];
let mut out = Vec::new();
walk(&root, &root, &include, &exclude, &mut out);
assert!(
!out.iter().any(|p| p.ends_with("not_a_doc.md")),
"walk should not have entered target/: {out:?}"
);
assert_eq!(out.len(), 2, "expected README.md + docs/loop.md, got {out:?}");
}
#[test]
fn walk_keeps_dir_whose_exclude_covers_only_its_own_files() {
let root = tmp("walk-shallow-excl");
std::fs::create_dir_all(root.join("skip/nested")).unwrap();
std::fs::write(root.join("skip/top.md"), "x").unwrap();
std::fs::write(root.join("skip/nested/deep.md"), "x").unwrap();
let include = vec!["**/*.md".to_string()];
let exclude = vec!["skip/*".to_string()];
let mut out = Vec::new();
walk(&root, &root, &include, &exclude, &mut out);
assert!(
out.iter().any(|p| p.ends_with("deep.md")),
"walk should have entered skip/nested/: {out:?}"
);
}
#[test]
fn walk_keeps_dir_matching_wildcard_first_segment() {
let root = tmp("walk-wild-seg");
std::fs::create_dir_all(root.join("docs1")).unwrap();
std::fs::write(root.join("docs1/a.md"), "x").unwrap();
let include = vec!["docs*/*.md".to_string()];
let exclude: Vec<String> = vec![];
let mut out = Vec::new();
walk(&root, &root, &include, &exclude, &mut out);
assert!(
out.iter().any(|p| p.ends_with("a.md")),
"walk should have entered docs1/: {out:?}"
);
}
#[test]
fn walk_skips_excluded_dir() {
let root = tmp("walk-excl");
std::fs::write(root.join("good.md"), "x").unwrap();
std::fs::create_dir_all(root.join("skip/nested")).unwrap();
std::fs::write(root.join("skip/nested/bad.md"), "x").unwrap();
let include = vec!["**/*.md".to_string()];
let exclude = vec!["skip/**".to_string()];
let mut out = Vec::new();
walk(&root, &root, &include, &exclude, &mut out);
assert!(
!out.iter().any(|p| p.ends_with("bad.md")),
"walk should not have entered skip/: {out:?}"
);
assert_eq!(out.len(), 1, "expected only good.md, got {out:?}");
}
}