agpm_cli/resolver/path_resolver.rs
1//! Path computation and resolution for dependency installation.
2//!
3//! This module provides utilities for computing installation paths, resolving
4//! pattern paths, determining flatten behavior, and handling all path-related
5//! operations for resource dependencies. It supports both merge-target resources
6//! (Hooks, MCP servers) and regular file-based resources (Agents, Commands,
7//! Snippets, Scripts).
8
9use crate::core::ResourceType;
10use crate::manifest::{Manifest, ResourceDependency};
11use crate::utils::{compute_relative_install_path, normalize_path_for_storage};
12use anyhow::Result;
13use std::path::{Path, PathBuf};
14
15/// Parses a pattern string to extract the base path and pattern components.
16///
17/// Handles three cases:
18/// 1. Patterns with path separators and absolute/relative parents
19/// 2. Patterns with path separators but simple relative paths
20/// 3. Simple patterns without path separators
21///
22/// # Arguments
23///
24/// * `pattern` - The glob pattern string (e.g., "agents/*.md", "../foo/*.md")
25///
26/// # Returns
27///
28/// A tuple of (base_path, pattern_str) where:
29/// - `base_path` is the directory to search in
30/// - `pattern_str` is the glob pattern to match files against
31///
32/// # Examples
33///
34/// ```
35/// use std::path::{Path, PathBuf};
36/// use agpm_cli::resolver::path_resolver::parse_pattern_base_path;
37///
38/// let (base, pattern) = parse_pattern_base_path("agents/*.md");
39/// assert_eq!(base, PathBuf::from("."));
40/// assert_eq!(pattern, "agents/*.md");
41///
42/// let (base, pattern) = parse_pattern_base_path("../foo/bar/*.md");
43/// assert_eq!(base, PathBuf::from("../foo/bar"));
44/// assert_eq!(pattern, "*.md");
45/// ```
46pub fn parse_pattern_base_path(pattern: &str) -> (PathBuf, String) {
47 if pattern.contains('/') || pattern.contains('\\') {
48 // Pattern contains path separators, extract base path
49 let pattern_path = Path::new(pattern);
50 if let Some(parent) = pattern_path.parent() {
51 if parent.is_absolute() || parent.starts_with("..") || parent.starts_with(".") {
52 // Use the parent as base path and just the filename pattern
53 (
54 parent.to_path_buf(),
55 pattern_path
56 .file_name()
57 .and_then(|s| s.to_str())
58 .unwrap_or(pattern)
59 .to_string(),
60 )
61 } else {
62 // Relative path, use current directory as base
63 (PathBuf::from("."), pattern.to_string())
64 }
65 } else {
66 // No parent, use current directory
67 (PathBuf::from("."), pattern.to_string())
68 }
69 } else {
70 // Simple pattern without path separators
71 (PathBuf::from("."), pattern.to_string())
72 }
73}
74
75/// Computes the installation path for a merge-target resource (Hook or McpServer).
76///
77/// These resources are not installed as files but are merged into configuration files.
78/// The installation path is determined by the tool's merge target configuration or
79/// hardcoded defaults.
80///
81/// # Arguments
82///
83/// * `manifest` - The project manifest containing tool configurations
84/// * `artifact_type` - The tool name (e.g., "claude-code", "opencode")
85/// * `resource_type` - The resource type (Hook or McpServer)
86///
87/// # Returns
88///
89/// The normalized path to the merge target configuration file.
90///
91/// # Examples
92///
93/// ```no_run
94/// use agpm_cli::core::ResourceType;
95/// use agpm_cli::manifest::Manifest;
96/// use agpm_cli::resolver::path_resolver::compute_merge_target_install_path;
97///
98/// let manifest = Manifest::new();
99/// let path = compute_merge_target_install_path(&manifest, "claude-code", ResourceType::Hook);
100/// assert_eq!(path, ".claude/settings.local.json");
101/// ```
102pub fn compute_merge_target_install_path(
103 manifest: &Manifest,
104 artifact_type: &str,
105 resource_type: ResourceType,
106) -> String {
107 // Use configured merge target, with fallback to hardcoded defaults
108 if let Some(merge_target) = manifest.get_merge_target(artifact_type, resource_type) {
109 normalize_path_for_storage(merge_target.display().to_string())
110 } else {
111 // Fallback to hardcoded defaults if not configured
112 match resource_type {
113 ResourceType::Hook => ".claude/settings.local.json".to_string(),
114 ResourceType::McpServer => {
115 if artifact_type == "opencode" {
116 ".opencode/opencode.json".to_string()
117 } else {
118 ".mcp.json".to_string()
119 }
120 }
121 _ => unreachable!(
122 "compute_merge_target_install_path should only be called for Hook or McpServer"
123 ),
124 }
125 }
126}
127
128/// Computes the installation path for a regular resource (Agent, Command, Snippet, Script).
129///
130/// Regular resources are installed as files in tool-specific directories. This function
131/// determines the final installation path by:
132/// 1. Getting the base artifact path from tool configuration
133/// 2. Applying any custom target override from the dependency
134/// 3. Computing the relative path based on flatten behavior
135/// 4. Avoiding redundant directory prefixes
136///
137/// # Arguments
138///
139/// * `manifest` - The project manifest containing tool configurations
140/// * `dep` - The resource dependency specification
141/// * `artifact_type` - The tool name (e.g., "claude-code", "opencode")
142/// * `resource_type` - The resource type (Agent, Command, etc.)
143/// * `filename` - The meaningful path structure extracted from the source file
144///
145/// # Returns
146///
147/// The normalized installation path, or an error if the resource type is not supported
148/// by the specified tool.
149///
150/// # Errors
151///
152/// Returns an error if:
153/// - The resource type is not supported by the specified tool
154///
155/// # Examples
156///
157/// ```no_run
158/// use agpm_cli::core::ResourceType;
159/// use agpm_cli::manifest::Manifest;
160/// use agpm_cli::resolver::path_resolver::compute_regular_resource_install_path;
161///
162/// # fn example() -> anyhow::Result<()> {
163/// let manifest = Manifest::new();
164/// # let dep: agpm_cli::manifest::ResourceDependency = todo!();
165/// let path = compute_regular_resource_install_path(
166/// &manifest,
167/// &dep,
168/// "claude-code",
169/// ResourceType::Agent,
170/// "agents/helper.md"
171/// )?;
172/// # Ok(())
173/// # }
174/// ```
175pub fn compute_regular_resource_install_path(
176 manifest: &Manifest,
177 dep: &ResourceDependency,
178 artifact_type: &str,
179 resource_type: ResourceType,
180 filename: &str,
181) -> Result<String> {
182 // Get the artifact path for this resource type
183 let artifact_path =
184 manifest.get_artifact_resource_path(artifact_type, resource_type).ok_or_else(|| {
185 anyhow::anyhow!(
186 "Resource type '{}' is not supported by tool '{}'",
187 resource_type,
188 artifact_type
189 )
190 })?;
191
192 // Determine flatten behavior: use explicit setting or tool config default
193 let flatten = get_flatten_behavior(manifest, dep, artifact_type, resource_type);
194
195 // Determine the base target directory
196 let base_target = if let Some(custom_target) = dep.get_target() {
197 // Custom target is relative to the artifact's resource directory
198 // Strip leading path separators (both Unix and Windows) to ensure relative path
199 PathBuf::from(artifact_path.display().to_string())
200 .join(custom_target.trim_start_matches(['/', '\\']))
201 } else {
202 artifact_path.to_path_buf()
203 };
204
205 // Use compute_relative_install_path to avoid redundant prefixes
206 let relative_path = compute_relative_install_path(&base_target, Path::new(filename), flatten);
207 // Convert directly to Unix format for lockfile storage (forward slashes only)
208 Ok(normalize_path_for_storage(base_target.join(relative_path)))
209}
210
211/// Determines the flatten behavior for a resource installation.
212///
213/// Flatten behavior controls whether directory structure from the source repository
214/// is preserved in the installation path. The decision is made by checking:
215/// 1. Explicit `flatten` setting on the dependency (highest priority)
216/// 2. Tool configuration default for this resource type
217/// 3. Global default (false)
218///
219/// # Arguments
220///
221/// * `manifest` - The project manifest containing tool configurations
222/// * `dep` - The resource dependency specification
223/// * `artifact_type` - The tool name (e.g., "claude-code", "opencode")
224/// * `resource_type` - The resource type (Agent, Command, etc.)
225///
226/// # Returns
227///
228/// `true` if directory structure should be flattened, `false` if it should be preserved.
229///
230/// # Examples
231///
232/// ```no_run
233/// use agpm_cli::core::ResourceType;
234/// use agpm_cli::manifest::Manifest;
235/// use agpm_cli::resolver::path_resolver::get_flatten_behavior;
236///
237/// let manifest = Manifest::new();
238/// # let dep: agpm_cli::manifest::ResourceDependency = todo!();
239/// let flatten = get_flatten_behavior(&manifest, &dep, "claude-code", ResourceType::Agent);
240/// ```
241pub fn get_flatten_behavior(
242 manifest: &Manifest,
243 dep: &ResourceDependency,
244 artifact_type: &str,
245 resource_type: ResourceType,
246) -> bool {
247 let dep_flatten = dep.get_flatten();
248 let tool_flatten = manifest
249 .get_tool_config(artifact_type)
250 .and_then(|config| config.resources.get(resource_type.to_plural()))
251 .and_then(|resource_config| resource_config.flatten);
252
253 dep_flatten.or(tool_flatten).unwrap_or(false) // Default to false if not configured
254}
255
256/// Constructs the full relative path for a matched pattern file.
257///
258/// Combines the base path with the matched file path, normalizing path separators
259/// for storage in the lockfile.
260///
261/// # Arguments
262///
263/// * `base_path` - The base directory the pattern was resolved in
264/// * `matched_path` - The path to the matched file (relative to base_path)
265///
266/// # Returns
267///
268/// A normalized path string suitable for storage in the lockfile.
269///
270/// # Examples
271///
272/// ```
273/// use std::path::{Path, PathBuf};
274/// use agpm_cli::resolver::path_resolver::construct_full_relative_path;
275///
276/// let base = PathBuf::from(".");
277/// let matched = Path::new("agents/helper.md");
278/// let path = construct_full_relative_path(&base, matched);
279/// assert_eq!(path, "agents/helper.md");
280///
281/// let base = PathBuf::from("../foo");
282/// let matched = Path::new("bar.md");
283/// let path = construct_full_relative_path(&base, matched);
284/// assert_eq!(path, "../foo/bar.md");
285/// ```
286pub fn construct_full_relative_path(base_path: &Path, matched_path: &Path) -> String {
287 if base_path == Path::new(".") {
288 crate::utils::normalize_path_for_storage(matched_path.to_string_lossy().to_string())
289 } else {
290 crate::utils::normalize_path_for_storage(format!(
291 "{}/{}",
292 base_path.display(),
293 matched_path.display()
294 ))
295 }
296}
297
298/// Extracts the meaningful path for pattern matching.
299///
300/// Constructs the full path from base path and matched path, then extracts
301/// the meaningful structure by removing redundant directory prefixes.
302///
303/// # Arguments
304///
305/// * `base_path` - The base directory the pattern was resolved in
306/// * `matched_path` - The path to the matched file (relative to base_path)
307///
308/// # Returns
309///
310/// The meaningful path structure string.
311///
312/// # Examples
313///
314/// ```
315/// use std::path::{Path, PathBuf};
316/// use agpm_cli::resolver::path_resolver::extract_pattern_filename;
317///
318/// let base = PathBuf::from(".");
319/// let matched = Path::new("agents/helper.md");
320/// let filename = extract_pattern_filename(&base, matched);
321/// assert_eq!(filename, "agents/helper.md");
322/// ```
323pub fn extract_pattern_filename(base_path: &Path, matched_path: &Path) -> String {
324 let full_path = if base_path == Path::new(".") {
325 matched_path.to_path_buf()
326 } else {
327 base_path.join(matched_path)
328 };
329 extract_meaningful_path(&full_path)
330}
331
332/// Extracts the meaningful path by removing redundant directory prefixes.
333///
334/// This prevents paths like `.claude/agents/agents/file.md` by eliminating
335/// duplicate directory components.
336///
337/// # Arguments
338///
339/// * `path` - The path to extract meaningful structure from
340///
341/// # Returns
342///
343/// The normalized meaningful path string
344pub fn extract_meaningful_path(path: &Path) -> String {
345 let components: Vec<_> = path.components().collect();
346
347 if path.is_absolute() {
348 // Case 2: Absolute path - resolve ".." components first, then strip root
349 let mut resolved = Vec::new();
350
351 for component in components.iter() {
352 match component {
353 std::path::Component::Normal(name) => {
354 resolved.push(name.to_str().unwrap_or(""));
355 }
356 std::path::Component::ParentDir => {
357 // Pop the last component if there is one
358 resolved.pop();
359 }
360 // Skip RootDir, Prefix, and CurDir
361 _ => {}
362 }
363 }
364
365 resolved.join("/")
366 } else if components.iter().any(|c| matches!(c, std::path::Component::ParentDir)) {
367 // Case 1: Relative path with "../" - skip all parent components
368 let start_idx = components
369 .iter()
370 .position(|c| matches!(c, std::path::Component::Normal(_)))
371 .unwrap_or(0);
372
373 components[start_idx..]
374 .iter()
375 .filter_map(|c| c.as_os_str().to_str())
376 .collect::<Vec<_>>()
377 .join("/")
378 } else {
379 // Case 3: Clean relative path - use as-is
380 path.to_str().unwrap_or("").replace('\\', "/") // Normalize to forward slashes
381 }
382}
383
384/// Checks if a path is a file-relative path (starts with "./" or "../").
385///
386/// # Arguments
387///
388/// * `path` - The path to check
389///
390/// # Returns
391///
392/// `true` if the path is file-relative, `false` otherwise
393pub fn is_file_relative_path(path: &str) -> bool {
394 path.starts_with("./") || path.starts_with("../")
395}
396
397/// Normalizes a bare filename by removing directory components.
398///
399/// # Arguments
400///
401/// * `path` - The path to normalize
402///
403/// # Returns
404///
405/// The normalized filename
406pub fn normalize_bare_filename(path: &str) -> String {
407 let path_buf = Path::new(path);
408 path_buf.file_name().and_then(|name| name.to_str()).unwrap_or(path).to_string()
409}
410
411// ============================================================================
412// Installation Path Resolution
413// ============================================================================
414
415/// Resolves the installation path for any resource type.
416///
417/// This is the main entry point for computing where a resource will be installed.
418/// It handles both merge-target resources (Hooks, MCP servers) and regular resources
419/// (Agents, Commands, Snippets, Scripts).
420///
421/// # Arguments
422///
423/// * `manifest` - The project manifest containing tool configurations
424/// * `dep` - The resource dependency specification
425/// * `artifact_type` - The tool name (e.g., "claude-code", "opencode")
426/// * `resource_type` - The resource type
427/// * `source_filename` - The filename/path from the source repository
428///
429/// # Returns
430///
431/// The normalized installation path, or an error if the resource type is not supported
432/// by the specified tool.
433///
434/// # Errors
435///
436/// Returns an error if:
437/// - The resource type is not supported by the specified tool
438///
439/// # Examples
440///
441/// ```no_run
442/// use agpm_cli::core::ResourceType;
443/// use agpm_cli::manifest::Manifest;
444/// use agpm_cli::resolver::path_resolver::resolve_install_path;
445///
446/// # fn example() -> anyhow::Result<()> {
447/// let manifest = Manifest::new();
448/// # let dep: agpm_cli::manifest::ResourceDependency = todo!();
449/// let path = resolve_install_path(
450/// &manifest,
451/// &dep,
452/// "claude-code",
453/// ResourceType::Agent,
454/// "agents/helper.md"
455/// )?;
456/// # Ok(())
457/// # }
458/// ```
459pub fn resolve_install_path(
460 manifest: &Manifest,
461 dep: &ResourceDependency,
462 artifact_type: &str,
463 resource_type: ResourceType,
464 source_filename: &str,
465) -> Result<String> {
466 match resource_type {
467 ResourceType::Hook | ResourceType::McpServer => {
468 Ok(resolve_merge_target_path(manifest, artifact_type, resource_type))
469 }
470 _ => resolve_regular_resource_path(
471 manifest,
472 dep,
473 artifact_type,
474 resource_type,
475 source_filename,
476 ),
477 }
478}
479
480/// Resolves the installation path for merge-target resources (Hook, McpServer).
481///
482/// These resources are not installed as files but are merged into configuration files.
483/// Uses configured merge targets or falls back to hardcoded defaults.
484///
485/// # Arguments
486///
487/// * `manifest` - The project manifest containing tool configurations
488/// * `artifact_type` - The tool name (e.g., "claude-code", "opencode")
489/// * `resource_type` - Must be Hook or McpServer
490///
491/// # Returns
492///
493/// The normalized path to the merge target configuration file.
494pub fn resolve_merge_target_path(
495 manifest: &Manifest,
496 artifact_type: &str,
497 resource_type: ResourceType,
498) -> String {
499 if let Some(merge_target) = manifest.get_merge_target(artifact_type, resource_type) {
500 normalize_path_for_storage(merge_target.display().to_string())
501 } else {
502 // Fallback to hardcoded defaults if not configured
503 match resource_type {
504 ResourceType::Hook => ".claude/settings.local.json".to_string(),
505 ResourceType::McpServer => {
506 if artifact_type == "opencode" {
507 ".opencode/opencode.json".to_string()
508 } else {
509 ".mcp.json".to_string()
510 }
511 }
512 _ => unreachable!(
513 "resolve_merge_target_path should only be called for Hook or McpServer"
514 ),
515 }
516 }
517}
518
519/// Resolves the installation path for regular file-based resources.
520///
521/// Handles agents, commands, snippets, and scripts by:
522/// 1. Getting the base artifact path from tool configuration
523/// 2. Applying custom target overrides if specified
524/// 3. Computing the relative path based on flatten behavior
525/// 4. Avoiding redundant directory prefixes
526///
527/// # Arguments
528///
529/// * `manifest` - The project manifest containing tool configurations
530/// * `dep` - The resource dependency specification
531/// * `artifact_type` - The tool name (e.g., "claude-code", "opencode")
532/// * `resource_type` - The resource type (Agent, Command, Snippet, Script)
533/// * `source_filename` - The filename/path from the source repository
534///
535/// # Returns
536///
537/// The normalized installation path, or an error if the resource type is not supported.
538///
539/// # Errors
540///
541/// Returns an error if the resource type is not supported by the specified tool.
542pub fn resolve_regular_resource_path(
543 manifest: &Manifest,
544 dep: &ResourceDependency,
545 artifact_type: &str,
546 resource_type: ResourceType,
547 source_filename: &str,
548) -> Result<String> {
549 // Get the artifact path for this resource type
550 let artifact_path =
551 manifest.get_artifact_resource_path(artifact_type, resource_type).ok_or_else(|| {
552 create_unsupported_resource_error(artifact_type, resource_type, dep.get_path())
553 })?;
554
555 // Compute the final path
556 let path = if let Some(custom_target) = dep.get_target() {
557 compute_custom_target_path(
558 &artifact_path,
559 custom_target,
560 source_filename,
561 dep,
562 manifest,
563 artifact_type,
564 resource_type,
565 )
566 } else {
567 compute_default_path(
568 &artifact_path,
569 source_filename,
570 dep,
571 manifest,
572 artifact_type,
573 resource_type,
574 )
575 };
576
577 // Convert directly to Unix format for lockfile storage (forward slashes only)
578 Ok(normalize_path_for_storage(path))
579}
580
581/// Computes the installation path when a custom target directory is specified.
582///
583/// Custom targets are relative to the artifact's resource directory. The function
584/// uses the original artifact path (not the custom target) for prefix stripping
585/// to avoid duplicate directories.
586fn compute_custom_target_path(
587 artifact_path: &Path,
588 custom_target: &str,
589 source_filename: &str,
590 dep: &ResourceDependency,
591 manifest: &Manifest,
592 artifact_type: &str,
593 resource_type: ResourceType,
594) -> PathBuf {
595 let flatten = get_flatten_behavior(manifest, dep, artifact_type, resource_type);
596 // Strip leading path separators (both Unix and Windows) to ensure relative path
597 let base_target = PathBuf::from(artifact_path.display().to_string())
598 .join(custom_target.trim_start_matches(['/', '\\']));
599 // For custom targets, still strip prefix based on the original artifact path
600 let relative_path =
601 compute_relative_install_path(artifact_path, Path::new(source_filename), flatten);
602 base_target.join(relative_path)
603}
604
605/// Computes the installation path using the default artifact path.
606fn compute_default_path(
607 artifact_path: &Path,
608 source_filename: &str,
609 dep: &ResourceDependency,
610 manifest: &Manifest,
611 artifact_type: &str,
612 resource_type: ResourceType,
613) -> PathBuf {
614 let flatten = get_flatten_behavior(manifest, dep, artifact_type, resource_type);
615 let relative_path =
616 compute_relative_install_path(artifact_path, Path::new(source_filename), flatten);
617 artifact_path.join(relative_path)
618}
619
620/// Creates a detailed error message when a resource type is not supported by a tool.
621///
622/// Provides helpful hints if it looks like a tool name was used as a resource type.
623fn create_unsupported_resource_error(
624 artifact_type: &str,
625 resource_type: ResourceType,
626 source_path: &str,
627) -> anyhow::Error {
628 let base_msg =
629 format!("Resource type '{}' is not supported by tool '{}'", resource_type, artifact_type);
630
631 let resource_type_str = resource_type.to_string();
632 let hint = if ["claude-code", "opencode", "agpm"].contains(&resource_type_str.as_str()) {
633 format!(
634 "\n\nIt looks like '{}' is a tool name, not a resource type.\n\
635 In transitive dependencies, use resource types (agents, snippets, commands)\n\
636 as section headers, then specify 'tool: {}' within each dependency.",
637 resource_type_str, resource_type_str
638 )
639 } else {
640 format!(
641 "\n\nValid resource types: agent, command, snippet, hook, mcp-server, script\n\
642 Source file: {}",
643 source_path
644 )
645 };
646
647 anyhow::anyhow!("{}{}", base_msg, hint)
648}
649
650/// Transforms an installation path for private dependencies.
651///
652/// Private dependencies are installed to a `private/` subdirectory within their
653/// resource type directory. For example:
654/// - `.claude/agents/helper.md` becomes `.claude/agents/private/helper.md`
655/// - `.agpm/snippets/utils.md` becomes `.agpm/snippets/private/utils.md`
656///
657/// This function intelligently inserts `private/` after the resource type directory
658/// by detecting common path patterns.
659///
660/// # Arguments
661///
662/// * `path` - The original installation path
663///
664/// # Returns
665///
666/// The transformed path with `private/` inserted before the filename.
667///
668/// # Examples
669///
670/// ```
671/// use agpm_cli::resolver::path_resolver::transform_path_for_private;
672///
673/// // Without tool namespace
674/// let path = transform_path_for_private(".claude/agents/helper.md");
675/// assert_eq!(path, ".claude/agents/private/helper.md");
676///
677/// // With tool namespace (the common case)
678/// let path = transform_path_for_private(".claude/agents/agpm/helper.md");
679/// assert_eq!(path, ".claude/agents/agpm/private/helper.md");
680///
681/// let path = transform_path_for_private(".agpm/snippets/utils.md");
682/// assert_eq!(path, ".agpm/snippets/private/utils.md");
683///
684/// // OpenCode paths (singular directory names)
685/// let path = transform_path_for_private(".opencode/agent/agpm/test.md");
686/// assert_eq!(path, ".opencode/agent/agpm/private/test.md");
687/// ```
688pub fn transform_path_for_private(path: &str) -> String {
689 // Split the path into components
690 let path_obj = Path::new(path);
691 let components: Vec<_> = path_obj.components().collect();
692
693 // Insert "private" before the filename (last component)
694 // This handles both paths with and without tool namespaces:
695 // - .claude/agents/helper.md -> .claude/agents/private/helper.md
696 // - .claude/agents/agpm/helper.md -> .claude/agents/agpm/private/helper.md
697 // - .opencode/agent/agpm/test.md -> .opencode/agent/agpm/private/test.md
698 if components.len() >= 2 {
699 let mut result_components: Vec<String> = components
700 .iter()
701 .take(components.len() - 1)
702 .map(|c| c.as_os_str().to_string_lossy().to_string())
703 .collect();
704
705 // Insert "private" before the filename
706 result_components.push("private".to_string());
707
708 // Add the filename
709 if let Some(last) = components.last() {
710 result_components.push(last.as_os_str().to_string_lossy().to_string());
711 }
712
713 result_components.join("/")
714 } else {
715 // Path is too short, just prepend private/
716 format!("private/{path}")
717 }
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 #[test]
725 fn test_parse_pattern_base_path_simple() {
726 let (base, pattern) = parse_pattern_base_path("*.md");
727 assert_eq!(base, PathBuf::from("."));
728 assert_eq!(pattern, "*.md");
729 }
730
731 #[test]
732 fn test_parse_pattern_base_path_with_directory() {
733 let (base, pattern) = parse_pattern_base_path("agents/*.md");
734 assert_eq!(base, PathBuf::from("."));
735 assert_eq!(pattern, "agents/*.md");
736 }
737
738 #[test]
739 fn test_parse_pattern_base_path_with_parent() {
740 let (base, pattern) = parse_pattern_base_path("../foo/*.md");
741 assert_eq!(base, PathBuf::from("../foo"));
742 assert_eq!(pattern, "*.md");
743 }
744
745 #[test]
746 fn test_parse_pattern_base_path_with_current_dir() {
747 let (base, pattern) = parse_pattern_base_path("./foo/*.md");
748 assert_eq!(base, PathBuf::from("./foo"));
749 assert_eq!(pattern, "*.md");
750 }
751
752 #[test]
753 fn test_construct_full_relative_path_current_dir() {
754 let base = PathBuf::from(".");
755 let matched = Path::new("agents/helper.md");
756 let path = construct_full_relative_path(&base, matched);
757 assert_eq!(path, "agents/helper.md");
758 }
759
760 #[test]
761 fn test_construct_full_relative_path_with_base() {
762 let base = PathBuf::from("../foo");
763 let matched = Path::new("bar.md");
764 let path = construct_full_relative_path(&base, matched);
765 assert_eq!(path, "../foo/bar.md");
766 }
767
768 #[test]
769 fn test_extract_pattern_filename_current_dir() {
770 let base = PathBuf::from(".");
771 let matched = Path::new("agents/helper.md");
772 let filename = extract_pattern_filename(&base, matched);
773 assert_eq!(filename, "agents/helper.md");
774 }
775}