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}