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, 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        PathBuf::from(artifact_path.display().to_string())
199            .join(custom_target.trim_start_matches('/'))
200    } else {
201        artifact_path.to_path_buf()
202    };
203
204    // Use compute_relative_install_path to avoid redundant prefixes
205    let relative_path = compute_relative_install_path(&base_target, Path::new(filename), flatten);
206    Ok(normalize_path_for_storage(normalize_path(&base_target.join(relative_path))))
207}
208
209/// Determines the flatten behavior for a resource installation.
210///
211/// Flatten behavior controls whether directory structure from the source repository
212/// is preserved in the installation path. The decision is made by checking:
213/// 1. Explicit `flatten` setting on the dependency (highest priority)
214/// 2. Tool configuration default for this resource type
215/// 3. Global default (false)
216///
217/// # Arguments
218///
219/// * `manifest` - The project manifest containing tool configurations
220/// * `dep` - The resource dependency specification
221/// * `artifact_type` - The tool name (e.g., "claude-code", "opencode")
222/// * `resource_type` - The resource type (Agent, Command, etc.)
223///
224/// # Returns
225///
226/// `true` if directory structure should be flattened, `false` if it should be preserved.
227///
228/// # Examples
229///
230/// ```no_run
231/// use agpm_cli::core::ResourceType;
232/// use agpm_cli::manifest::Manifest;
233/// use agpm_cli::resolver::path_resolver::get_flatten_behavior;
234///
235/// let manifest = Manifest::new();
236/// # let dep: agpm_cli::manifest::ResourceDependency = todo!();
237/// let flatten = get_flatten_behavior(&manifest, &dep, "claude-code", ResourceType::Agent);
238/// ```
239pub fn get_flatten_behavior(
240    manifest: &Manifest,
241    dep: &ResourceDependency,
242    artifact_type: &str,
243    resource_type: ResourceType,
244) -> bool {
245    let dep_flatten = dep.get_flatten();
246    let tool_flatten = manifest
247        .get_tool_config(artifact_type)
248        .and_then(|config| config.resources.get(resource_type.to_plural()))
249        .and_then(|resource_config| resource_config.flatten);
250
251    dep_flatten.or(tool_flatten).unwrap_or(false) // Default to false if not configured
252}
253
254/// Constructs the full relative path for a matched pattern file.
255///
256/// Combines the base path with the matched file path, normalizing path separators
257/// for storage in the lockfile.
258///
259/// # Arguments
260///
261/// * `base_path` - The base directory the pattern was resolved in
262/// * `matched_path` - The path to the matched file (relative to base_path)
263///
264/// # Returns
265///
266/// A normalized path string suitable for storage in the lockfile.
267///
268/// # Examples
269///
270/// ```
271/// use std::path::{Path, PathBuf};
272/// use agpm_cli::resolver::path_resolver::construct_full_relative_path;
273///
274/// let base = PathBuf::from(".");
275/// let matched = Path::new("agents/helper.md");
276/// let path = construct_full_relative_path(&base, matched);
277/// assert_eq!(path, "agents/helper.md");
278///
279/// let base = PathBuf::from("../foo");
280/// let matched = Path::new("bar.md");
281/// let path = construct_full_relative_path(&base, matched);
282/// assert_eq!(path, "../foo/bar.md");
283/// ```
284pub fn construct_full_relative_path(base_path: &Path, matched_path: &Path) -> String {
285    if base_path == Path::new(".") {
286        crate::utils::normalize_path_for_storage(matched_path.to_string_lossy().to_string())
287    } else {
288        crate::utils::normalize_path_for_storage(format!(
289            "{}/{}",
290            base_path.display(),
291            matched_path.display()
292        ))
293    }
294}
295
296/// Extracts the meaningful path for pattern matching.
297///
298/// Constructs the full path from base path and matched path, then extracts
299/// the meaningful structure by removing redundant directory prefixes.
300///
301/// # Arguments
302///
303/// * `base_path` - The base directory the pattern was resolved in
304/// * `matched_path` - The path to the matched file (relative to base_path)
305///
306/// # Returns
307///
308/// The meaningful path structure string.
309///
310/// # Examples
311///
312/// ```
313/// use std::path::{Path, PathBuf};
314/// use agpm_cli::resolver::path_resolver::extract_pattern_filename;
315///
316/// let base = PathBuf::from(".");
317/// let matched = Path::new("agents/helper.md");
318/// let filename = extract_pattern_filename(&base, matched);
319/// assert_eq!(filename, "agents/helper.md");
320/// ```
321pub fn extract_pattern_filename(base_path: &Path, matched_path: &Path) -> String {
322    let full_path = if base_path == Path::new(".") {
323        matched_path.to_path_buf()
324    } else {
325        base_path.join(matched_path)
326    };
327    extract_meaningful_path(&full_path)
328}
329
330/// Extracts the meaningful path by removing redundant directory prefixes.
331///
332/// This prevents paths like `.claude/agents/agents/file.md` by eliminating
333/// duplicate directory components.
334///
335/// # Arguments
336///
337/// * `path` - The path to extract meaningful structure from
338///
339/// # Returns
340///
341/// The normalized meaningful path string
342pub fn extract_meaningful_path(path: &Path) -> String {
343    let components: Vec<_> = path.components().collect();
344
345    if path.is_absolute() {
346        // Case 2: Absolute path - resolve ".." components first, then strip root
347        let mut resolved = Vec::new();
348
349        for component in components.iter() {
350            match component {
351                std::path::Component::Normal(name) => {
352                    resolved.push(name.to_str().unwrap_or(""));
353                }
354                std::path::Component::ParentDir => {
355                    // Pop the last component if there is one
356                    resolved.pop();
357                }
358                // Skip RootDir, Prefix, and CurDir
359                _ => {}
360            }
361        }
362
363        resolved.join("/")
364    } else if components.iter().any(|c| matches!(c, std::path::Component::ParentDir)) {
365        // Case 1: Relative path with "../" - skip all parent components
366        let start_idx = components
367            .iter()
368            .position(|c| matches!(c, std::path::Component::Normal(_)))
369            .unwrap_or(0);
370
371        components[start_idx..]
372            .iter()
373            .filter_map(|c| c.as_os_str().to_str())
374            .collect::<Vec<_>>()
375            .join("/")
376    } else {
377        // Case 3: Clean relative path - use as-is
378        path.to_str().unwrap_or("").replace('\\', "/") // Normalize to forward slashes
379    }
380}
381
382/// Checks if a path is a file-relative path (starts with "./" or "../").
383///
384/// # Arguments
385///
386/// * `path` - The path to check
387///
388/// # Returns
389///
390/// `true` if the path is file-relative, `false` otherwise
391pub fn is_file_relative_path(path: &str) -> bool {
392    path.starts_with("./") || path.starts_with("../")
393}
394
395/// Normalizes a bare filename by removing directory components.
396///
397/// # Arguments
398///
399/// * `path` - The path to normalize
400///
401/// # Returns
402///
403/// The normalized filename
404pub fn normalize_bare_filename(path: &str) -> String {
405    let path_buf = Path::new(path);
406    path_buf.file_name().and_then(|name| name.to_str()).unwrap_or(path).to_string()
407}
408
409// ============================================================================
410// Installation Path Resolution
411// ============================================================================
412
413/// Resolves the installation path for any resource type.
414///
415/// This is the main entry point for computing where a resource will be installed.
416/// It handles both merge-target resources (Hooks, MCP servers) and regular resources
417/// (Agents, Commands, Snippets, Scripts).
418///
419/// # Arguments
420///
421/// * `manifest` - The project manifest containing tool configurations
422/// * `dep` - The resource dependency specification
423/// * `artifact_type` - The tool name (e.g., "claude-code", "opencode")
424/// * `resource_type` - The resource type
425/// * `source_filename` - The filename/path from the source repository
426///
427/// # Returns
428///
429/// The normalized installation path, or an error if the resource type is not supported
430/// by the specified tool.
431///
432/// # Errors
433///
434/// Returns an error if:
435/// - The resource type is not supported by the specified tool
436///
437/// # Examples
438///
439/// ```no_run
440/// use agpm_cli::core::ResourceType;
441/// use agpm_cli::manifest::Manifest;
442/// use agpm_cli::resolver::path_resolver::resolve_install_path;
443///
444/// # fn example() -> anyhow::Result<()> {
445/// let manifest = Manifest::new();
446/// # let dep: agpm_cli::manifest::ResourceDependency = todo!();
447/// let path = resolve_install_path(
448///     &manifest,
449///     &dep,
450///     "claude-code",
451///     ResourceType::Agent,
452///     "agents/helper.md"
453/// )?;
454/// # Ok(())
455/// # }
456/// ```
457pub fn resolve_install_path(
458    manifest: &Manifest,
459    dep: &ResourceDependency,
460    artifact_type: &str,
461    resource_type: ResourceType,
462    source_filename: &str,
463) -> Result<String> {
464    match resource_type {
465        ResourceType::Hook | ResourceType::McpServer => {
466            Ok(resolve_merge_target_path(manifest, artifact_type, resource_type))
467        }
468        _ => resolve_regular_resource_path(
469            manifest,
470            dep,
471            artifact_type,
472            resource_type,
473            source_filename,
474        ),
475    }
476}
477
478/// Resolves the installation path for merge-target resources (Hook, McpServer).
479///
480/// These resources are not installed as files but are merged into configuration files.
481/// Uses configured merge targets or falls back to hardcoded defaults.
482///
483/// # Arguments
484///
485/// * `manifest` - The project manifest containing tool configurations
486/// * `artifact_type` - The tool name (e.g., "claude-code", "opencode")
487/// * `resource_type` - Must be Hook or McpServer
488///
489/// # Returns
490///
491/// The normalized path to the merge target configuration file.
492pub fn resolve_merge_target_path(
493    manifest: &Manifest,
494    artifact_type: &str,
495    resource_type: ResourceType,
496) -> String {
497    if let Some(merge_target) = manifest.get_merge_target(artifact_type, resource_type) {
498        normalize_path_for_storage(merge_target.display().to_string())
499    } else {
500        // Fallback to hardcoded defaults if not configured
501        match resource_type {
502            ResourceType::Hook => ".claude/settings.local.json".to_string(),
503            ResourceType::McpServer => {
504                if artifact_type == "opencode" {
505                    ".opencode/opencode.json".to_string()
506                } else {
507                    ".mcp.json".to_string()
508                }
509            }
510            _ => unreachable!(
511                "resolve_merge_target_path should only be called for Hook or McpServer"
512            ),
513        }
514    }
515}
516
517/// Resolves the installation path for regular file-based resources.
518///
519/// Handles agents, commands, snippets, and scripts by:
520/// 1. Getting the base artifact path from tool configuration
521/// 2. Applying custom target overrides if specified
522/// 3. Computing the relative path based on flatten behavior
523/// 4. Avoiding redundant directory prefixes
524///
525/// # Arguments
526///
527/// * `manifest` - The project manifest containing tool configurations
528/// * `dep` - The resource dependency specification
529/// * `artifact_type` - The tool name (e.g., "claude-code", "opencode")
530/// * `resource_type` - The resource type (Agent, Command, Snippet, Script)
531/// * `source_filename` - The filename/path from the source repository
532///
533/// # Returns
534///
535/// The normalized installation path, or an error if the resource type is not supported.
536///
537/// # Errors
538///
539/// Returns an error if the resource type is not supported by the specified tool.
540pub fn resolve_regular_resource_path(
541    manifest: &Manifest,
542    dep: &ResourceDependency,
543    artifact_type: &str,
544    resource_type: ResourceType,
545    source_filename: &str,
546) -> Result<String> {
547    // Get the artifact path for this resource type
548    let artifact_path =
549        manifest.get_artifact_resource_path(artifact_type, resource_type).ok_or_else(|| {
550            create_unsupported_resource_error(artifact_type, resource_type, dep.get_path())
551        })?;
552
553    // Compute the final path
554    let path = if let Some(custom_target) = dep.get_target() {
555        compute_custom_target_path(
556            &artifact_path,
557            custom_target,
558            source_filename,
559            dep,
560            manifest,
561            artifact_type,
562            resource_type,
563        )
564    } else {
565        compute_default_path(
566            &artifact_path,
567            source_filename,
568            dep,
569            manifest,
570            artifact_type,
571            resource_type,
572        )
573    };
574
575    Ok(normalize_path_for_storage(normalize_path(&path)))
576}
577
578/// Computes the installation path when a custom target directory is specified.
579///
580/// Custom targets are relative to the artifact's resource directory. The function
581/// uses the original artifact path (not the custom target) for prefix stripping
582/// to avoid duplicate directories.
583fn compute_custom_target_path(
584    artifact_path: &Path,
585    custom_target: &str,
586    source_filename: &str,
587    dep: &ResourceDependency,
588    manifest: &Manifest,
589    artifact_type: &str,
590    resource_type: ResourceType,
591) -> PathBuf {
592    let flatten = get_flatten_behavior(manifest, dep, artifact_type, resource_type);
593    let base_target = PathBuf::from(artifact_path.display().to_string())
594        .join(custom_target.trim_start_matches('/'));
595    // For custom targets, still strip prefix based on the original artifact path
596    let relative_path =
597        compute_relative_install_path(artifact_path, Path::new(source_filename), flatten);
598    base_target.join(relative_path)
599}
600
601/// Computes the installation path using the default artifact path.
602fn compute_default_path(
603    artifact_path: &Path,
604    source_filename: &str,
605    dep: &ResourceDependency,
606    manifest: &Manifest,
607    artifact_type: &str,
608    resource_type: ResourceType,
609) -> PathBuf {
610    let flatten = get_flatten_behavior(manifest, dep, artifact_type, resource_type);
611    let relative_path =
612        compute_relative_install_path(artifact_path, Path::new(source_filename), flatten);
613    artifact_path.join(relative_path)
614}
615
616/// Creates a detailed error message when a resource type is not supported by a tool.
617///
618/// Provides helpful hints if it looks like a tool name was used as a resource type.
619fn create_unsupported_resource_error(
620    artifact_type: &str,
621    resource_type: ResourceType,
622    source_path: &str,
623) -> anyhow::Error {
624    let base_msg =
625        format!("Resource type '{}' is not supported by tool '{}'", resource_type, artifact_type);
626
627    let resource_type_str = resource_type.to_string();
628    let hint = if ["claude-code", "opencode", "agpm"].contains(&resource_type_str.as_str()) {
629        format!(
630            "\n\nIt looks like '{}' is a tool name, not a resource type.\n\
631            In transitive dependencies, use resource types (agents, snippets, commands)\n\
632            as section headers, then specify 'tool: {}' within each dependency.",
633            resource_type_str, resource_type_str
634        )
635    } else {
636        format!(
637            "\n\nValid resource types: agent, command, snippet, hook, mcp-server, script\n\
638            Source file: {}",
639            source_path
640        )
641    };
642
643    anyhow::anyhow!("{}{}", base_msg, hint)
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    #[test]
651    fn test_parse_pattern_base_path_simple() {
652        let (base, pattern) = parse_pattern_base_path("*.md");
653        assert_eq!(base, PathBuf::from("."));
654        assert_eq!(pattern, "*.md");
655    }
656
657    #[test]
658    fn test_parse_pattern_base_path_with_directory() {
659        let (base, pattern) = parse_pattern_base_path("agents/*.md");
660        assert_eq!(base, PathBuf::from("."));
661        assert_eq!(pattern, "agents/*.md");
662    }
663
664    #[test]
665    fn test_parse_pattern_base_path_with_parent() {
666        let (base, pattern) = parse_pattern_base_path("../foo/*.md");
667        assert_eq!(base, PathBuf::from("../foo"));
668        assert_eq!(pattern, "*.md");
669    }
670
671    #[test]
672    fn test_parse_pattern_base_path_with_current_dir() {
673        let (base, pattern) = parse_pattern_base_path("./foo/*.md");
674        assert_eq!(base, PathBuf::from("./foo"));
675        assert_eq!(pattern, "*.md");
676    }
677
678    #[test]
679    fn test_construct_full_relative_path_current_dir() {
680        let base = PathBuf::from(".");
681        let matched = Path::new("agents/helper.md");
682        let path = construct_full_relative_path(&base, matched);
683        assert_eq!(path, "agents/helper.md");
684    }
685
686    #[test]
687    fn test_construct_full_relative_path_with_base() {
688        let base = PathBuf::from("../foo");
689        let matched = Path::new("bar.md");
690        let path = construct_full_relative_path(&base, matched);
691        assert_eq!(path, "../foo/bar.md");
692    }
693
694    #[test]
695    fn test_extract_pattern_filename_current_dir() {
696        let base = PathBuf::from(".");
697        let matched = Path::new("agents/helper.md");
698        let filename = extract_pattern_filename(&base, matched);
699        assert_eq!(filename, "agents/helper.md");
700    }
701}