1use globset::{Glob, GlobMatcher};
18use std::borrow::Cow;
19use std::ffi::OsStr;
20use std::path::{Path, PathBuf};
21
22const GLOB_METACHARS: &[char] = &['*', '?', '[', ']', '{', '}'];
25
26pub fn explicit_file_name_glob(pattern: &str) -> Option<&str> {
42 if pattern.starts_with('!') {
43 return None;
44 }
45 let file_name = pattern.rsplit('/').next().unwrap_or(pattern);
46 if file_name.is_empty() {
47 return None;
48 }
49 let literal_tail = match file_name.rfind(GLOB_METACHARS) {
52 Some(idx) => &file_name[idx + 1..],
53 None => file_name,
54 };
55 match literal_tail.rsplit_once('.') {
56 Some((_, ext)) if !ext.is_empty() => Some(file_name),
57 _ => None,
58 }
59}
60
61pub struct ExplicitIncludeMatchers {
80 matchers: Vec<ExplicitInclude>,
81}
82
83struct ExplicitInclude {
84 file_name_glob: String,
85 path_matcher: GlobMatcher,
86}
87
88impl ExplicitIncludeMatchers {
89 pub fn new(patterns: &[String]) -> Self {
90 let matchers = patterns
91 .iter()
92 .filter_map(|pattern| {
93 let file_name_glob = explicit_file_name_glob(pattern)?;
94 let path_glob = if let Some(anchored) = pattern.strip_prefix('/') {
95 anchored.to_string()
96 } else if pattern.contains('/') {
97 pattern.clone()
98 } else {
99 format!("**/{pattern}")
100 };
101 let path_matcher = globset::GlobBuilder::new(&path_glob)
102 .literal_separator(true)
103 .build()
104 .ok()?
105 .compile_matcher();
106 Some(ExplicitInclude {
107 file_name_glob: file_name_glob.to_string(),
108 path_matcher,
109 })
110 })
111 .collect();
112 Self { matchers }
113 }
114
115 pub fn is_empty(&self) -> bool {
116 self.matchers.is_empty()
117 }
118
119 pub fn file_name_globs(&self) -> impl Iterator<Item = &str> {
121 self.matchers.iter().map(|m| m.file_name_glob.as_str())
122 }
123
124 pub fn matches_relative_path(&self, path: &str) -> bool {
127 self.matchers.iter().any(|m| m.path_matcher.is_match(path))
128 }
129}
130
131pub const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
133
134#[inline]
137pub fn is_markdown_extension(ext: &OsStr) -> bool {
138 ext.to_str()
139 .is_some_and(|s| MARKDOWN_EXTENSIONS.iter().any(|known| s.eq_ignore_ascii_case(known)))
140}
141
142#[inline]
144pub fn has_markdown_extension(path: &Path) -> bool {
145 path.extension().is_some_and(is_markdown_extension)
146}
147
148pub fn any_case_extension_glob(ext: &str) -> String {
156 let mut glob = String::with_capacity(2 + ext.len() * 4);
157 glob.push_str("*.");
158 for ch in ext.chars() {
159 if ch.is_ascii_alphabetic() {
160 glob.push('[');
161 glob.push(ch.to_ascii_lowercase());
162 glob.push(ch.to_ascii_uppercase());
163 glob.push(']');
164 } else {
165 glob.push(ch);
166 }
167 }
168 glob
169}
170
171#[derive(Debug, Clone)]
173pub struct MarkdownWalkOptions {
174 pub respect_gitignore: bool,
177 pub skip_vendor_dirs: bool,
180}
181
182impl Default for MarkdownWalkOptions {
183 fn default() -> Self {
184 Self {
185 respect_gitignore: true,
186 skip_vendor_dirs: false,
187 }
188 }
189}
190
191pub fn stops_at_repository_root<P: AsRef<Path>>(roots: &[P]) -> bool {
203 !roots.is_empty() && roots.iter().all(|root| in_repository(root.as_ref()))
204}
205
206fn in_repository(path: &Path) -> bool {
212 let Ok(absolute) = std::fs::canonicalize(path) else {
213 return false;
214 };
215 absolute
216 .ancestors()
217 .any(|dir| dir.join(".git").exists() || dir.join(".jj").exists())
218}
219
220pub fn apply_markdown_walk_options<P: AsRef<Path>>(
230 builder: &mut ignore::WalkBuilder,
231 roots: &[P],
232 options: &MarkdownWalkOptions,
233) {
234 let gitignore = options.respect_gitignore;
235 builder
236 .ignore(gitignore)
237 .git_ignore(gitignore)
238 .git_global(gitignore)
239 .git_exclude(gitignore)
240 .parents(gitignore)
241 .hidden(false)
242 .require_git(stops_at_repository_root(roots))
248 .add_custom_ignore_filename(".markdownlintignore");
249
250 if options.skip_vendor_dirs {
251 builder.filter_entry(|entry| {
252 let name = entry.file_name().to_str().unwrap_or("");
253 name != ".git" && name != "node_modules" && name != "target"
254 });
255 }
256}
257
258pub fn markdown_walk_builder(root: &Path, options: &MarkdownWalkOptions) -> ignore::WalkBuilder {
260 let mut builder = ignore::WalkBuilder::new(root);
261 apply_markdown_walk_options(&mut builder, &[root], options);
262 builder
263}
264
265fn strip_verbatim_prefix(path: &str) -> Cow<'_, str> {
280 if let Some(rest) = path.strip_prefix(r"\\?\UNC")
283 && rest.starts_with('\\')
284 {
285 return Cow::Owned(format!(r"\{rest}"));
286 }
287 let Some(rest) = path.strip_prefix(r"\\?\") else {
288 return Cow::Borrowed(path);
289 };
290 let is_drive_path = rest.as_bytes().get(1) == Some(&b':');
291 if is_drive_path {
292 Cow::Borrowed(rest)
293 } else {
294 Cow::Borrowed(path)
295 }
296}
297
298pub fn canonicalize_for_matching(path: &Path) -> Option<PathBuf> {
306 let canonical = path.canonicalize().ok()?;
307 if !cfg!(windows) {
308 return Some(canonical);
309 }
310 let as_str = canonical.to_string_lossy();
311 Some(PathBuf::from(strip_verbatim_prefix(&as_str).as_ref()))
312}
313
314fn home_dir() -> Option<PathBuf> {
322 #[cfg(feature = "native")]
323 {
324 use etcetera::{BaseStrategy, choose_base_strategy};
325 choose_base_strategy()
326 .ok()
327 .map(|s| canonicalize_for_matching(s.home_dir()).unwrap_or_else(|| s.home_dir().to_path_buf()))
328 }
329 #[cfg(not(feature = "native"))]
330 {
331 None
332 }
333}
334
335pub fn expand_home_prefix(pattern: &str) -> Cow<'_, str> {
349 if !has_home_prefix(pattern) {
352 return Cow::Borrowed(pattern);
353 }
354 expand_home_prefix_impl(pattern, home_dir().as_deref())
355}
356
357fn has_home_prefix(pattern: &str) -> bool {
359 pattern == "~" || pattern.starts_with("~/")
360}
361
362fn expand_home_prefix_impl<'a>(pattern: &'a str, home: Option<&Path>) -> Cow<'a, str> {
363 let Some(suffix) = (if pattern == "~" {
364 Some("")
365 } else {
366 pattern.strip_prefix("~/")
367 }) else {
368 return Cow::Borrowed(pattern);
369 };
370 let Some(home) = home else {
371 return Cow::Borrowed(pattern);
372 };
373
374 let home = normalize_pattern_separators(home.to_string_lossy());
375 let home = home.trim_end_matches('/');
376 if suffix.is_empty() {
377 Cow::Owned(home.to_string())
378 } else {
379 Cow::Owned(format!("{home}/{suffix}"))
380 }
381}
382
383fn normalize_pattern_separators(path: Cow<'_, str>) -> Cow<'_, str> {
388 if cfg!(windows) && path.contains('\\') {
389 Cow::Owned(path.replace('\\', "/"))
390 } else {
391 path
392 }
393}
394
395pub fn normalize_pattern_for_base(pattern: &str, base: Option<&Path>) -> String {
406 let expanded = expand_home_prefix(pattern);
407 let Some(base) = base else {
408 return expanded.into_owned();
409 };
410 if !is_absolute_pattern(&expanded) {
411 return expanded.into_owned();
412 }
413
414 let path = Path::new(expanded.as_ref());
417 let relative = path.strip_prefix(base).ok().or_else(|| {
418 let canonical = canonicalize_for_matching(base)?;
419 path.strip_prefix(canonical).ok()
420 });
421 match relative {
422 Some(relative) => normalize_pattern_separators(relative.to_string_lossy()).into_owned(),
423 None => expanded.into_owned(),
424 }
425}
426
427pub fn expand_directory_pattern(pattern: &str) -> Vec<String> {
438 let pattern = expand_home_prefix(pattern);
439 let base = pattern.trim_end_matches('/');
440 let final_component = base.rsplit('/').next().unwrap_or(base);
441
442 if final_component.is_empty() || final_component.contains(['*', '?', '[']) {
443 return vec![pattern.to_string()];
444 }
445
446 vec![
447 base.to_string(), format!("{base}/**"), ]
450}
451
452pub fn exclude_override_rule(pattern: &str) -> String {
457 if pattern.starts_with('!') {
458 pattern.to_string()
459 } else {
460 format!("!{pattern}")
461 }
462}
463
464pub fn exclude_pattern_compiles(pattern: &str) -> bool {
471 expand_directory_pattern(pattern).iter().all(|expanded| {
472 Glob::new(expanded).is_ok()
473 && ignore::overrides::OverrideBuilder::new(Path::new("."))
474 .add(&exclude_override_rule(expanded))
475 .is_ok()
476 })
477}
478
479pub fn include_pattern_compiles(pattern: &str) -> bool {
488 ignore::overrides::OverrideBuilder::new(Path::new("."))
489 .add(&expand_home_prefix(pattern))
490 .is_ok()
491}
492
493pub struct ExcludeMatchers {
500 matchers: Vec<(String, GlobMatcher)>,
501 has_absolute: bool,
505 pub invalid: Vec<(String, String)>,
508}
509
510pub fn is_absolute_pattern(pattern: &str) -> bool {
514 pattern.starts_with('/') || Path::new(pattern).is_absolute()
515}
516
517impl ExcludeMatchers {
518 pub fn new(patterns: &[String]) -> Self {
519 let mut matchers = Vec::new();
520 let mut invalid = Vec::new();
521 let mut has_absolute = false;
522 for pattern in patterns.iter().flat_map(|p| expand_directory_pattern(p)) {
523 has_absolute |= is_absolute_pattern(&pattern);
524 match Glob::new(&pattern) {
525 Ok(glob) => matchers.push((pattern, glob.compile_matcher())),
526 Err(e) => invalid.push((pattern, e.to_string())),
527 }
528 }
529 Self {
530 matchers,
531 has_absolute,
532 invalid,
533 }
534 }
535
536 pub fn is_empty(&self) -> bool {
537 self.matchers.is_empty()
538 }
539
540 pub fn matched_pattern(&self, relative_path: &str) -> Option<&str> {
542 self.matchers
543 .iter()
544 .find(|(_, matcher)| matcher.is_match(relative_path))
545 .map(|(pattern, _)| pattern.as_str())
546 }
547
548 pub fn is_match(&self, relative_path: &str) -> bool {
549 self.matched_pattern(relative_path).is_some()
550 }
551
552 pub fn matched_pattern_for_file(&self, relative: Option<&str>, absolute: &Path) -> Option<&str> {
569 if let Some(pattern) = relative.and_then(|rel| self.matched_pattern(rel)) {
570 return Some(pattern);
571 }
572 if !self.has_absolute {
573 return None;
574 }
575 let canonical = canonicalize_for_matching(absolute);
576 let absolute = canonical.as_deref().unwrap_or(absolute);
577 self.matched_pattern(&normalize_pattern_separators(absolute.to_string_lossy()))
578 }
579
580 pub fn excludes_file(&self, relative: Option<&str>, absolute: &Path) -> bool {
582 self.matched_pattern_for_file(relative, absolute).is_some()
583 }
584}
585
586pub fn path_relative_to(path: &Path, base: &Path) -> Option<String> {
595 let canonical_base = base.canonicalize().ok()?;
596 let canonical_path = path.canonicalize().ok()?;
597 canonical_path.strip_prefix(&canonical_base).ok().map(|rel| {
598 let rel = rel.to_string_lossy();
599 if cfg!(windows) {
600 rel.replace('\\', "/")
601 } else {
602 rel.to_string()
603 }
604 })
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610 use std::fs;
611 use tempfile::tempdir;
612
613 #[test]
614 fn markdown_extensions_match_case_insensitively() {
615 for ext in ["md", "MD", "Rmd", "rmd", "MarkDown", "qmd", "mdx"] {
616 assert!(is_markdown_extension(OsStr::new(ext)), "{ext} should match");
617 }
618 for ext in ["rs", "txt", "mdq", ""] {
619 assert!(!is_markdown_extension(OsStr::new(ext)), "{ext} should not match");
620 }
621 assert!(has_markdown_extension(Path::new("a/b/README.md")));
622 assert!(has_markdown_extension(Path::new("notebook.Rmd")));
623 assert!(!has_markdown_extension(Path::new("no_extension")));
624 assert!(!has_markdown_extension(Path::new("lib.rs")));
625 }
626
627 #[test]
628 fn the_type_glob_selects_exactly_what_counts_as_markdown() {
629 assert_eq!(any_case_extension_glob("md"), "*.[mM][dD]");
630
631 let mut builder = globset::GlobSetBuilder::new();
634 for ext in MARKDOWN_EXTENSIONS {
635 builder.add(
636 globset::GlobBuilder::new(&any_case_extension_glob(ext))
637 .literal_separator(true)
638 .build()
639 .unwrap(),
640 );
641 }
642 let globs = builder.build().unwrap();
643
644 for ext in MARKDOWN_EXTENSIONS {
645 for spelling in [ext.to_ascii_lowercase(), ext.to_ascii_uppercase(), capitalize(ext)] {
646 let name = format!("README.{spelling}");
647 assert!(
648 globs.is_match(&name),
649 "{name} is markdown by extension but no type glob selects it"
650 );
651 assert!(is_markdown_extension(OsStr::new(&spelling)), "{spelling} should match");
652 }
653 }
654
655 for name in ["lib.rs", "notes.txt", "README.mdq", "README.m"] {
657 assert!(!globs.is_match(name), "{name} should not be selected");
658 }
659 }
660
661 fn capitalize(ext: &str) -> String {
662 let mut chars = ext.chars();
663 match chars.next() {
664 Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
665 None => String::new(),
666 }
667 }
668
669 #[test]
670 fn walk_includes_hidden_files() {
671 let temp = tempdir().unwrap();
672 fs::create_dir_all(temp.path().join(".github")).unwrap();
673 fs::write(temp.path().join(".github/PULL_REQUEST_TEMPLATE.md"), "# hi").unwrap();
674 fs::write(temp.path().join("README.md"), "# hi").unwrap();
675
676 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
677 .build()
678 .flatten()
679 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
680 .map(|e| e.path().to_path_buf())
681 .collect();
682 assert!(files.iter().any(|p| p.ends_with(".github/PULL_REQUEST_TEMPLATE.md")));
683 assert!(files.iter().any(|p| p.ends_with("README.md")));
684 }
685
686 #[test]
687 fn walk_honors_gitignore_when_enabled_only() {
688 let temp = tempdir().unwrap();
689 fs::write(temp.path().join(".gitignore"), "ignored.md\n").unwrap();
690 fs::write(temp.path().join("ignored.md"), "# hi").unwrap();
691 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
692
693 let walk = |respect: bool| -> Vec<std::path::PathBuf> {
694 markdown_walk_builder(
695 temp.path(),
696 &MarkdownWalkOptions {
697 respect_gitignore: respect,
698 ..Default::default()
699 },
700 )
701 .build()
702 .flatten()
703 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
704 .map(|e| e.path().to_path_buf())
705 .collect()
706 };
707
708 let respected = walk(true);
709 assert!(!respected.iter().any(|p| p.ends_with("ignored.md")));
710 assert!(respected.iter().any(|p| p.ends_with("kept.md")));
711
712 let unrespected = walk(false);
713 assert!(unrespected.iter().any(|p| p.ends_with("ignored.md")));
714 }
715
716 #[test]
717 fn a_gitignore_above_the_repository_root_stays_outside_it() {
718 let temp = tempdir().unwrap();
719 fs::write(temp.path().join(".gitignore"), "*.md\n").unwrap();
720 let repo = temp.path().join("repo");
721 fs::create_dir_all(repo.join(".git")).unwrap();
722 fs::write(repo.join("kept.md"), "# hi").unwrap();
723
724 let walk = |root: &Path| -> Vec<std::path::PathBuf> {
725 markdown_walk_builder(root, &MarkdownWalkOptions::default())
726 .build()
727 .flatten()
728 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
729 .map(|e| e.path().to_path_buf())
730 .collect()
731 };
732
733 assert!(
734 walk(&repo).iter().any(|p| p.ends_with("kept.md")),
735 "git reads no gitignore above the repository root, so neither does the walk"
736 );
737
738 fs::remove_dir(repo.join(".git")).unwrap();
741 assert!(
742 !walk(&repo).iter().any(|p| p.ends_with("kept.md")),
743 "with no repository to bound it, the walk keeps reading upward"
744 );
745 }
746
747 #[test]
748 fn the_repository_boundary_needs_every_root_to_have_one() {
749 let temp = tempdir().unwrap();
750 let inside = temp.path().join("repo/docs");
751 fs::create_dir_all(&inside).unwrap();
752 fs::create_dir_all(temp.path().join("repo/.git")).unwrap();
753 let outside = temp.path().join("plain");
754 fs::create_dir_all(&outside).unwrap();
755
756 assert!(stops_at_repository_root(&[&inside]), "a root under a repository root");
757 assert!(!stops_at_repository_root(&[&outside]), "a root under no repository");
758
759 assert!(!stops_at_repository_root(&[inside.as_path(), outside.as_path()]));
763 assert!(!stops_at_repository_root(&[] as &[&Path]), "no root is no repository");
764
765 let worktree = temp.path().join("worktree");
768 fs::create_dir_all(&worktree).unwrap();
769 fs::write(worktree.join(".git"), "gitdir: /elsewhere/.git/worktrees/x\n").unwrap();
770 assert!(stops_at_repository_root(&[&worktree]));
771 }
772
773 #[test]
774 fn walk_honors_markdownlintignore() {
775 let temp = tempdir().unwrap();
776 fs::write(temp.path().join(".markdownlintignore"), "legacy.md\n").unwrap();
777 fs::write(temp.path().join("legacy.md"), "# hi").unwrap();
778 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
779
780 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
781 .build()
782 .flatten()
783 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
784 .map(|e| e.path().to_path_buf())
785 .collect();
786 assert!(!files.iter().any(|p| p.ends_with("legacy.md")));
787 assert!(files.iter().any(|p| p.ends_with("kept.md")));
788 }
789
790 #[test]
791 fn vendor_dirs_skipped_only_when_requested() {
792 let temp = tempdir().unwrap();
793 for dir in ["node_modules", "target", "src"] {
794 fs::create_dir_all(temp.path().join(dir)).unwrap();
795 fs::write(temp.path().join(dir).join("doc.md"), "# hi").unwrap();
796 }
797
798 let walk = |skip: bool| -> Vec<std::path::PathBuf> {
799 markdown_walk_builder(
800 temp.path(),
801 &MarkdownWalkOptions {
802 skip_vendor_dirs: skip,
803 respect_gitignore: false,
807 },
808 )
809 .build()
810 .flatten()
811 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
812 .map(|e| e.path().to_path_buf())
813 .collect()
814 };
815
816 let skipped = walk(true);
817 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
818 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("target")));
819 assert!(skipped.iter().any(|p| p.ends_with("src/doc.md")));
820
821 let unskipped = walk(false);
822 assert!(unskipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
823 }
824
825 #[test]
826 fn explicit_file_name_glob_extracts_literal_extensions() {
827 assert_eq!(explicit_file_name_glob("**/*.md.jinja"), Some("*.md.jinja"));
828 assert_eq!(explicit_file_name_glob("*.md.jinja"), Some("*.md.jinja"));
829 assert_eq!(explicit_file_name_glob("docs/*.txt"), Some("*.txt"));
830 assert_eq!(explicit_file_name_glob("templates/NOTES.tmpl"), Some("NOTES.tmpl"));
831 assert_eq!(explicit_file_name_glob("*.md"), Some("*.md"));
832 assert_eq!(explicit_file_name_glob("a/b/c/*.md.tmpl"), Some("*.md.tmpl"));
833 }
834
835 #[test]
836 fn explicit_file_name_glob_rejects_unpinned_patterns() {
837 for pattern in [
838 "docs/",
839 "docs/**",
840 "docs",
841 "*",
842 "**",
843 "**/*",
844 "*.*",
845 "*.md*",
846 "*.{md,jinja}",
847 "*.md?",
848 "data.[ch]",
849 "!drafts/*.md.jinja",
850 "",
851 "**/Makefile",
852 "*.",
853 ] {
854 assert_eq!(explicit_file_name_glob(pattern), None, "{pattern:?} should not qualify");
855 }
856 }
857
858 #[test]
859 fn explicit_include_matchers_match_full_relative_paths() {
860 let matchers = ExplicitIncludeMatchers::new(&[
861 "**/*.md.jinja".to_string(),
862 "docs/**".to_string(),
863 "templates/NOTES.tmpl".to_string(),
864 ]);
865 assert!(!matchers.is_empty());
866 assert!(matchers.matches_relative_path("test.md.jinja"));
867 assert!(matchers.matches_relative_path("a/b/test.md.jinja"));
868 assert!(matchers.matches_relative_path("templates/NOTES.tmpl"));
869 assert!(!matchers.matches_relative_path("docs/anything.txt"));
871 assert!(!matchers.matches_relative_path("test.jinja"));
872 assert!(!matchers.matches_relative_path("docs/NOTES.tmpl"));
875 assert!(!matchers.matches_relative_path("x/templates/NOTES.tmpl"));
876
877 let globs: Vec<_> = matchers.file_name_globs().collect();
878 assert_eq!(globs, vec!["*.md.jinja", "NOTES.tmpl"]);
879 }
880
881 #[test]
882 fn explicit_include_matchers_follow_gitignore_anchoring() {
883 let unanchored = ExplicitIncludeMatchers::new(&["*.md.jinja".to_string()]);
885 assert!(unanchored.matches_relative_path("test.md.jinja"));
886 assert!(unanchored.matches_relative_path("a/b/test.md.jinja"));
887
888 let anchored = ExplicitIncludeMatchers::new(&["docs/*.txt".to_string()]);
890 assert!(anchored.matches_relative_path("docs/a.txt"));
891 assert!(!anchored.matches_relative_path("docs/sub/a.txt"));
892 assert!(!anchored.matches_relative_path("other/docs/a.txt"));
893
894 let rooted = ExplicitIncludeMatchers::new(&["/NOTES.tmpl".to_string()]);
896 assert!(rooted.matches_relative_path("NOTES.tmpl"));
897 assert!(!rooted.matches_relative_path("docs/NOTES.tmpl"));
898 }
899
900 #[test]
901 fn explicit_include_matchers_empty_for_directory_and_wildcard_patterns() {
902 let matchers = ExplicitIncludeMatchers::new(&["docs/".to_string(), "**/*".to_string()]);
903 assert!(matchers.is_empty());
904 assert!(!matchers.matches_relative_path("x.md.jinja"));
905 }
906
907 #[test]
908 fn explicit_include_matchers_skip_invalid_globs() {
909 let matchers = ExplicitIncludeMatchers::new(&["bad[.tmpl".to_string(), "**/*.md.jinja".to_string()]);
912 assert!(matchers.matches_relative_path("ok.md.jinja"));
913 assert_eq!(matchers.file_name_globs().collect::<Vec<_>>(), vec!["*.md.jinja"]);
914 }
915
916 #[test]
917 fn exclude_matchers_expand_directory_patterns() {
918 let matchers = ExcludeMatchers::new(&["drafts".to_string(), "*.tmp.md".to_string()]);
919 assert!(matchers.is_match("drafts"));
920 assert!(
921 matchers.is_match("drafts/inner.md"),
922 "directory pattern must match contents"
923 );
924 assert!(matchers.is_match("note.tmp.md"));
925 assert!(!matchers.is_match("docs/guide.md"));
926 assert_eq!(matchers.matched_pattern("drafts/inner.md"), Some("drafts/**"));
927 assert!(matchers.invalid.is_empty());
928 }
929
930 #[test]
931 fn expand_home_prefix_expands_only_a_leading_tilde() {
932 let home = Path::new("/home/dev");
933 assert_eq!(
934 expand_home_prefix_impl("~/.cursor/plans", Some(home)),
935 "/home/dev/.cursor/plans"
936 );
937 assert_eq!(expand_home_prefix_impl("~", Some(home)), "/home/dev");
938 assert_eq!(expand_home_prefix_impl("~/", Some(home)), "/home/dev");
939 }
940
941 #[test]
942 fn expand_home_prefix_leaves_interior_tildes_alone() {
943 let home = Path::new("/home/dev");
944 for pattern in ["backup.md~", "docs/~drafts/**", "~user/docs", "**/*~", "!~/secret"] {
946 assert_eq!(
947 expand_home_prefix_impl(pattern, Some(home)),
948 pattern,
949 "{pattern:?} must be left as written"
950 );
951 }
952 }
953
954 #[test]
955 fn expand_home_prefix_without_a_home_leaves_the_pattern_as_written() {
956 assert_eq!(expand_home_prefix_impl("~/.cursor/plans", None), "~/.cursor/plans");
957 }
958
959 #[test]
960 fn normalize_pattern_for_base_rewrites_absolute_patterns_under_the_base() {
961 let temp = tempdir().unwrap();
962 let base = canonicalize_for_matching(temp.path()).unwrap();
965 let pattern = format!("{}/docs/**", base.to_string_lossy().replace('\\', "/"));
966 assert_eq!(normalize_pattern_for_base(&pattern, Some(&base)), "docs/**");
967 }
968
969 #[test]
970 fn normalize_pattern_for_base_strips_through_a_non_canonical_base() {
971 let temp = tempdir().unwrap();
974 let canonical = canonicalize_for_matching(temp.path()).unwrap();
975 let pattern = format!("{}/docs/**", canonical.to_string_lossy().replace('\\', "/"));
976 assert_eq!(normalize_pattern_for_base(&pattern, Some(temp.path())), "docs/**");
977 }
978
979 #[test]
980 fn normalize_pattern_for_base_leaves_other_patterns_alone() {
981 let temp = tempdir().unwrap();
982 let base = canonicalize_for_matching(temp.path()).unwrap();
983 assert_eq!(normalize_pattern_for_base("docs/**", Some(&base)), "docs/**");
985 assert_eq!(
988 normalize_pattern_for_base("/somewhere/else/**", Some(&base)),
989 "/somewhere/else/**"
990 );
991 assert_eq!(normalize_pattern_for_base("/abs/docs/**", None), "/abs/docs/**");
993 }
994
995 #[test]
996 fn strip_verbatim_prefix_unwraps_windows_canonical_paths() {
997 assert_eq!(
1000 strip_verbatim_prefix(r"\\?\C:\Users\dev\AppData\Local\Temp\x"),
1001 r"C:\Users\dev\AppData\Local\Temp\x"
1002 );
1003 assert_eq!(strip_verbatim_prefix(r"\\?\C:\"), r"C:\");
1004 assert_eq!(
1006 strip_verbatim_prefix(r"\\?\UNC\server\share\docs"),
1007 r"\\server\share\docs"
1008 );
1009 }
1010
1011 #[test]
1012 fn strip_verbatim_prefix_leaves_other_paths_alone() {
1013 for path in [
1014 "/home/dev/docs",
1015 r"C:\Users\dev",
1016 r"\\server\share",
1017 r"\\?\Volume{b75e2c83-0000-0000-0000-602f00000000}\docs",
1019 r"\\?\",
1020 "",
1021 ] {
1022 assert_eq!(strip_verbatim_prefix(path), path, "{path:?} must be left as written");
1023 }
1024 }
1025
1026 #[test]
1027 fn expand_directory_pattern_expands_a_literal_final_component() {
1028 assert_eq!(
1031 expand_directory_pattern("**/.cursor/plans"),
1032 vec!["**/.cursor/plans", "**/.cursor/plans/**"]
1033 );
1034 assert_eq!(
1035 expand_directory_pattern("docs/**/drafts"),
1036 vec!["docs/**/drafts", "docs/**/drafts/**"]
1037 );
1038 assert_eq!(
1040 expand_directory_pattern("logs/{a,b}"),
1041 vec!["logs/{a,b}", "logs/{a,b}/**"]
1042 );
1043 }
1044
1045 #[test]
1046 fn expand_directory_pattern_leaves_a_wildcard_final_component_alone() {
1047 for pattern in ["docs/*", "*.tmp.md", "build/**", "data.[ch]", "notes?"] {
1050 assert_eq!(
1051 expand_directory_pattern(pattern),
1052 vec![pattern.to_string()],
1053 "{pattern:?} must not gain a contents-expansion"
1054 );
1055 }
1056 }
1057
1058 #[test]
1059 fn exclude_matchers_match_an_absolute_pattern_against_an_absolute_path() {
1060 let matchers = ExcludeMatchers::new(&["/home/dev/.cursor/plans".to_string()]);
1061 let excluded = Path::new("/home/dev/.cursor/plans/plan.md");
1062 assert!(
1063 matchers.excludes_file(None, excluded),
1064 "an absolute pattern must match the absolute path when there is no relative form"
1065 );
1066 assert_eq!(
1067 matchers.matched_pattern_for_file(None, excluded),
1068 Some("/home/dev/.cursor/plans/**")
1069 );
1070 assert!(matchers.excludes_file(Some(".cursor/plans/plan.md"), excluded));
1073 assert!(!matchers.excludes_file(Some("docs/guide.md"), Path::new("/home/dev/docs/guide.md")));
1074 }
1075
1076 #[test]
1077 fn exclude_matchers_do_not_let_relative_patterns_match_absolute_paths() {
1078 let matchers = ExcludeMatchers::new(&["drafts".to_string()]);
1081 assert!(!matchers.excludes_file(None, Path::new("/home/dev/proj/drafts/note.md")));
1082 assert!(matchers.excludes_file(Some("drafts/note.md"), Path::new("/home/dev/proj/drafts/note.md")));
1083 }
1084
1085 #[test]
1086 fn exclude_matchers_report_invalid_patterns() {
1087 let matchers = ExcludeMatchers::new(&["[".to_string(), "ok.md".to_string()]);
1088 assert_eq!(matchers.invalid.len(), 1);
1089 assert_eq!(matchers.invalid[0].0, "[");
1090 assert!(matchers.is_match("ok.md"));
1091 }
1092
1093 #[test]
1094 fn path_relative_to_strips_through_symlinked_base() {
1095 let temp = tempdir().unwrap();
1096 let base = temp.path().join("base");
1097 fs::create_dir_all(base.join("docs")).unwrap();
1098 fs::write(base.join("docs/a.md"), "# hi").unwrap();
1099
1100 assert_eq!(
1101 path_relative_to(&base.join("docs/a.md"), &base).as_deref(),
1102 Some("docs/a.md")
1103 );
1104 assert_eq!(
1105 path_relative_to(&base.join("docs/a.md"), &base.join("docs")).as_deref(),
1106 Some("a.md")
1107 );
1108 assert_eq!(path_relative_to(temp.path(), &base), None, "path outside base");
1109 }
1110}