fabro/lib/foundation/fabro-util/src/workspace_glob.rs
2026-07-25 11:56:57 -04:00

326 lines
9.6 KiB
Rust

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<Self, WorkspaceGlobError> {
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<WorkspaceGlob>,
}
impl WorkspaceGlobSet {
pub fn try_new<I, S>(patterns: I) -> Result<Self, WorkspaceGlobError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let patterns = patterns
.into_iter()
.map(|pattern| WorkspaceGlob::try_new(pattern.as_ref()))
.collect::<Result<Vec<_>, _>>()?;
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::<Vec<_>>();
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"));
}
}