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
148#[derive(Debug, Clone)]
150pub struct MarkdownWalkOptions {
151 pub respect_gitignore: bool,
154 pub skip_vendor_dirs: bool,
157}
158
159impl Default for MarkdownWalkOptions {
160 fn default() -> Self {
161 Self {
162 respect_gitignore: true,
163 skip_vendor_dirs: false,
164 }
165 }
166}
167
168pub fn apply_markdown_walk_options(builder: &mut ignore::WalkBuilder, options: &MarkdownWalkOptions) {
175 let gitignore = options.respect_gitignore;
176 builder
177 .ignore(gitignore)
178 .git_ignore(gitignore)
179 .git_global(gitignore)
180 .git_exclude(gitignore)
181 .parents(gitignore)
182 .hidden(false)
183 .require_git(false)
185 .add_custom_ignore_filename(".markdownlintignore");
186
187 if options.skip_vendor_dirs {
188 builder.filter_entry(|entry| {
189 let name = entry.file_name().to_str().unwrap_or("");
190 name != ".git" && name != "node_modules" && name != "target"
191 });
192 }
193}
194
195pub fn markdown_walk_builder(root: &Path, options: &MarkdownWalkOptions) -> ignore::WalkBuilder {
197 let mut builder = ignore::WalkBuilder::new(root);
198 apply_markdown_walk_options(&mut builder, options);
199 builder
200}
201
202fn strip_verbatim_prefix(path: &str) -> Cow<'_, str> {
217 if let Some(rest) = path.strip_prefix(r"\\?\UNC")
220 && rest.starts_with('\\')
221 {
222 return Cow::Owned(format!(r"\{rest}"));
223 }
224 let Some(rest) = path.strip_prefix(r"\\?\") else {
225 return Cow::Borrowed(path);
226 };
227 let is_drive_path = rest.as_bytes().get(1) == Some(&b':');
228 if is_drive_path {
229 Cow::Borrowed(rest)
230 } else {
231 Cow::Borrowed(path)
232 }
233}
234
235pub fn canonicalize_for_matching(path: &Path) -> Option<PathBuf> {
243 let canonical = path.canonicalize().ok()?;
244 if !cfg!(windows) {
245 return Some(canonical);
246 }
247 let as_str = canonical.to_string_lossy();
248 Some(PathBuf::from(strip_verbatim_prefix(&as_str).as_ref()))
249}
250
251fn home_dir() -> Option<PathBuf> {
259 #[cfg(feature = "native")]
260 {
261 use etcetera::{BaseStrategy, choose_base_strategy};
262 choose_base_strategy()
263 .ok()
264 .map(|s| canonicalize_for_matching(s.home_dir()).unwrap_or_else(|| s.home_dir().to_path_buf()))
265 }
266 #[cfg(not(feature = "native"))]
267 {
268 None
269 }
270}
271
272pub fn expand_home_prefix(pattern: &str) -> Cow<'_, str> {
286 if !has_home_prefix(pattern) {
289 return Cow::Borrowed(pattern);
290 }
291 expand_home_prefix_impl(pattern, home_dir().as_deref())
292}
293
294fn has_home_prefix(pattern: &str) -> bool {
296 pattern == "~" || pattern.starts_with("~/")
297}
298
299fn expand_home_prefix_impl<'a>(pattern: &'a str, home: Option<&Path>) -> Cow<'a, str> {
300 let Some(suffix) = (if pattern == "~" {
301 Some("")
302 } else {
303 pattern.strip_prefix("~/")
304 }) else {
305 return Cow::Borrowed(pattern);
306 };
307 let Some(home) = home else {
308 return Cow::Borrowed(pattern);
309 };
310
311 let home = normalize_pattern_separators(home.to_string_lossy());
312 let home = home.trim_end_matches('/');
313 if suffix.is_empty() {
314 Cow::Owned(home.to_string())
315 } else {
316 Cow::Owned(format!("{home}/{suffix}"))
317 }
318}
319
320fn normalize_pattern_separators(path: Cow<'_, str>) -> Cow<'_, str> {
325 if cfg!(windows) && path.contains('\\') {
326 Cow::Owned(path.replace('\\', "/"))
327 } else {
328 path
329 }
330}
331
332pub fn normalize_pattern_for_base(pattern: &str, base: Option<&Path>) -> String {
343 let expanded = expand_home_prefix(pattern);
344 let Some(base) = base else {
345 return expanded.into_owned();
346 };
347 if !is_absolute_pattern(&expanded) {
348 return expanded.into_owned();
349 }
350
351 let path = Path::new(expanded.as_ref());
354 let relative = path.strip_prefix(base).ok().or_else(|| {
355 let canonical = canonicalize_for_matching(base)?;
356 path.strip_prefix(canonical).ok()
357 });
358 match relative {
359 Some(relative) => normalize_pattern_separators(relative.to_string_lossy()).into_owned(),
360 None => expanded.into_owned(),
361 }
362}
363
364pub fn expand_directory_pattern(pattern: &str) -> Vec<String> {
375 let pattern = expand_home_prefix(pattern);
376 let base = pattern.trim_end_matches('/');
377 let final_component = base.rsplit('/').next().unwrap_or(base);
378
379 if final_component.is_empty() || final_component.contains(['*', '?', '[']) {
380 return vec![pattern.to_string()];
381 }
382
383 vec![
384 base.to_string(), format!("{base}/**"), ]
387}
388
389pub struct ExcludeMatchers {
396 matchers: Vec<(String, GlobMatcher)>,
397 has_absolute: bool,
401 pub invalid: Vec<(String, String)>,
404}
405
406pub fn is_absolute_pattern(pattern: &str) -> bool {
410 pattern.starts_with('/') || Path::new(pattern).is_absolute()
411}
412
413impl ExcludeMatchers {
414 pub fn new(patterns: &[String]) -> Self {
415 let mut matchers = Vec::new();
416 let mut invalid = Vec::new();
417 let mut has_absolute = false;
418 for pattern in patterns.iter().flat_map(|p| expand_directory_pattern(p)) {
419 has_absolute |= is_absolute_pattern(&pattern);
420 match Glob::new(&pattern) {
421 Ok(glob) => matchers.push((pattern, glob.compile_matcher())),
422 Err(e) => invalid.push((pattern, e.to_string())),
423 }
424 }
425 Self {
426 matchers,
427 has_absolute,
428 invalid,
429 }
430 }
431
432 pub fn is_empty(&self) -> bool {
433 self.matchers.is_empty()
434 }
435
436 pub fn matched_pattern(&self, relative_path: &str) -> Option<&str> {
438 self.matchers
439 .iter()
440 .find(|(_, matcher)| matcher.is_match(relative_path))
441 .map(|(pattern, _)| pattern.as_str())
442 }
443
444 pub fn is_match(&self, relative_path: &str) -> bool {
445 self.matched_pattern(relative_path).is_some()
446 }
447
448 pub fn matched_pattern_for_file(&self, relative: Option<&str>, absolute: &Path) -> Option<&str> {
465 if let Some(pattern) = relative.and_then(|rel| self.matched_pattern(rel)) {
466 return Some(pattern);
467 }
468 if !self.has_absolute {
469 return None;
470 }
471 let canonical = canonicalize_for_matching(absolute);
472 let absolute = canonical.as_deref().unwrap_or(absolute);
473 self.matched_pattern(&normalize_pattern_separators(absolute.to_string_lossy()))
474 }
475
476 pub fn excludes_file(&self, relative: Option<&str>, absolute: &Path) -> bool {
478 self.matched_pattern_for_file(relative, absolute).is_some()
479 }
480}
481
482pub fn path_relative_to(path: &Path, base: &Path) -> Option<String> {
491 let canonical_base = base.canonicalize().ok()?;
492 let canonical_path = path.canonicalize().ok()?;
493 canonical_path.strip_prefix(&canonical_base).ok().map(|rel| {
494 let rel = rel.to_string_lossy();
495 if cfg!(windows) {
496 rel.replace('\\', "/")
497 } else {
498 rel.to_string()
499 }
500 })
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use std::fs;
507 use tempfile::tempdir;
508
509 #[test]
510 fn markdown_extensions_match_case_insensitively() {
511 for ext in ["md", "MD", "Rmd", "rmd", "MarkDown", "qmd", "mdx"] {
512 assert!(is_markdown_extension(OsStr::new(ext)), "{ext} should match");
513 }
514 for ext in ["rs", "txt", "mdq", ""] {
515 assert!(!is_markdown_extension(OsStr::new(ext)), "{ext} should not match");
516 }
517 assert!(has_markdown_extension(Path::new("a/b/README.md")));
518 assert!(has_markdown_extension(Path::new("notebook.Rmd")));
519 assert!(!has_markdown_extension(Path::new("no_extension")));
520 assert!(!has_markdown_extension(Path::new("lib.rs")));
521 }
522
523 #[test]
524 fn walk_includes_hidden_files() {
525 let temp = tempdir().unwrap();
526 fs::create_dir_all(temp.path().join(".github")).unwrap();
527 fs::write(temp.path().join(".github/PULL_REQUEST_TEMPLATE.md"), "# hi").unwrap();
528 fs::write(temp.path().join("README.md"), "# hi").unwrap();
529
530 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
531 .build()
532 .flatten()
533 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
534 .map(|e| e.path().to_path_buf())
535 .collect();
536 assert!(files.iter().any(|p| p.ends_with(".github/PULL_REQUEST_TEMPLATE.md")));
537 assert!(files.iter().any(|p| p.ends_with("README.md")));
538 }
539
540 #[test]
541 fn walk_honors_gitignore_when_enabled_only() {
542 let temp = tempdir().unwrap();
543 fs::write(temp.path().join(".gitignore"), "ignored.md\n").unwrap();
544 fs::write(temp.path().join("ignored.md"), "# hi").unwrap();
545 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
546
547 let walk = |respect: bool| -> Vec<std::path::PathBuf> {
548 markdown_walk_builder(
549 temp.path(),
550 &MarkdownWalkOptions {
551 respect_gitignore: respect,
552 ..Default::default()
553 },
554 )
555 .build()
556 .flatten()
557 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
558 .map(|e| e.path().to_path_buf())
559 .collect()
560 };
561
562 let respected = walk(true);
563 assert!(!respected.iter().any(|p| p.ends_with("ignored.md")));
564 assert!(respected.iter().any(|p| p.ends_with("kept.md")));
565
566 let unrespected = walk(false);
567 assert!(unrespected.iter().any(|p| p.ends_with("ignored.md")));
568 }
569
570 #[test]
571 fn walk_honors_markdownlintignore() {
572 let temp = tempdir().unwrap();
573 fs::write(temp.path().join(".markdownlintignore"), "legacy.md\n").unwrap();
574 fs::write(temp.path().join("legacy.md"), "# hi").unwrap();
575 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
576
577 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
578 .build()
579 .flatten()
580 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
581 .map(|e| e.path().to_path_buf())
582 .collect();
583 assert!(!files.iter().any(|p| p.ends_with("legacy.md")));
584 assert!(files.iter().any(|p| p.ends_with("kept.md")));
585 }
586
587 #[test]
588 fn vendor_dirs_skipped_only_when_requested() {
589 let temp = tempdir().unwrap();
590 for dir in ["node_modules", "target", "src"] {
591 fs::create_dir_all(temp.path().join(dir)).unwrap();
592 fs::write(temp.path().join(dir).join("doc.md"), "# hi").unwrap();
593 }
594
595 let walk = |skip: bool| -> Vec<std::path::PathBuf> {
596 markdown_walk_builder(
597 temp.path(),
598 &MarkdownWalkOptions {
599 skip_vendor_dirs: skip,
600 respect_gitignore: false,
604 },
605 )
606 .build()
607 .flatten()
608 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
609 .map(|e| e.path().to_path_buf())
610 .collect()
611 };
612
613 let skipped = walk(true);
614 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
615 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("target")));
616 assert!(skipped.iter().any(|p| p.ends_with("src/doc.md")));
617
618 let unskipped = walk(false);
619 assert!(unskipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
620 }
621
622 #[test]
623 fn explicit_file_name_glob_extracts_literal_extensions() {
624 assert_eq!(explicit_file_name_glob("**/*.md.jinja"), Some("*.md.jinja"));
625 assert_eq!(explicit_file_name_glob("*.md.jinja"), Some("*.md.jinja"));
626 assert_eq!(explicit_file_name_glob("docs/*.txt"), Some("*.txt"));
627 assert_eq!(explicit_file_name_glob("templates/NOTES.tmpl"), Some("NOTES.tmpl"));
628 assert_eq!(explicit_file_name_glob("*.md"), Some("*.md"));
629 assert_eq!(explicit_file_name_glob("a/b/c/*.md.tmpl"), Some("*.md.tmpl"));
630 }
631
632 #[test]
633 fn explicit_file_name_glob_rejects_unpinned_patterns() {
634 for pattern in [
635 "docs/",
636 "docs/**",
637 "docs",
638 "*",
639 "**",
640 "**/*",
641 "*.*",
642 "*.md*",
643 "*.{md,jinja}",
644 "*.md?",
645 "data.[ch]",
646 "!drafts/*.md.jinja",
647 "",
648 "**/Makefile",
649 "*.",
650 ] {
651 assert_eq!(explicit_file_name_glob(pattern), None, "{pattern:?} should not qualify");
652 }
653 }
654
655 #[test]
656 fn explicit_include_matchers_match_full_relative_paths() {
657 let matchers = ExplicitIncludeMatchers::new(&[
658 "**/*.md.jinja".to_string(),
659 "docs/**".to_string(),
660 "templates/NOTES.tmpl".to_string(),
661 ]);
662 assert!(!matchers.is_empty());
663 assert!(matchers.matches_relative_path("test.md.jinja"));
664 assert!(matchers.matches_relative_path("a/b/test.md.jinja"));
665 assert!(matchers.matches_relative_path("templates/NOTES.tmpl"));
666 assert!(!matchers.matches_relative_path("docs/anything.txt"));
668 assert!(!matchers.matches_relative_path("test.jinja"));
669 assert!(!matchers.matches_relative_path("docs/NOTES.tmpl"));
672 assert!(!matchers.matches_relative_path("x/templates/NOTES.tmpl"));
673
674 let globs: Vec<_> = matchers.file_name_globs().collect();
675 assert_eq!(globs, vec!["*.md.jinja", "NOTES.tmpl"]);
676 }
677
678 #[test]
679 fn explicit_include_matchers_follow_gitignore_anchoring() {
680 let unanchored = ExplicitIncludeMatchers::new(&["*.md.jinja".to_string()]);
682 assert!(unanchored.matches_relative_path("test.md.jinja"));
683 assert!(unanchored.matches_relative_path("a/b/test.md.jinja"));
684
685 let anchored = ExplicitIncludeMatchers::new(&["docs/*.txt".to_string()]);
687 assert!(anchored.matches_relative_path("docs/a.txt"));
688 assert!(!anchored.matches_relative_path("docs/sub/a.txt"));
689 assert!(!anchored.matches_relative_path("other/docs/a.txt"));
690
691 let rooted = ExplicitIncludeMatchers::new(&["/NOTES.tmpl".to_string()]);
693 assert!(rooted.matches_relative_path("NOTES.tmpl"));
694 assert!(!rooted.matches_relative_path("docs/NOTES.tmpl"));
695 }
696
697 #[test]
698 fn explicit_include_matchers_empty_for_directory_and_wildcard_patterns() {
699 let matchers = ExplicitIncludeMatchers::new(&["docs/".to_string(), "**/*".to_string()]);
700 assert!(matchers.is_empty());
701 assert!(!matchers.matches_relative_path("x.md.jinja"));
702 }
703
704 #[test]
705 fn explicit_include_matchers_skip_invalid_globs() {
706 let matchers = ExplicitIncludeMatchers::new(&["bad[.tmpl".to_string(), "**/*.md.jinja".to_string()]);
709 assert!(matchers.matches_relative_path("ok.md.jinja"));
710 assert_eq!(matchers.file_name_globs().collect::<Vec<_>>(), vec!["*.md.jinja"]);
711 }
712
713 #[test]
714 fn exclude_matchers_expand_directory_patterns() {
715 let matchers = ExcludeMatchers::new(&["drafts".to_string(), "*.tmp.md".to_string()]);
716 assert!(matchers.is_match("drafts"));
717 assert!(
718 matchers.is_match("drafts/inner.md"),
719 "directory pattern must match contents"
720 );
721 assert!(matchers.is_match("note.tmp.md"));
722 assert!(!matchers.is_match("docs/guide.md"));
723 assert_eq!(matchers.matched_pattern("drafts/inner.md"), Some("drafts/**"));
724 assert!(matchers.invalid.is_empty());
725 }
726
727 #[test]
728 fn expand_home_prefix_expands_only_a_leading_tilde() {
729 let home = Path::new("/home/dev");
730 assert_eq!(
731 expand_home_prefix_impl("~/.cursor/plans", Some(home)),
732 "/home/dev/.cursor/plans"
733 );
734 assert_eq!(expand_home_prefix_impl("~", Some(home)), "/home/dev");
735 assert_eq!(expand_home_prefix_impl("~/", Some(home)), "/home/dev");
736 }
737
738 #[test]
739 fn expand_home_prefix_leaves_interior_tildes_alone() {
740 let home = Path::new("/home/dev");
741 for pattern in ["backup.md~", "docs/~drafts/**", "~user/docs", "**/*~", "!~/secret"] {
743 assert_eq!(
744 expand_home_prefix_impl(pattern, Some(home)),
745 pattern,
746 "{pattern:?} must be left as written"
747 );
748 }
749 }
750
751 #[test]
752 fn expand_home_prefix_without_a_home_leaves_the_pattern_as_written() {
753 assert_eq!(expand_home_prefix_impl("~/.cursor/plans", None), "~/.cursor/plans");
754 }
755
756 #[test]
757 fn normalize_pattern_for_base_rewrites_absolute_patterns_under_the_base() {
758 let temp = tempdir().unwrap();
759 let base = canonicalize_for_matching(temp.path()).unwrap();
762 let pattern = format!("{}/docs/**", base.to_string_lossy().replace('\\', "/"));
763 assert_eq!(normalize_pattern_for_base(&pattern, Some(&base)), "docs/**");
764 }
765
766 #[test]
767 fn normalize_pattern_for_base_strips_through_a_non_canonical_base() {
768 let temp = tempdir().unwrap();
771 let canonical = canonicalize_for_matching(temp.path()).unwrap();
772 let pattern = format!("{}/docs/**", canonical.to_string_lossy().replace('\\', "/"));
773 assert_eq!(normalize_pattern_for_base(&pattern, Some(temp.path())), "docs/**");
774 }
775
776 #[test]
777 fn normalize_pattern_for_base_leaves_other_patterns_alone() {
778 let temp = tempdir().unwrap();
779 let base = canonicalize_for_matching(temp.path()).unwrap();
780 assert_eq!(normalize_pattern_for_base("docs/**", Some(&base)), "docs/**");
782 assert_eq!(
785 normalize_pattern_for_base("/somewhere/else/**", Some(&base)),
786 "/somewhere/else/**"
787 );
788 assert_eq!(normalize_pattern_for_base("/abs/docs/**", None), "/abs/docs/**");
790 }
791
792 #[test]
793 fn strip_verbatim_prefix_unwraps_windows_canonical_paths() {
794 assert_eq!(
797 strip_verbatim_prefix(r"\\?\C:\Users\dev\AppData\Local\Temp\x"),
798 r"C:\Users\dev\AppData\Local\Temp\x"
799 );
800 assert_eq!(strip_verbatim_prefix(r"\\?\C:\"), r"C:\");
801 assert_eq!(
803 strip_verbatim_prefix(r"\\?\UNC\server\share\docs"),
804 r"\\server\share\docs"
805 );
806 }
807
808 #[test]
809 fn strip_verbatim_prefix_leaves_other_paths_alone() {
810 for path in [
811 "/home/dev/docs",
812 r"C:\Users\dev",
813 r"\\server\share",
814 r"\\?\Volume{b75e2c83-0000-0000-0000-602f00000000}\docs",
816 r"\\?\",
817 "",
818 ] {
819 assert_eq!(strip_verbatim_prefix(path), path, "{path:?} must be left as written");
820 }
821 }
822
823 #[test]
824 fn expand_directory_pattern_expands_a_literal_final_component() {
825 assert_eq!(
828 expand_directory_pattern("**/.cursor/plans"),
829 vec!["**/.cursor/plans", "**/.cursor/plans/**"]
830 );
831 assert_eq!(
832 expand_directory_pattern("docs/**/drafts"),
833 vec!["docs/**/drafts", "docs/**/drafts/**"]
834 );
835 assert_eq!(
837 expand_directory_pattern("logs/{a,b}"),
838 vec!["logs/{a,b}", "logs/{a,b}/**"]
839 );
840 }
841
842 #[test]
843 fn expand_directory_pattern_leaves_a_wildcard_final_component_alone() {
844 for pattern in ["docs/*", "*.tmp.md", "build/**", "data.[ch]", "notes?"] {
847 assert_eq!(
848 expand_directory_pattern(pattern),
849 vec![pattern.to_string()],
850 "{pattern:?} must not gain a contents-expansion"
851 );
852 }
853 }
854
855 #[test]
856 fn exclude_matchers_match_an_absolute_pattern_against_an_absolute_path() {
857 let matchers = ExcludeMatchers::new(&["/home/dev/.cursor/plans".to_string()]);
858 let excluded = Path::new("/home/dev/.cursor/plans/plan.md");
859 assert!(
860 matchers.excludes_file(None, excluded),
861 "an absolute pattern must match the absolute path when there is no relative form"
862 );
863 assert_eq!(
864 matchers.matched_pattern_for_file(None, excluded),
865 Some("/home/dev/.cursor/plans/**")
866 );
867 assert!(matchers.excludes_file(Some(".cursor/plans/plan.md"), excluded));
870 assert!(!matchers.excludes_file(Some("docs/guide.md"), Path::new("/home/dev/docs/guide.md")));
871 }
872
873 #[test]
874 fn exclude_matchers_do_not_let_relative_patterns_match_absolute_paths() {
875 let matchers = ExcludeMatchers::new(&["drafts".to_string()]);
878 assert!(!matchers.excludes_file(None, Path::new("/home/dev/proj/drafts/note.md")));
879 assert!(matchers.excludes_file(Some("drafts/note.md"), Path::new("/home/dev/proj/drafts/note.md")));
880 }
881
882 #[test]
883 fn exclude_matchers_report_invalid_patterns() {
884 let matchers = ExcludeMatchers::new(&["[".to_string(), "ok.md".to_string()]);
885 assert_eq!(matchers.invalid.len(), 1);
886 assert_eq!(matchers.invalid[0].0, "[");
887 assert!(matchers.is_match("ok.md"));
888 }
889
890 #[test]
891 fn path_relative_to_strips_through_symlinked_base() {
892 let temp = tempdir().unwrap();
893 let base = temp.path().join("base");
894 fs::create_dir_all(base.join("docs")).unwrap();
895 fs::write(base.join("docs/a.md"), "# hi").unwrap();
896
897 assert_eq!(
898 path_relative_to(&base.join("docs/a.md"), &base).as_deref(),
899 Some("docs/a.md")
900 );
901 assert_eq!(
902 path_relative_to(&base.join("docs/a.md"), &base.join("docs")).as_deref(),
903 Some("a.md")
904 );
905 assert_eq!(path_relative_to(temp.path(), &base), None, "path outside base");
906 }
907}