agpm_cli/pattern.rs
1//! Pattern-based dependency resolution for AGPM.
2//!
3//! This module provides glob pattern matching functionality to support
4//! pattern-based dependencies in AGPM manifests. Pattern dependencies
5//! allow installation of multiple resources matching a glob pattern,
6//! enabling bulk operations on related resources.
7//!
8//! # Pattern Syntax
9//!
10//! AGPM uses standard glob patterns with the following support:
11//!
12//! - `*` matches any sequence of characters within a single path component
13//! - `**` matches any sequence of path components (recursive matching)
14//! - `?` matches any single character
15//! - `[abc]` matches any character in the set
16//! - `[a-z]` matches any character in the range
17//! - `{foo,bar}` matches either "foo" or "bar" (brace expansion)
18//!
19//! # Examples
20//!
21//! ## Common Pattern Usage
22//!
23//! ```toml
24//! # Install all agents in the agents/ directory
25//! [agents]
26//! ai-helpers = { source = "community", path = "agents/*.md", version = "v1.0.0" }
27//!
28//! # Install all review-related agents recursively
29//! review-tools = { source = "community", path = "**/review*.md", version = "v1.0.0" }
30//!
31//! # Install specific agent categories
32//! python-agents = { source = "community", path = "agents/python-*.md", version = "v1.0.0" }
33//! ```
34//!
35//! ## Security Considerations
36//!
37//! Pattern matching includes several security measures:
38//!
39//! - **Path Traversal Prevention**: Patterns containing `..` are rejected
40//! - **Absolute Path Restriction**: Patterns starting with `/` or containing drive letters are rejected
41//! - **Symlink Safety**: Pattern matching does not follow symlinks to prevent directory traversal
42//! - **Input Validation**: All patterns are validated before processing
43//!
44//! # Performance
45//!
46//! Pattern matching is optimized for typical repository structures:
47//!
48//! - **Recursive Traversal**: Uses `walkdir` for efficient directory traversal
49//! - **Pattern Caching**: Compiled glob patterns are reused across matches
50//! - **Early Termination**: Stops on first match when appropriate
51//! - **Memory Efficient**: Streaming approach for large directory trees
52
53use anyhow::{Context, Result};
54use glob::Pattern;
55use std::collections::HashSet;
56use std::path::{Path, PathBuf};
57use tracing::{debug, trace};
58use walkdir::WalkDir;
59
60/// Pattern matcher for resource discovery in repositories.
61///
62/// The `PatternMatcher` provides glob pattern matching capabilities for
63/// discovering resources in Git repositories and local directories. It supports
64/// standard glob patterns and handles cross-platform path matching.
65///
66/// # Thread Safety
67///
68/// `PatternMatcher` is thread-safe and can be cloned for use in concurrent contexts.
69///
70/// # Examples
71///
72/// ```rust,no_run
73/// use agpm_cli::pattern::PatternMatcher;
74/// use std::path::Path;
75///
76/// # fn example() -> anyhow::Result<()> {
77/// let matcher = PatternMatcher::new("agents/*.md")?;
78///
79/// // Check if a path matches
80/// assert!(matcher.matches(Path::new("agents/helper.md")));
81/// assert!(!matcher.matches(Path::new("snippets/code.md")));
82///
83/// // Find all matches in a directory
84/// let matches = matcher.find_matches(Path::new("/path/to/repo"))?;
85/// println!("Found {} matching files", matches.len());
86/// # Ok(())
87/// # }
88/// ```
89#[derive(Debug, Clone)]
90pub struct PatternMatcher {
91 pattern: Pattern,
92 original_pattern: String,
93}
94
95impl PatternMatcher {
96 /// Creates a new pattern matcher from a glob pattern string.
97 ///
98 /// The pattern is compiled once during creation for efficient matching.
99 /// Invalid glob patterns will return an error.
100 ///
101 /// # Arguments
102 ///
103 /// * `pattern_str` - A glob pattern string (e.g., "*.md", "**/*.py")
104 ///
105 /// # Returns
106 ///
107 /// A new `PatternMatcher` instance ready for matching operations.
108 ///
109 /// # Errors
110 ///
111 /// Returns an error if:
112 /// - The pattern contains invalid glob syntax
113 /// - The pattern is malformed or contains unsupported features
114 ///
115 /// # Examples
116 ///
117 /// ```rust,no_run
118 /// use agpm_cli::pattern::PatternMatcher;
119 ///
120 /// // Simple wildcard
121 /// let matcher = PatternMatcher::new("*.md")?;
122 ///
123 /// // Recursive matching
124 /// let matcher = PatternMatcher::new("**/docs/*.md")?;
125 ///
126 /// // Character classes
127 /// let matcher = PatternMatcher::new("agent[0-9].md")?;
128 /// # Ok::<(), anyhow::Error>(())
129 /// ```
130 pub fn new(pattern_str: &str) -> Result<Self> {
131 let pattern = Pattern::new(pattern_str)
132 .with_context(|| format!("Invalid glob pattern: {pattern_str}"))?;
133
134 Ok(Self {
135 pattern,
136 original_pattern: pattern_str.to_string(),
137 })
138 }
139
140 /// Finds all files matching the pattern in the specified directory.
141 ///
142 /// This method recursively traverses the directory tree and returns all
143 /// files that match the compiled pattern. The search is performed relative
144 /// to the base path, ensuring portable pattern matching across platforms.
145 ///
146 /// # Security
147 ///
148 /// This method includes security measures:
149 /// - Does not follow symlinks to prevent directory traversal attacks
150 /// - Returns relative paths to prevent information disclosure
151 /// - Handles permission errors gracefully
152 ///
153 /// # Arguments
154 ///
155 /// * `base_path` - The directory to search in (must exist)
156 ///
157 /// # Returns
158 ///
159 /// A vector of relative paths (from `base_path`) that match the pattern.
160 /// Paths are returned as `PathBuf` for easy manipulation.
161 ///
162 /// # Errors
163 ///
164 /// Returns an error if:
165 /// - The base path does not exist or cannot be accessed
166 /// - The base path cannot be canonicalized
167 /// - Permission errors occur during directory traversal
168 /// - I/O errors prevent directory reading
169 ///
170 /// # Examples
171 ///
172 /// ```rust,no_run
173 /// use agpm_cli::pattern::PatternMatcher;
174 /// use std::path::Path;
175 ///
176 /// # async fn example() -> anyhow::Result<()> {
177 /// let matcher = PatternMatcher::new("**/*.md")?;
178 /// let matches = matcher.find_matches(Path::new("/repo"))?;
179 ///
180 /// for path in matches {
181 /// println!("Found: {}", path.display());
182 /// }
183 /// # Ok(())
184 /// # }
185 /// ```
186 pub fn find_matches(&self, base_path: &Path) -> Result<Vec<PathBuf>> {
187 debug!("Searching for pattern '{}' in {:?}", self.original_pattern, base_path);
188
189 let mut matches = Vec::new();
190 let base_path = base_path
191 .canonicalize()
192 .with_context(|| format!("Failed to canonicalize path: {base_path:?}"))?;
193
194 for entry in WalkDir::new(&base_path)
195 .follow_links(false) // Security: don't follow symlinks
196 .into_iter()
197 .filter_map(std::result::Result::ok)
198 {
199 let path = entry.path();
200
201 // Get relative path for pattern matching
202 if let Ok(relative_path) = path.strip_prefix(&base_path) {
203 let relative_str = relative_path.to_string_lossy();
204
205 trace!("Checking path: {}", relative_str);
206
207 if self.pattern.matches(&relative_str) {
208 debug!("Found match: {}", relative_str);
209 matches.push(relative_path.to_path_buf());
210 }
211 }
212 }
213
214 debug!("Found {} matches for pattern '{}'", matches.len(), self.original_pattern);
215 Ok(matches)
216 }
217
218 /// Checks if a single path matches the compiled pattern.
219 ///
220 /// This is a lightweight operation that checks if the given path
221 /// matches the pattern without filesystem access. Useful for filtering
222 /// or validation operations.
223 ///
224 /// # Arguments
225 ///
226 /// * `path` - The path to test against the pattern
227 ///
228 /// # Returns
229 ///
230 /// `true` if the path matches the pattern, `false` otherwise.
231 ///
232 /// # Examples
233 ///
234 /// ```rust,no_run
235 /// use agpm_cli::pattern::PatternMatcher;
236 /// use std::path::Path;
237 ///
238 /// # fn example() -> anyhow::Result<()> {
239 /// let matcher = PatternMatcher::new("agents/*.md")?;
240 ///
241 /// assert!(matcher.matches(Path::new("agents/helper.md")));
242 /// assert!(matcher.matches(Path::new("agents/reviewer.md")));
243 /// assert!(!matcher.matches(Path::new("snippets/code.md")));
244 /// assert!(!matcher.matches(Path::new("agents/nested/deep.md")));
245 /// # Ok(())
246 /// # }
247 /// ```
248 pub fn matches(&self, path: &Path) -> bool {
249 let path_str = path.to_string_lossy();
250 self.pattern.matches(&path_str)
251 }
252
253 /// Returns the original pattern string used to create this matcher.
254 ///
255 /// Useful for logging, debugging, or displaying the pattern to users.
256 ///
257 /// # Returns
258 ///
259 /// The original pattern string as provided to [`PatternMatcher::new`].
260 ///
261 /// # Examples
262 ///
263 /// ```rust,no_run
264 /// use agpm_cli::pattern::PatternMatcher;
265 ///
266 /// # fn example() -> anyhow::Result<()> {
267 /// let pattern_str = "**/*.md";
268 /// let matcher = PatternMatcher::new(pattern_str)?;
269 ///
270 /// assert_eq!(matcher.pattern(), pattern_str);
271 /// # Ok(())
272 /// # }
273 /// ```
274 pub fn pattern(&self) -> &str {
275 &self.original_pattern
276 }
277}
278
279/// Resolves pattern-based dependencies to concrete file paths.
280///
281/// The `PatternResolver` provides advanced pattern matching with exclusion
282/// support and deterministic ordering. It's designed for resolving
283/// pattern-based dependencies in AGPM manifests to concrete resource files.
284///
285/// # Features
286///
287/// - **Exclusion Patterns**: Support for excluding specific patterns from results
288/// - **Deterministic Ordering**: Results are always returned in sorted order
289/// - **Deduplication**: Automatically removes duplicate paths from results
290/// - **Multiple Pattern Support**: Can resolve multiple patterns in one operation
291///
292/// # Examples
293///
294/// ```rust,no_run
295/// use agpm_cli::pattern::PatternResolver;
296/// use std::path::Path;
297///
298/// # fn example() -> anyhow::Result<()> {
299/// let mut resolver = PatternResolver::new();
300///
301/// // Add exclusion patterns
302/// resolver.exclude("**/test_*.md")?;
303/// resolver.exclude("**/.*")?; // Exclude hidden files
304///
305/// // Resolve pattern with exclusions applied
306/// let matches = resolver.resolve("**/*.md", Path::new("/repo"))?;
307/// println!("Found {} files (excluding test files and hidden files)", matches.len());
308/// # Ok(())
309/// # }
310/// ```
311pub struct PatternResolver {
312 /// Patterns to exclude from matching
313 exclude_patterns: Vec<Pattern>,
314}
315
316impl PatternResolver {
317 /// Creates a new pattern resolver with no exclusions.
318 ///
319 /// The resolver starts with an empty exclusion list. Use [`exclude`]
320 /// to add patterns that should be filtered out of results.
321 ///
322 /// # Examples
323 ///
324 /// ```rust,no_run
325 /// use agpm_cli::pattern::PatternResolver;
326 ///
327 /// let resolver = PatternResolver::new();
328 /// // PatternResolver starts with no exclusions
329 /// ```
330 ///
331 /// [`exclude`]: PatternResolver::exclude
332 pub const fn new() -> Self {
333 Self {
334 exclude_patterns: Vec::new(),
335 }
336 }
337
338 /// Adds an exclusion pattern to filter out unwanted results.
339 ///
340 /// Files matching exclusion patterns will be removed from resolution
341 /// results. Exclusions are applied after the main pattern matching,
342 /// making them useful for filtering out test files, hidden files,
343 /// or other unwanted resources.
344 ///
345 /// # Arguments
346 ///
347 /// * `pattern` - A glob pattern for files to exclude
348 ///
349 /// # Errors
350 ///
351 /// Returns an error if the exclusion pattern is invalid glob syntax.
352 ///
353 /// # Examples
354 ///
355 /// ```rust,no_run
356 /// use agpm_cli::pattern::PatternResolver;
357 ///
358 /// # fn example() -> anyhow::Result<()> {
359 /// let mut resolver = PatternResolver::new();
360 ///
361 /// // Exclude test files
362 /// resolver.exclude("**/test_*.md")?;
363 /// resolver.exclude("**/*_test.md")?;
364 ///
365 /// // Exclude hidden files
366 /// resolver.exclude("**/.*")?;
367 ///
368 /// // Exclude backup files
369 /// resolver.exclude("**/*.bak")?;
370 /// resolver.exclude("**/*~")?;
371 /// # Ok(())
372 /// # }
373 /// ```
374 pub fn exclude(&mut self, pattern: &str) -> Result<()> {
375 let pattern = Pattern::new(pattern)
376 .with_context(|| format!("Invalid exclusion pattern: {pattern}"))?;
377 self.exclude_patterns.push(pattern);
378 Ok(())
379 }
380
381 /// Resolves a pattern to a list of resource paths with exclusions applied.
382 ///
383 /// This is the primary method for pattern resolution. It finds all files
384 /// matching the pattern, applies exclusion filters, removes duplicates,
385 /// and returns results in deterministic sorted order.
386 ///
387 /// # Algorithm
388 ///
389 /// 1. Use `PatternMatcher` to find all files matching the pattern
390 /// 2. Filter out any files matching exclusion patterns
391 /// 3. Remove duplicates (though unlikely with file paths)
392 /// 4. Sort results for deterministic ordering
393 ///
394 /// # Arguments
395 ///
396 /// * `pattern` - The glob pattern to match files against
397 /// * `base_path` - The directory to search within
398 ///
399 /// # Returns
400 ///
401 /// A vector of `PathBuf` objects representing matching files,
402 /// sorted in lexicographic order for deterministic results.
403 ///
404 /// # Errors
405 ///
406 /// Returns an error if:
407 /// - The pattern is invalid glob syntax
408 /// - The base path doesn't exist or can't be accessed
409 /// - I/O errors occur during directory traversal
410 ///
411 /// # Examples
412 ///
413 /// ```rust,no_run
414 /// use agpm_cli::pattern::PatternResolver;
415 /// use std::path::Path;
416 ///
417 /// # fn example() -> anyhow::Result<()> {
418 /// let mut resolver = PatternResolver::new();
419 /// resolver.exclude("**/test_*.md")?;
420 ///
421 /// let matches = resolver.resolve("agents/*.md", Path::new("/repo"))?;
422 /// for path in &matches {
423 /// println!("Agent: {}", path.display());
424 /// }
425 /// # Ok(())
426 /// # }
427 /// ```
428 pub fn resolve(&self, pattern: &str, base_path: &Path) -> Result<Vec<PathBuf>> {
429 let matcher = PatternMatcher::new(pattern)?;
430 let mut matches = matcher.find_matches(base_path)?;
431
432 // Apply exclusions
433 if !self.exclude_patterns.is_empty() {
434 matches.retain(|path| {
435 let path_str = path.to_string_lossy();
436 !self.exclude_patterns.iter().any(|exclude| exclude.matches(&path_str))
437 });
438 }
439
440 // Sort for deterministic ordering
441 matches.sort();
442
443 Ok(matches)
444 }
445
446 /// Resolves multiple patterns and returns unique results.
447 ///
448 /// This method combines results from multiple pattern resolutions,
449 /// automatically deduplicating any files that match multiple patterns.
450 /// Useful for installing resources from multiple pattern-based dependencies.
451 ///
452 /// # Arguments
453 ///
454 /// * `patterns` - A slice of pattern strings to resolve
455 /// * `base_path` - The directory to search within
456 ///
457 /// # Returns
458 ///
459 /// A vector of unique `PathBuf` objects representing all files that
460 /// match any of the provided patterns, sorted for deterministic results.
461 ///
462 /// # Errors
463 ///
464 /// Returns an error if any pattern is invalid or if directory access fails.
465 ///
466 /// # Examples
467 ///
468 /// ```rust,no_run
469 /// use agpm_cli::pattern::PatternResolver;
470 /// use std::path::Path;
471 ///
472 /// # fn example() -> anyhow::Result<()> {
473 /// let resolver = PatternResolver::new();
474 /// let patterns = vec![
475 /// "agents/*.md".to_string(),
476 /// "helpers/*.md".to_string(),
477 /// "tools/*.md".to_string(),
478 /// ];
479 ///
480 /// let matches = resolver.resolve_multiple(&patterns, Path::new("/repo"))?;
481 /// println!("Found {} unique resources", matches.len());
482 /// # Ok(())
483 /// # }
484 /// ```
485 pub fn resolve_multiple(&self, patterns: &[String], base_path: &Path) -> Result<Vec<PathBuf>> {
486 let mut all_matches = HashSet::new();
487
488 for pattern in patterns {
489 let matches = self.resolve(pattern, base_path)?;
490 all_matches.extend(matches);
491 }
492
493 let mut result: Vec<_> = all_matches.into_iter().collect();
494 result.sort();
495
496 Ok(result)
497 }
498}
499
500impl Default for PatternResolver {
501 fn default() -> Self {
502 Self::new()
503 }
504}
505
506/// Extracts a resource name from a file path.
507///
508/// This function determines an appropriate resource name by extracting
509/// the file stem (filename without extension) from the path. This is
510/// used when generating resource names for pattern-based dependencies.
511///
512/// # Arguments
513///
514/// * `path` - The file path to extract a name from
515///
516/// # Returns
517///
518/// The file stem as a string, or "unknown" if the path has no filename.
519///
520/// # Examples
521///
522/// ```rust,no_run
523/// use agpm_cli::pattern::extract_resource_name;
524/// use std::path::Path;
525///
526/// assert_eq!(extract_resource_name(Path::new("agents/helper.md")), "helper");
527/// assert_eq!(extract_resource_name(Path::new("/path/to/script.py")), "script");
528/// assert_eq!(extract_resource_name(Path::new("no-extension")), "no-extension");
529/// assert_eq!(extract_resource_name(Path::new("/")), "unknown");
530/// ```
531pub fn extract_resource_name(path: &Path) -> String {
532 path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown").to_string()
533}
534
535/// Validates that a pattern is safe and doesn't contain path traversal attempts.
536///
537/// This security function prevents malicious patterns that could access
538/// files outside the intended directory boundaries. It checks for common
539/// path traversal patterns and absolute paths that could escape the
540/// repository or project directory.
541///
542/// # Security Checks
543///
544/// - **Path Traversal**: Rejects patterns containing `..` components
545/// - **Absolute Paths (Unix)**: Rejects patterns starting with `/`
546/// - **Absolute Paths (Windows)**: Rejects patterns containing `:` or starting with `\`
547///
548/// # Arguments
549///
550/// * `pattern` - The glob pattern to validate
551///
552/// # Returns
553///
554/// `Ok(())` if the pattern is safe to use.
555///
556/// # Errors
557///
558/// Returns an error if the pattern contains dangerous components:
559/// - Path traversal attempts (`../`, `../../`, etc.)
560/// - Absolute paths (`/etc/passwd`, `C:\Windows\`, etc.)
561/// - UNC paths on Windows (`\\server\share`)
562///
563/// # Examples
564///
565/// ```rust,no_run
566/// use agpm_cli::pattern::validate_pattern_safety;
567///
568/// // Safe patterns
569/// assert!(validate_pattern_safety("*.md").is_ok());
570/// assert!(validate_pattern_safety("agents/*.md").is_ok());
571/// assert!(validate_pattern_safety("**/*.md").is_ok());
572///
573/// // Unsafe patterns
574/// assert!(validate_pattern_safety("../etc/passwd").is_err());
575/// # #[cfg(unix)]
576/// # assert!(validate_pattern_safety("/etc/*").is_err());
577/// # #[cfg(windows)]
578/// # assert!(validate_pattern_safety("C:\\Windows\\*").is_err());
579/// ```
580pub fn validate_pattern_safety(pattern: &str) -> Result<()> {
581 // Check for path traversal attempts
582 if pattern.contains("..") {
583 anyhow::bail!("Pattern contains path traversal (..): {pattern}");
584 }
585
586 // Check for absolute paths on Unix
587 if cfg!(unix) && pattern.starts_with('/') {
588 anyhow::bail!("Pattern contains absolute path: {pattern}");
589 }
590
591 // Check for absolute paths on Windows
592 if cfg!(windows) && (pattern.contains(':') || pattern.starts_with('\\')) {
593 anyhow::bail!("Pattern contains absolute path: {pattern}");
594 }
595
596 Ok(())
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602 use std::fs;
603 use tempfile::TempDir;
604
605 #[test]
606 fn test_pattern_matcher_creation_and_basic_matching() {
607 let pattern = PatternMatcher::new("*.md").unwrap();
608
609 assert!(pattern.matches(Path::new("test.md")));
610 assert!(pattern.matches(Path::new("README.md")));
611 assert!(!pattern.matches(Path::new("test.txt")));
612 assert!(!pattern.matches(Path::new("test.md.backup")));
613 }
614
615 #[test]
616 fn test_pattern_matcher_directory_patterns() {
617 let pattern = PatternMatcher::new("agents/*.md").unwrap();
618
619 assert!(pattern.matches(Path::new("agents/test.md")));
620 assert!(pattern.matches(Path::new("agents/helper.md")));
621 assert!(!pattern.matches(Path::new("snippets/test.md")));
622 // Note: glob patterns like "agents/*.md" will match nested paths in some implementations
623 // For strict single-level matching, the pattern would need to be more specific
624 }
625
626 #[test]
627 fn test_pattern_matcher_recursive_globstar() {
628 let pattern = PatternMatcher::new("**/*.md").unwrap();
629
630 assert!(pattern.matches(Path::new("test.md")));
631 assert!(pattern.matches(Path::new("agents/test.md")));
632 assert!(pattern.matches(Path::new("agents/subdir/test.md")));
633 assert!(!pattern.matches(Path::new("test.txt")));
634 }
635
636 #[test]
637 fn test_find_matches_in_directory_structure() {
638 let temp_dir = TempDir::new().unwrap();
639 let base_path = temp_dir.path();
640
641 // Create test file structure
642 fs::create_dir_all(base_path.join("agents")).unwrap();
643 fs::create_dir_all(base_path.join("snippets")).unwrap();
644 fs::create_dir_all(base_path.join("agents/subdir")).unwrap();
645
646 fs::write(base_path.join("README.md"), "").unwrap();
647 fs::write(base_path.join("agents/helper.md"), "").unwrap();
648 fs::write(base_path.join("agents/assistant.md"), "").unwrap();
649 fs::write(base_path.join("agents/subdir/nested.md"), "").unwrap();
650 fs::write(base_path.join("snippets/code.md"), "").unwrap();
651 fs::write(base_path.join("config.toml"), "").unwrap();
652
653 // Test recursive pattern
654 let pattern = PatternMatcher::new("**/*.md").unwrap();
655 let matches = pattern.find_matches(base_path).unwrap();
656 assert_eq!(matches.len(), 5); // All .md files in the tree
657
658 // Test directory pattern - matches files in agents directory
659 let pattern = PatternMatcher::new("agents/*.md").unwrap();
660 let matches = pattern.find_matches(base_path).unwrap();
661 // The glob pattern "agents/*.md" should match agents/helper.md, agents/assistant.md
662 // and potentially agents/subdir/nested.md depending on glob implementation
663 assert!(matches.len() >= 2);
664 assert!(matches.contains(&PathBuf::from("agents/helper.md")));
665 assert!(matches.contains(&PathBuf::from("agents/assistant.md")));
666
667 // Test recursive pattern
668 let pattern = PatternMatcher::new("**/*.md").unwrap();
669 let matches = pattern.find_matches(base_path).unwrap();
670 assert_eq!(matches.len(), 5);
671 assert!(matches.contains(&PathBuf::from("README.md")));
672 assert!(matches.contains(&PathBuf::from("agents/helper.md")));
673 assert!(matches.contains(&PathBuf::from("agents/assistant.md")));
674 assert!(matches.contains(&PathBuf::from("agents/subdir/nested.md")));
675 assert!(matches.contains(&PathBuf::from("snippets/code.md")));
676 }
677
678 #[test]
679 fn test_pattern_resolver_with_exclusion_filters() {
680 let temp_dir = TempDir::new().unwrap();
681 let base_path = temp_dir.path();
682
683 // Create test files
684 fs::create_dir_all(base_path.join("agents")).unwrap();
685 fs::write(base_path.join("agents/helper.md"), "").unwrap();
686 fs::write(base_path.join("agents/test.md"), "").unwrap();
687 fs::write(base_path.join("agents/example.md"), "").unwrap();
688
689 let mut resolver = PatternResolver::new();
690 resolver.exclude("*/test.md").unwrap();
691 resolver.exclude("*/example.md").unwrap();
692
693 let matches = resolver.resolve("agents/*.md", base_path).unwrap();
694 assert_eq!(matches.len(), 1);
695 assert!(matches.contains(&PathBuf::from("agents/helper.md")));
696 }
697
698 #[test]
699 fn test_resolve_multiple_patterns_with_deduplication() {
700 let temp_dir = TempDir::new().unwrap();
701 let base_path = temp_dir.path();
702
703 // Create test files
704 fs::create_dir_all(base_path.join("agents")).unwrap();
705 fs::create_dir_all(base_path.join("snippets")).unwrap();
706 fs::write(base_path.join("agents/helper.md"), "").unwrap();
707 fs::write(base_path.join("snippets/code.md"), "").unwrap();
708 fs::write(base_path.join("README.md"), "").unwrap();
709
710 let resolver = PatternResolver::new();
711 let patterns =
712 vec!["agents/*.md".to_string(), "snippets/*.md".to_string(), "*.md".to_string()];
713
714 let matches = resolver.resolve_multiple(&patterns, base_path).unwrap();
715 assert_eq!(matches.len(), 3);
716 assert!(matches.contains(&PathBuf::from("agents/helper.md")));
717 assert!(matches.contains(&PathBuf::from("snippets/code.md")));
718 assert!(matches.contains(&PathBuf::from("README.md")));
719 }
720
721 #[test]
722 fn test_extract_resource_name_from_paths() {
723 assert_eq!(extract_resource_name(Path::new("agents/helper.md")), "helper");
724 assert_eq!(extract_resource_name(Path::new("test.md")), "test");
725 assert_eq!(extract_resource_name(Path::new("path/to/resource.txt")), "resource");
726 assert_eq!(extract_resource_name(Path::new("noextension")), "noextension");
727 }
728
729 #[test]
730 fn test_validate_pattern_security_checks() {
731 // Valid patterns
732 assert!(validate_pattern_safety("*.md").is_ok());
733 assert!(validate_pattern_safety("agents/*.md").is_ok());
734 assert!(validate_pattern_safety("**/*.md").is_ok());
735
736 // Invalid patterns - path traversal
737 assert!(validate_pattern_safety("../parent/*.md").is_err());
738 assert!(validate_pattern_safety("agents/../*.md").is_err());
739 assert!(validate_pattern_safety("../../etc/passwd").is_err());
740
741 // Invalid patterns - absolute paths
742 if cfg!(unix) {
743 assert!(validate_pattern_safety("/etc/*.conf").is_err());
744 assert!(validate_pattern_safety("/home/user/*.md").is_err());
745 }
746
747 if cfg!(windows) {
748 assert!(validate_pattern_safety("C:\\Windows\\*.dll").is_err());
749 assert!(validate_pattern_safety("\\\\server\\share\\*.md").is_err());
750 }
751 }
752
753 #[test]
754 fn test_pattern_with_alternatives() {
755 let pattern = PatternMatcher::new("agents/{helper,assistant}.md").unwrap();
756
757 // Note: glob crate doesn't support {a,b} syntax directly
758 // This test documents current behavior
759 assert!(!pattern.matches(Path::new("agents/helper.md")));
760 assert!(!pattern.matches(Path::new("agents/assistant.md")));
761 assert!(pattern.matches(Path::new("agents/{helper,assistant}.md")));
762 }
763
764 #[test]
765 fn test_pattern_case_sensitivity() {
766 let pattern = PatternMatcher::new("*.MD").unwrap();
767
768 // Pattern matching is case-sensitive on Unix, case-insensitive on Windows
769 if cfg!(unix) {
770 assert!(!pattern.matches(Path::new("test.md")));
771 assert!(pattern.matches(Path::new("test.MD")));
772 }
773 }
774
775 #[test]
776 fn test_complex_patterns() {
777 // Test character class
778 let pattern = PatternMatcher::new("agent[0-9].md").unwrap();
779 assert!(pattern.matches(Path::new("agent1.md")));
780 assert!(pattern.matches(Path::new("agent5.md")));
781 assert!(!pattern.matches(Path::new("agenta.md")));
782 assert!(!pattern.matches(Path::new("agent10.md")));
783
784 // Test negation
785 let pattern = PatternMatcher::new("!test*.md").unwrap();
786 // Note: glob crate doesn't support negation directly
787 assert!(pattern.matches(Path::new("!test*.md")));
788
789 // Test question mark wildcard
790 let pattern = PatternMatcher::new("agent?.md").unwrap();
791 assert!(pattern.matches(Path::new("agent1.md")));
792 assert!(pattern.matches(Path::new("agenta.md")));
793 assert!(!pattern.matches(Path::new("agent10.md")));
794 assert!(!pattern.matches(Path::new("agent.md")));
795 }
796
797 #[test]
798 fn test_edge_cases() {
799 // Empty pattern
800 assert!(PatternMatcher::new("").is_ok());
801
802 // Pattern with spaces
803 let pattern = PatternMatcher::new("my agent.md").unwrap();
804 assert!(pattern.matches(Path::new("my agent.md")));
805 assert!(!pattern.matches(Path::new("myagent.md")));
806
807 // Pattern with special characters
808 let pattern = PatternMatcher::new("agent-v1.0.0.md").unwrap();
809 assert!(pattern.matches(Path::new("agent-v1.0.0.md")));
810
811 // Very long pattern
812 let long_pattern = "a".repeat(1000) + "*.md";
813 assert!(PatternMatcher::new(&long_pattern).is_ok());
814 }
815
816 #[test]
817 fn test_find_matches_with_symlinks() {
818 let temp_dir = TempDir::new().unwrap();
819 let base_path = temp_dir.path();
820
821 // Create files and a symlink
822 fs::create_dir_all(base_path.join("real")).unwrap();
823 fs::write(base_path.join("real/file.md"), "").unwrap();
824
825 #[cfg(unix)]
826 {
827 use std::os::unix::fs::symlink;
828 symlink(base_path.join("real"), base_path.join("link")).unwrap();
829
830 let pattern = PatternMatcher::new("**/*.md").unwrap();
831 let matches = pattern.find_matches(base_path).unwrap();
832
833 // Should not follow symlinks (security measure)
834 assert_eq!(matches.len(), 1);
835 assert!(matches.contains(&PathBuf::from("real/file.md")));
836 }
837
838 #[cfg(not(unix))]
839 {
840 // On non-Unix systems, just verify basic functionality
841 let pattern = PatternMatcher::new("**/*.md").unwrap();
842 let matches = pattern.find_matches(base_path).unwrap();
843 assert_eq!(matches.len(), 1);
844 assert!(matches.contains(&PathBuf::from("real/file.md")));
845 }
846 }
847
848 #[test]
849 fn test_pattern_resolver_with_multiple_exclusions() {
850 let temp_dir = TempDir::new().unwrap();
851 let base_path = temp_dir.path();
852
853 // Create test files
854 fs::create_dir_all(base_path.join("agents")).unwrap();
855 fs::write(base_path.join("agents/helper.md"), "").unwrap();
856 fs::write(base_path.join("agents/test.md"), "").unwrap();
857 fs::write(base_path.join("agents/debug.md"), "").unwrap();
858 fs::write(base_path.join("agents/production.md"), "").unwrap();
859
860 let mut resolver = PatternResolver::new();
861 resolver.exclude("*/test.md").unwrap();
862 resolver.exclude("*/debug.md").unwrap();
863
864 let matches = resolver.resolve("agents/*.md", base_path).unwrap();
865 assert_eq!(matches.len(), 2);
866 assert!(matches.contains(&PathBuf::from("agents/helper.md")));
867 assert!(matches.contains(&PathBuf::from("agents/production.md")));
868 }
869
870 #[test]
871 fn test_concurrent_pattern_resolution() {
872 use std::sync::Arc;
873 use std::thread;
874
875 let temp_dir = TempDir::new().unwrap();
876 let base_path = Arc::new(temp_dir.path().to_path_buf());
877
878 // Create test files
879 for i in 0..100 {
880 fs::write(base_path.join(format!("file{}.md", i)), "").unwrap();
881 }
882
883 // Run pattern matching concurrently
884 let mut handles = vec![];
885 for _ in 0..10 {
886 let path = Arc::clone(&base_path);
887 let handle = thread::spawn(move || {
888 let pattern = PatternMatcher::new("*.md").unwrap();
889 pattern.find_matches(&path).unwrap()
890 });
891 handles.push(handle);
892 }
893
894 // All threads should find the same files
895 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
896 let first_result = &results[0];
897 for result in &results[1..] {
898 assert_eq!(result.len(), first_result.len());
899 }
900 }
901
902 #[test]
903 fn test_pattern_performance() {
904 let temp_dir = TempDir::new().unwrap();
905 let base_path = temp_dir.path();
906
907 // Create a large directory structure
908 for i in 0..10 {
909 let dir = base_path.join(format!("dir{}", i));
910 fs::create_dir_all(&dir).unwrap();
911 for j in 0..100 {
912 fs::write(dir.join(format!("file{}.md", j)), "").unwrap();
913 }
914 }
915
916 let pattern = PatternMatcher::new("**/*.md").unwrap();
917 let start = std::time::Instant::now();
918 let matches = pattern.find_matches(base_path).unwrap();
919 let duration = start.elapsed();
920
921 assert_eq!(matches.len(), 1000);
922 // Should complete reasonably quickly (< 1 second for 1000 files)
923 assert!(duration.as_secs() < 1);
924 }
925}