use globset::{Glob, GlobMatcher};
use std::borrow::Cow;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
const GLOB_METACHARS: &[char] = &['*', '?', '[', ']', '{', '}'];
pub fn explicit_file_name_glob(pattern: &str) -> Option<&str> {
if pattern.starts_with('!') {
return None;
}
let file_name = pattern.rsplit('/').next().unwrap_or(pattern);
if file_name.is_empty() {
return None;
}
let literal_tail = match file_name.rfind(GLOB_METACHARS) {
Some(idx) => &file_name[idx + 1..],
None => file_name,
};
match literal_tail.rsplit_once('.') {
Some((_, ext)) if !ext.is_empty() => Some(file_name),
_ => None,
}
}
pub struct ExplicitIncludeMatchers {
matchers: Vec<ExplicitInclude>,
}
struct ExplicitInclude {
file_name_glob: String,
path_matcher: GlobMatcher,
}
impl ExplicitIncludeMatchers {
pub fn new(patterns: &[String]) -> Self {
let matchers = patterns
.iter()
.filter_map(|pattern| {
let file_name_glob = explicit_file_name_glob(pattern)?;
let path_glob = if let Some(anchored) = pattern.strip_prefix('/') {
anchored.to_string()
} else if pattern.contains('/') {
pattern.clone()
} else {
format!("**/{pattern}")
};
let path_matcher = globset::GlobBuilder::new(&path_glob)
.literal_separator(true)
.build()
.ok()?
.compile_matcher();
Some(ExplicitInclude {
file_name_glob: file_name_glob.to_string(),
path_matcher,
})
})
.collect();
Self { matchers }
}
pub fn is_empty(&self) -> bool {
self.matchers.is_empty()
}
pub fn file_name_globs(&self) -> impl Iterator<Item = &str> {
self.matchers.iter().map(|m| m.file_name_glob.as_str())
}
pub fn matches_relative_path(&self, path: &str) -> bool {
self.matchers.iter().any(|m| m.path_matcher.is_match(path))
}
}
pub const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
#[inline]
pub fn is_markdown_extension(ext: &OsStr) -> bool {
ext.to_str()
.is_some_and(|s| MARKDOWN_EXTENSIONS.iter().any(|known| s.eq_ignore_ascii_case(known)))
}
#[inline]
pub fn has_markdown_extension(path: &Path) -> bool {
path.extension().is_some_and(is_markdown_extension)
}
pub fn any_case_extension_glob(ext: &str) -> String {
let mut glob = String::with_capacity(2 + ext.len() * 4);
glob.push_str("*.");
for ch in ext.chars() {
if ch.is_ascii_alphabetic() {
glob.push('[');
glob.push(ch.to_ascii_lowercase());
glob.push(ch.to_ascii_uppercase());
glob.push(']');
} else {
glob.push(ch);
}
}
glob
}
#[derive(Debug, Clone)]
pub struct MarkdownWalkOptions {
pub respect_gitignore: bool,
pub skip_vendor_dirs: bool,
}
impl Default for MarkdownWalkOptions {
fn default() -> Self {
Self {
respect_gitignore: true,
skip_vendor_dirs: false,
}
}
}
pub fn stops_at_repository_root<P: AsRef<Path>>(roots: &[P]) -> bool {
!roots.is_empty() && roots.iter().all(|root| in_repository(root.as_ref()))
}
fn in_repository(path: &Path) -> bool {
let Ok(absolute) = std::fs::canonicalize(path) else {
return false;
};
absolute
.ancestors()
.any(|dir| dir.join(".git").exists() || dir.join(".jj").exists())
}
pub fn apply_markdown_walk_options<P: AsRef<Path>>(
builder: &mut ignore::WalkBuilder,
roots: &[P],
options: &MarkdownWalkOptions,
) {
let gitignore = options.respect_gitignore;
builder
.ignore(gitignore)
.git_ignore(gitignore)
.git_global(gitignore)
.git_exclude(gitignore)
.parents(gitignore)
.hidden(false)
.require_git(stops_at_repository_root(roots))
.add_custom_ignore_filename(".markdownlintignore");
if options.skip_vendor_dirs {
builder.filter_entry(|entry| {
let name = entry.file_name().to_str().unwrap_or("");
name != ".git" && name != "node_modules" && name != "target"
});
}
}
pub fn markdown_walk_builder(root: &Path, options: &MarkdownWalkOptions) -> ignore::WalkBuilder {
let mut builder = ignore::WalkBuilder::new(root);
apply_markdown_walk_options(&mut builder, &[root], options);
builder
}
fn strip_verbatim_prefix(path: &str) -> Cow<'_, str> {
if let Some(rest) = path.strip_prefix(r"\\?\UNC")
&& rest.starts_with('\\')
{
return Cow::Owned(format!(r"\{rest}"));
}
let Some(rest) = path.strip_prefix(r"\\?\") else {
return Cow::Borrowed(path);
};
let is_drive_path = rest.as_bytes().get(1) == Some(&b':');
if is_drive_path {
Cow::Borrowed(rest)
} else {
Cow::Borrowed(path)
}
}
pub fn canonicalize_for_matching(path: &Path) -> Option<PathBuf> {
let canonical = path.canonicalize().ok()?;
if !cfg!(windows) {
return Some(canonical);
}
let as_str = canonical.to_string_lossy();
Some(PathBuf::from(strip_verbatim_prefix(&as_str).as_ref()))
}
fn home_dir() -> Option<PathBuf> {
#[cfg(feature = "native")]
{
use etcetera::{BaseStrategy, choose_base_strategy};
choose_base_strategy()
.ok()
.map(|s| canonicalize_for_matching(s.home_dir()).unwrap_or_else(|| s.home_dir().to_path_buf()))
}
#[cfg(not(feature = "native"))]
{
None
}
}
pub fn expand_home_prefix(pattern: &str) -> Cow<'_, str> {
if !has_home_prefix(pattern) {
return Cow::Borrowed(pattern);
}
expand_home_prefix_impl(pattern, home_dir().as_deref())
}
fn has_home_prefix(pattern: &str) -> bool {
pattern == "~" || pattern.starts_with("~/")
}
fn expand_home_prefix_impl<'a>(pattern: &'a str, home: Option<&Path>) -> Cow<'a, str> {
let Some(suffix) = (if pattern == "~" {
Some("")
} else {
pattern.strip_prefix("~/")
}) else {
return Cow::Borrowed(pattern);
};
let Some(home) = home else {
return Cow::Borrowed(pattern);
};
let home = normalize_pattern_separators(home.to_string_lossy());
let home = home.trim_end_matches('/');
if suffix.is_empty() {
Cow::Owned(home.to_string())
} else {
Cow::Owned(format!("{home}/{suffix}"))
}
}
fn normalize_pattern_separators(path: Cow<'_, str>) -> Cow<'_, str> {
if cfg!(windows) && path.contains('\\') {
Cow::Owned(path.replace('\\', "/"))
} else {
path
}
}
pub fn normalize_pattern_for_base(pattern: &str, base: Option<&Path>) -> String {
let expanded = expand_home_prefix(pattern);
let Some(base) = base else {
return expanded.into_owned();
};
if !is_absolute_pattern(&expanded) {
return expanded.into_owned();
}
let path = Path::new(expanded.as_ref());
let relative = path.strip_prefix(base).ok().or_else(|| {
let canonical = canonicalize_for_matching(base)?;
path.strip_prefix(canonical).ok()
});
match relative {
Some(relative) => normalize_pattern_separators(relative.to_string_lossy()).into_owned(),
None => expanded.into_owned(),
}
}
pub fn expand_directory_pattern(pattern: &str) -> Vec<String> {
let pattern = expand_home_prefix(pattern);
let base = pattern.trim_end_matches('/');
let final_component = base.rsplit('/').next().unwrap_or(base);
if final_component.is_empty() || final_component.contains(['*', '?', '[']) {
return vec![pattern.to_string()];
}
vec![
base.to_string(), format!("{base}/**"), ]
}
pub struct ExcludeMatchers {
matchers: Vec<(String, GlobMatcher)>,
has_absolute: bool,
pub invalid: Vec<(String, String)>,
}
pub fn is_absolute_pattern(pattern: &str) -> bool {
pattern.starts_with('/') || Path::new(pattern).is_absolute()
}
impl ExcludeMatchers {
pub fn new(patterns: &[String]) -> Self {
let mut matchers = Vec::new();
let mut invalid = Vec::new();
let mut has_absolute = false;
for pattern in patterns.iter().flat_map(|p| expand_directory_pattern(p)) {
has_absolute |= is_absolute_pattern(&pattern);
match Glob::new(&pattern) {
Ok(glob) => matchers.push((pattern, glob.compile_matcher())),
Err(e) => invalid.push((pattern, e.to_string())),
}
}
Self {
matchers,
has_absolute,
invalid,
}
}
pub fn is_empty(&self) -> bool {
self.matchers.is_empty()
}
pub fn matched_pattern(&self, relative_path: &str) -> Option<&str> {
self.matchers
.iter()
.find(|(_, matcher)| matcher.is_match(relative_path))
.map(|(pattern, _)| pattern.as_str())
}
pub fn is_match(&self, relative_path: &str) -> bool {
self.matched_pattern(relative_path).is_some()
}
pub fn matched_pattern_for_file(&self, relative: Option<&str>, absolute: &Path) -> Option<&str> {
if let Some(pattern) = relative.and_then(|rel| self.matched_pattern(rel)) {
return Some(pattern);
}
if !self.has_absolute {
return None;
}
let canonical = canonicalize_for_matching(absolute);
let absolute = canonical.as_deref().unwrap_or(absolute);
self.matched_pattern(&normalize_pattern_separators(absolute.to_string_lossy()))
}
pub fn excludes_file(&self, relative: Option<&str>, absolute: &Path) -> bool {
self.matched_pattern_for_file(relative, absolute).is_some()
}
}
pub fn path_relative_to(path: &Path, base: &Path) -> Option<String> {
let canonical_base = base.canonicalize().ok()?;
let canonical_path = path.canonicalize().ok()?;
canonical_path.strip_prefix(&canonical_base).ok().map(|rel| {
let rel = rel.to_string_lossy();
if cfg!(windows) {
rel.replace('\\', "/")
} else {
rel.to_string()
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn markdown_extensions_match_case_insensitively() {
for ext in ["md", "MD", "Rmd", "rmd", "MarkDown", "qmd", "mdx"] {
assert!(is_markdown_extension(OsStr::new(ext)), "{ext} should match");
}
for ext in ["rs", "txt", "mdq", ""] {
assert!(!is_markdown_extension(OsStr::new(ext)), "{ext} should not match");
}
assert!(has_markdown_extension(Path::new("a/b/README.md")));
assert!(has_markdown_extension(Path::new("notebook.Rmd")));
assert!(!has_markdown_extension(Path::new("no_extension")));
assert!(!has_markdown_extension(Path::new("lib.rs")));
}
#[test]
fn the_type_glob_selects_exactly_what_counts_as_markdown() {
assert_eq!(any_case_extension_glob("md"), "*.[mM][dD]");
let mut builder = globset::GlobSetBuilder::new();
for ext in MARKDOWN_EXTENSIONS {
builder.add(
globset::GlobBuilder::new(&any_case_extension_glob(ext))
.literal_separator(true)
.build()
.unwrap(),
);
}
let globs = builder.build().unwrap();
for ext in MARKDOWN_EXTENSIONS {
for spelling in [ext.to_ascii_lowercase(), ext.to_ascii_uppercase(), capitalize(ext)] {
let name = format!("README.{spelling}");
assert!(
globs.is_match(&name),
"{name} is markdown by extension but no type glob selects it"
);
assert!(is_markdown_extension(OsStr::new(&spelling)), "{spelling} should match");
}
}
for name in ["lib.rs", "notes.txt", "README.mdq", "README.m"] {
assert!(!globs.is_match(name), "{name} should not be selected");
}
}
fn capitalize(ext: &str) -> String {
let mut chars = ext.chars();
match chars.next() {
Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
}
#[test]
fn walk_includes_hidden_files() {
let temp = tempdir().unwrap();
fs::create_dir_all(temp.path().join(".github")).unwrap();
fs::write(temp.path().join(".github/PULL_REQUEST_TEMPLATE.md"), "# hi").unwrap();
fs::write(temp.path().join("README.md"), "# hi").unwrap();
let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
.build()
.flatten()
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
.map(|e| e.path().to_path_buf())
.collect();
assert!(files.iter().any(|p| p.ends_with(".github/PULL_REQUEST_TEMPLATE.md")));
assert!(files.iter().any(|p| p.ends_with("README.md")));
}
#[test]
fn walk_honors_gitignore_when_enabled_only() {
let temp = tempdir().unwrap();
fs::write(temp.path().join(".gitignore"), "ignored.md\n").unwrap();
fs::write(temp.path().join("ignored.md"), "# hi").unwrap();
fs::write(temp.path().join("kept.md"), "# hi").unwrap();
let walk = |respect: bool| -> Vec<std::path::PathBuf> {
markdown_walk_builder(
temp.path(),
&MarkdownWalkOptions {
respect_gitignore: respect,
..Default::default()
},
)
.build()
.flatten()
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
.map(|e| e.path().to_path_buf())
.collect()
};
let respected = walk(true);
assert!(!respected.iter().any(|p| p.ends_with("ignored.md")));
assert!(respected.iter().any(|p| p.ends_with("kept.md")));
let unrespected = walk(false);
assert!(unrespected.iter().any(|p| p.ends_with("ignored.md")));
}
#[test]
fn a_gitignore_above_the_repository_root_stays_outside_it() {
let temp = tempdir().unwrap();
fs::write(temp.path().join(".gitignore"), "*.md\n").unwrap();
let repo = temp.path().join("repo");
fs::create_dir_all(repo.join(".git")).unwrap();
fs::write(repo.join("kept.md"), "# hi").unwrap();
let walk = |root: &Path| -> Vec<std::path::PathBuf> {
markdown_walk_builder(root, &MarkdownWalkOptions::default())
.build()
.flatten()
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
.map(|e| e.path().to_path_buf())
.collect()
};
assert!(
walk(&repo).iter().any(|p| p.ends_with("kept.md")),
"git reads no gitignore above the repository root, so neither does the walk"
);
fs::remove_dir(repo.join(".git")).unwrap();
assert!(
!walk(&repo).iter().any(|p| p.ends_with("kept.md")),
"with no repository to bound it, the walk keeps reading upward"
);
}
#[test]
fn the_repository_boundary_needs_every_root_to_have_one() {
let temp = tempdir().unwrap();
let inside = temp.path().join("repo/docs");
fs::create_dir_all(&inside).unwrap();
fs::create_dir_all(temp.path().join("repo/.git")).unwrap();
let outside = temp.path().join("plain");
fs::create_dir_all(&outside).unwrap();
assert!(stops_at_repository_root(&[&inside]), "a root under a repository root");
assert!(!stops_at_repository_root(&[&outside]), "a root under no repository");
assert!(!stops_at_repository_root(&[inside.as_path(), outside.as_path()]));
assert!(!stops_at_repository_root(&[] as &[&Path]), "no root is no repository");
let worktree = temp.path().join("worktree");
fs::create_dir_all(&worktree).unwrap();
fs::write(worktree.join(".git"), "gitdir: /elsewhere/.git/worktrees/x\n").unwrap();
assert!(stops_at_repository_root(&[&worktree]));
}
#[test]
fn walk_honors_markdownlintignore() {
let temp = tempdir().unwrap();
fs::write(temp.path().join(".markdownlintignore"), "legacy.md\n").unwrap();
fs::write(temp.path().join("legacy.md"), "# hi").unwrap();
fs::write(temp.path().join("kept.md"), "# hi").unwrap();
let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
.build()
.flatten()
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
.map(|e| e.path().to_path_buf())
.collect();
assert!(!files.iter().any(|p| p.ends_with("legacy.md")));
assert!(files.iter().any(|p| p.ends_with("kept.md")));
}
#[test]
fn vendor_dirs_skipped_only_when_requested() {
let temp = tempdir().unwrap();
for dir in ["node_modules", "target", "src"] {
fs::create_dir_all(temp.path().join(dir)).unwrap();
fs::write(temp.path().join(dir).join("doc.md"), "# hi").unwrap();
}
let walk = |skip: bool| -> Vec<std::path::PathBuf> {
markdown_walk_builder(
temp.path(),
&MarkdownWalkOptions {
skip_vendor_dirs: skip,
respect_gitignore: false,
},
)
.build()
.flatten()
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
.map(|e| e.path().to_path_buf())
.collect()
};
let skipped = walk(true);
assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("target")));
assert!(skipped.iter().any(|p| p.ends_with("src/doc.md")));
let unskipped = walk(false);
assert!(unskipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
}
#[test]
fn explicit_file_name_glob_extracts_literal_extensions() {
assert_eq!(explicit_file_name_glob("**/*.md.jinja"), Some("*.md.jinja"));
assert_eq!(explicit_file_name_glob("*.md.jinja"), Some("*.md.jinja"));
assert_eq!(explicit_file_name_glob("docs/*.txt"), Some("*.txt"));
assert_eq!(explicit_file_name_glob("templates/NOTES.tmpl"), Some("NOTES.tmpl"));
assert_eq!(explicit_file_name_glob("*.md"), Some("*.md"));
assert_eq!(explicit_file_name_glob("a/b/c/*.md.tmpl"), Some("*.md.tmpl"));
}
#[test]
fn explicit_file_name_glob_rejects_unpinned_patterns() {
for pattern in [
"docs/",
"docs/**",
"docs",
"*",
"**",
"**/*",
"*.*",
"*.md*",
"*.{md,jinja}",
"*.md?",
"data.[ch]",
"!drafts/*.md.jinja",
"",
"**/Makefile",
"*.",
] {
assert_eq!(explicit_file_name_glob(pattern), None, "{pattern:?} should not qualify");
}
}
#[test]
fn explicit_include_matchers_match_full_relative_paths() {
let matchers = ExplicitIncludeMatchers::new(&[
"**/*.md.jinja".to_string(),
"docs/**".to_string(),
"templates/NOTES.tmpl".to_string(),
]);
assert!(!matchers.is_empty());
assert!(matchers.matches_relative_path("test.md.jinja"));
assert!(matchers.matches_relative_path("a/b/test.md.jinja"));
assert!(matchers.matches_relative_path("templates/NOTES.tmpl"));
assert!(!matchers.matches_relative_path("docs/anything.txt"));
assert!(!matchers.matches_relative_path("test.jinja"));
assert!(!matchers.matches_relative_path("docs/NOTES.tmpl"));
assert!(!matchers.matches_relative_path("x/templates/NOTES.tmpl"));
let globs: Vec<_> = matchers.file_name_globs().collect();
assert_eq!(globs, vec!["*.md.jinja", "NOTES.tmpl"]);
}
#[test]
fn explicit_include_matchers_follow_gitignore_anchoring() {
let unanchored = ExplicitIncludeMatchers::new(&["*.md.jinja".to_string()]);
assert!(unanchored.matches_relative_path("test.md.jinja"));
assert!(unanchored.matches_relative_path("a/b/test.md.jinja"));
let anchored = ExplicitIncludeMatchers::new(&["docs/*.txt".to_string()]);
assert!(anchored.matches_relative_path("docs/a.txt"));
assert!(!anchored.matches_relative_path("docs/sub/a.txt"));
assert!(!anchored.matches_relative_path("other/docs/a.txt"));
let rooted = ExplicitIncludeMatchers::new(&["/NOTES.tmpl".to_string()]);
assert!(rooted.matches_relative_path("NOTES.tmpl"));
assert!(!rooted.matches_relative_path("docs/NOTES.tmpl"));
}
#[test]
fn explicit_include_matchers_empty_for_directory_and_wildcard_patterns() {
let matchers = ExplicitIncludeMatchers::new(&["docs/".to_string(), "**/*".to_string()]);
assert!(matchers.is_empty());
assert!(!matchers.matches_relative_path("x.md.jinja"));
}
#[test]
fn explicit_include_matchers_skip_invalid_globs() {
let matchers = ExplicitIncludeMatchers::new(&["bad[.tmpl".to_string(), "**/*.md.jinja".to_string()]);
assert!(matchers.matches_relative_path("ok.md.jinja"));
assert_eq!(matchers.file_name_globs().collect::<Vec<_>>(), vec!["*.md.jinja"]);
}
#[test]
fn exclude_matchers_expand_directory_patterns() {
let matchers = ExcludeMatchers::new(&["drafts".to_string(), "*.tmp.md".to_string()]);
assert!(matchers.is_match("drafts"));
assert!(
matchers.is_match("drafts/inner.md"),
"directory pattern must match contents"
);
assert!(matchers.is_match("note.tmp.md"));
assert!(!matchers.is_match("docs/guide.md"));
assert_eq!(matchers.matched_pattern("drafts/inner.md"), Some("drafts/**"));
assert!(matchers.invalid.is_empty());
}
#[test]
fn expand_home_prefix_expands_only_a_leading_tilde() {
let home = Path::new("/home/dev");
assert_eq!(
expand_home_prefix_impl("~/.cursor/plans", Some(home)),
"/home/dev/.cursor/plans"
);
assert_eq!(expand_home_prefix_impl("~", Some(home)), "/home/dev");
assert_eq!(expand_home_prefix_impl("~/", Some(home)), "/home/dev");
}
#[test]
fn expand_home_prefix_leaves_interior_tildes_alone() {
let home = Path::new("/home/dev");
for pattern in ["backup.md~", "docs/~drafts/**", "~user/docs", "**/*~", "!~/secret"] {
assert_eq!(
expand_home_prefix_impl(pattern, Some(home)),
pattern,
"{pattern:?} must be left as written"
);
}
}
#[test]
fn expand_home_prefix_without_a_home_leaves_the_pattern_as_written() {
assert_eq!(expand_home_prefix_impl("~/.cursor/plans", None), "~/.cursor/plans");
}
#[test]
fn normalize_pattern_for_base_rewrites_absolute_patterns_under_the_base() {
let temp = tempdir().unwrap();
let base = canonicalize_for_matching(temp.path()).unwrap();
let pattern = format!("{}/docs/**", base.to_string_lossy().replace('\\', "/"));
assert_eq!(normalize_pattern_for_base(&pattern, Some(&base)), "docs/**");
}
#[test]
fn normalize_pattern_for_base_strips_through_a_non_canonical_base() {
let temp = tempdir().unwrap();
let canonical = canonicalize_for_matching(temp.path()).unwrap();
let pattern = format!("{}/docs/**", canonical.to_string_lossy().replace('\\', "/"));
assert_eq!(normalize_pattern_for_base(&pattern, Some(temp.path())), "docs/**");
}
#[test]
fn normalize_pattern_for_base_leaves_other_patterns_alone() {
let temp = tempdir().unwrap();
let base = canonicalize_for_matching(temp.path()).unwrap();
assert_eq!(normalize_pattern_for_base("docs/**", Some(&base)), "docs/**");
assert_eq!(
normalize_pattern_for_base("/somewhere/else/**", Some(&base)),
"/somewhere/else/**"
);
assert_eq!(normalize_pattern_for_base("/abs/docs/**", None), "/abs/docs/**");
}
#[test]
fn strip_verbatim_prefix_unwraps_windows_canonical_paths() {
assert_eq!(
strip_verbatim_prefix(r"\\?\C:\Users\dev\AppData\Local\Temp\x"),
r"C:\Users\dev\AppData\Local\Temp\x"
);
assert_eq!(strip_verbatim_prefix(r"\\?\C:\"), r"C:\");
assert_eq!(
strip_verbatim_prefix(r"\\?\UNC\server\share\docs"),
r"\\server\share\docs"
);
}
#[test]
fn strip_verbatim_prefix_leaves_other_paths_alone() {
for path in [
"/home/dev/docs",
r"C:\Users\dev",
r"\\server\share",
r"\\?\Volume{b75e2c83-0000-0000-0000-602f00000000}\docs",
r"\\?\",
"",
] {
assert_eq!(strip_verbatim_prefix(path), path, "{path:?} must be left as written");
}
}
#[test]
fn expand_directory_pattern_expands_a_literal_final_component() {
assert_eq!(
expand_directory_pattern("**/.cursor/plans"),
vec!["**/.cursor/plans", "**/.cursor/plans/**"]
);
assert_eq!(
expand_directory_pattern("docs/**/drafts"),
vec!["docs/**/drafts", "docs/**/drafts/**"]
);
assert_eq!(
expand_directory_pattern("logs/{a,b}"),
vec!["logs/{a,b}", "logs/{a,b}/**"]
);
}
#[test]
fn expand_directory_pattern_leaves_a_wildcard_final_component_alone() {
for pattern in ["docs/*", "*.tmp.md", "build/**", "data.[ch]", "notes?"] {
assert_eq!(
expand_directory_pattern(pattern),
vec![pattern.to_string()],
"{pattern:?} must not gain a contents-expansion"
);
}
}
#[test]
fn exclude_matchers_match_an_absolute_pattern_against_an_absolute_path() {
let matchers = ExcludeMatchers::new(&["/home/dev/.cursor/plans".to_string()]);
let excluded = Path::new("/home/dev/.cursor/plans/plan.md");
assert!(
matchers.excludes_file(None, excluded),
"an absolute pattern must match the absolute path when there is no relative form"
);
assert_eq!(
matchers.matched_pattern_for_file(None, excluded),
Some("/home/dev/.cursor/plans/**")
);
assert!(matchers.excludes_file(Some(".cursor/plans/plan.md"), excluded));
assert!(!matchers.excludes_file(Some("docs/guide.md"), Path::new("/home/dev/docs/guide.md")));
}
#[test]
fn exclude_matchers_do_not_let_relative_patterns_match_absolute_paths() {
let matchers = ExcludeMatchers::new(&["drafts".to_string()]);
assert!(!matchers.excludes_file(None, Path::new("/home/dev/proj/drafts/note.md")));
assert!(matchers.excludes_file(Some("drafts/note.md"), Path::new("/home/dev/proj/drafts/note.md")));
}
#[test]
fn exclude_matchers_report_invalid_patterns() {
let matchers = ExcludeMatchers::new(&["[".to_string(), "ok.md".to_string()]);
assert_eq!(matchers.invalid.len(), 1);
assert_eq!(matchers.invalid[0].0, "[");
assert!(matchers.is_match("ok.md"));
}
#[test]
fn path_relative_to_strips_through_symlinked_base() {
let temp = tempdir().unwrap();
let base = temp.path().join("base");
fs::create_dir_all(base.join("docs")).unwrap();
fs::write(base.join("docs/a.md"), "# hi").unwrap();
assert_eq!(
path_relative_to(&base.join("docs/a.md"), &base).as_deref(),
Some("docs/a.md")
);
assert_eq!(
path_relative_to(&base.join("docs/a.md"), &base.join("docs")).as_deref(),
Some("a.md")
);
assert_eq!(path_relative_to(temp.path(), &base), None, "path outside base");
}
}