use std::borrow::Cow; use std::path::Path; const MATCH_OPTIONS: glob::MatchOptions = glob::MatchOptions { case_sensitive: true, require_literal_separator: true, require_literal_leading_dot: false, }; /// A validated glob matched against normalized paths relative to a workspace /// root. /// /// Patterns always use `/` as their separator. `*` and `?` stay within one /// path segment; `**` crosses directory boundaries. #[derive(Clone, Debug)] pub struct WorkspaceGlob { pattern: glob::Pattern, traversal_root: String, } impl WorkspaceGlob { pub fn try_new(source: &str) -> Result { let source = strip_current_dir_prefix(source); if source.is_empty() { return Err(WorkspaceGlobError::Empty); } if source.contains('\\') { return Err(WorkspaceGlobError::BackslashSeparator { pattern: source.to_string(), }); } if is_absolute(source) { return Err(WorkspaceGlobError::Absolute { pattern: source.to_string(), }); } if source.split('/').any(|segment| segment == "..") { return Err(WorkspaceGlobError::ParentTraversal { pattern: source.to_string(), }); } let pattern = glob::Pattern::new(source).map_err(|source_error| WorkspaceGlobError::Syntax { pattern: source.to_string(), source: source_error, })?; Ok(Self { pattern, traversal_root: literal_traversal_root(source), }) } #[must_use] pub fn is_match(&self, relative_path: &str) -> bool { let relative_path = normalize_candidate(relative_path); let relative_path = strip_current_dir_prefix(&relative_path); !is_absolute(relative_path) && !relative_path.split('/').any(|segment| segment == "..") && self.pattern.matches_with(relative_path, MATCH_OPTIONS) } /// A literal directory prefix that can reduce traversal work. /// /// This is only an optimization. Callers must still apply /// [`Self::is_match`] to every returned candidate. #[must_use] pub fn traversal_root(&self) -> &str { &self.traversal_root } } /// A compiled union of [`WorkspaceGlob`] patterns. #[derive(Clone, Debug, Default)] pub struct WorkspaceGlobSet { patterns: Vec, } impl WorkspaceGlobSet { pub fn try_new(patterns: I) -> Result where I: IntoIterator, S: AsRef, { let patterns = patterns .into_iter() .map(|pattern| WorkspaceGlob::try_new(pattern.as_ref())) .collect::, _>>()?; Ok(Self { patterns }) } #[must_use] pub fn is_empty(&self) -> bool { self.patterns.is_empty() } #[must_use] pub fn is_match(&self, relative_path: &str) -> bool { self.patterns .iter() .any(|pattern| pattern.is_match(relative_path)) } /// Return the smallest non-overlapping set of literal traversal roots. #[must_use] pub fn traversal_roots(&self) -> Vec<&str> { let mut roots = self .patterns .iter() .map(WorkspaceGlob::traversal_root) .collect::>(); roots.sort_by(|left, right| { segment_count(left) .cmp(&segment_count(right)) .then_with(|| left.cmp(right)) }); roots.dedup(); let mut selected: Vec<&str> = Vec::new(); for root in roots { if !selected .iter() .any(|ancestor| is_same_or_ancestor(ancestor, root)) { selected.push(root); } } selected } } #[derive(Debug, thiserror::Error)] pub enum WorkspaceGlobError { #[error("workspace glob cannot be empty")] Empty, #[error("workspace glob must use '/' as its path separator: {pattern:?}")] BackslashSeparator { pattern: String }, #[error("workspace glob must be relative: {pattern:?}")] Absolute { pattern: String }, #[error("workspace glob cannot traverse to a parent directory: {pattern:?}")] ParentTraversal { pattern: String }, #[error("invalid workspace glob {pattern:?}: {source}")] Syntax { pattern: String, #[source] source: glob::PatternError, }, } fn strip_current_dir_prefix(mut path: &str) -> &str { while let Some(stripped) = path.strip_prefix("./") { path = stripped; } path } fn is_absolute(path: &str) -> bool { let bytes = path.as_bytes(); path.starts_with('/') || Path::new(path).is_absolute() || matches!(bytes, [drive, b':', ..] if drive.is_ascii_alphabetic()) } fn literal_traversal_root(pattern: &str) -> String { let mut literal_segments = Vec::new(); let mut saw_meta = false; for segment in pattern.split('/').filter(|segment| !segment.is_empty()) { if has_glob_meta(segment) { saw_meta = true; break; } literal_segments.push(segment); } if !saw_meta { literal_segments.pop(); } literal_segments.join("/") } fn has_glob_meta(segment: &str) -> bool { segment .chars() .any(|character| matches!(character, '*' | '?' | '[')) } fn segment_count(path: &str) -> usize { path.split('/') .filter(|segment| !segment.is_empty()) .count() } fn is_same_or_ancestor(ancestor: &str, candidate: &str) -> bool { ancestor.is_empty() || ancestor == candidate || candidate .strip_prefix(ancestor) .is_some_and(|suffix| suffix.starts_with('/')) } fn normalize_candidate(path: &str) -> Cow<'_, str> { #[cfg(windows)] { Cow::Owned(path.replace('\\', "/")) } #[cfg(not(windows))] { Cow::Borrowed(path) } } #[cfg(test)] mod tests { use super::{WorkspaceGlob, WorkspaceGlobError, WorkspaceGlobSet}; #[test] fn workspace_glob_has_root_relative_segment_semantics() { let cases = [ ("*.md", "README.md", true), ("*.md", "docs/README.md", false), ("**/*.md", "README.md", true), ("**/*.md", "docs/README.md", true), (".ai/reports/*.md", ".ai/reports/result.md", true), (".ai/reports/*.md", ".ai/reports/nested/result.md", false), (".ai/reports/**/*.md", ".ai/reports/nested/result.md", true), ( ".ai/plans/????-??-??-*.md", ".ai/plans/2026-07-25-globbing.md", true, ), (".ai/plans/????-??-??-*.md", ".ai/plans/DRAFTING.md", false), ("*/SKILL.md", "rust/SKILL.md", true), ("*/SKILL.md", "rust/review/SKILL.md", false), ("src/[lm]ib.rs", "src/lib.rs", true), ("src/[!m]ib.rs", "src/lib.rs", true), ("**/.env", ".env", true), ("**/.env", "nested/.env", true), ]; for (pattern, candidate, expected) in cases { let glob = WorkspaceGlob::try_new(pattern).unwrap(); assert_eq!( glob.is_match(candidate), expected, "pattern {pattern:?}, candidate {candidate:?}" ); } } #[test] fn workspace_glob_normalizes_a_leading_current_directory() { let glob = WorkspaceGlob::try_new("./src/*.rs").unwrap(); assert!(glob.is_match("./src/lib.rs")); assert_eq!(glob.traversal_root(), "src"); } #[test] fn workspace_glob_rejects_paths_outside_the_root() { assert!(matches!( WorkspaceGlob::try_new(""), Err(WorkspaceGlobError::Empty) )); assert!(matches!( WorkspaceGlob::try_new("/tmp/*.md"), Err(WorkspaceGlobError::Absolute { .. }) )); assert!(matches!( WorkspaceGlob::try_new("C:/tmp/*.md"), Err(WorkspaceGlobError::Absolute { .. }) )); assert!(matches!( WorkspaceGlob::try_new(r"dir\*.rs"), Err(WorkspaceGlobError::BackslashSeparator { .. }) )); assert!(matches!( WorkspaceGlob::try_new(r"\\server\share\*.md"), Err(WorkspaceGlobError::BackslashSeparator { .. }) )); assert!(matches!( WorkspaceGlob::try_new("../*.md"), Err(WorkspaceGlobError::ParentTraversal { .. }) )); assert!(matches!( WorkspaceGlob::try_new("src/[abc"), Err(WorkspaceGlobError::Syntax { .. }) )); assert!(matches!( WorkspaceGlob::try_new("src**/*.rs"), Err(WorkspaceGlobError::Syntax { .. }) )); } #[test] fn workspace_glob_set_minimizes_traversal_roots() { let globs = WorkspaceGlobSet::try_new([ ".ai/reports/*.md", ".ai/reports/nested/*.json", ".ai/plans/*.md", ".other/targeted.txt", ]) .unwrap(); assert_eq!(globs.traversal_roots(), vec![ ".other", ".ai/plans", ".ai/reports" ]); } #[test] fn recursive_pattern_supersedes_narrower_traversal_roots() { let globs = WorkspaceGlobSet::try_new(["**/*.md", ".ai/reports/*.json"]).unwrap(); assert_eq!(globs.traversal_roots(), vec![""]); assert!(globs.is_match(".ai/reports/result.md")); assert!(globs.is_match(".ai/reports/result.json")); assert!(!globs.is_match(".ai/reports/result.txt")); } }