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}