agpm_cli/resolver/
source_context.rs

1//! Source context for dependency name generation.
2//!
3//! This module provides types and utilities for handling different source contexts
4//! when generating canonical names for dependencies. It distinguishes between
5//! local filesystem paths, Git repositories, and other remote sources.
6
7use crate::manifest::ResourceDependency;
8use crate::utils::{compute_relative_path, normalize_path_for_storage};
9use std::path::{Path, PathBuf};
10
11/// Context for determining how to generate canonical dependency names.
12///
13/// Different source contexts require different naming strategies:
14/// - Local dependencies use paths relative to the manifest directory
15/// - Git dependencies use paths relative to the repository root
16/// - Remote dependencies may have other naming conventions
17#[derive(Debug, Clone)]
18pub enum SourceContext {
19    /// Local filesystem dependency relative to manifest directory
20    Local(PathBuf),
21    /// Git repository dependency with repository root path
22    Git(PathBuf),
23    /// Remote source with source name (for backward compatibility)
24    Remote(String),
25}
26
27impl SourceContext {
28    /// Create a local source context from a manifest directory path
29    pub fn local(manifest_dir: impl Into<PathBuf>) -> Self {
30        Self::Local(manifest_dir.into())
31    }
32
33    /// Create a Git source context from a repository root path
34    pub fn git(repo_root: impl Into<PathBuf>) -> Self {
35        Self::Git(repo_root.into())
36    }
37
38    /// Create a remote source context from a source name
39    pub fn remote(source_name: impl Into<String>) -> Self {
40        Self::Remote(source_name.into())
41    }
42
43    /// Check if this context represents a local source
44    pub fn is_local(&self) -> bool {
45        matches!(self, Self::Local(_))
46    }
47
48    /// Check if this context represents a Git source
49    pub fn is_git(&self) -> bool {
50        matches!(self, Self::Git(_))
51    }
52
53    /// Check if this context represents a remote source
54    pub fn is_remote(&self) -> bool {
55        matches!(self, Self::Remote(_))
56    }
57}
58
59/// Compute a canonical dependency name relative to the appropriate source base.
60///
61/// This function generates canonical names based on the source context:
62/// - Local: paths relative to manifest directory
63/// - Git: paths relative to repository root
64/// - Remote: paths relative to source name (for backward compatibility)
65pub fn compute_canonical_name(path: &str, source_context: &SourceContext) -> String {
66    let path = Path::new(path);
67
68    // Remove file extension
69    let without_ext = path.with_extension("");
70
71    match source_context {
72        SourceContext::Local(manifest_dir) => {
73            // For local dependencies, try to strip the manifest directory prefix
74            // If it strips successfully, the path was absolute (or rooted)
75            // If it fails, the path is already relative
76            let manifest_path = Path::new(manifest_dir);
77            let relative = without_ext.strip_prefix(manifest_path).unwrap_or(&without_ext);
78            normalize_path_for_storage(relative)
79        }
80        SourceContext::Git(repo_root) => compute_relative_to_repo(&without_ext, repo_root),
81        SourceContext::Remote(_source_name) => {
82            // For remote sources, use full path relative to repository root
83            // This preserves the directory structure (e.g., "agents/helper.md" -> "agents/helper")
84            // Uniqueness is ensured by (name, source) tuple
85            normalize_path_for_storage(&without_ext)
86        }
87    }
88}
89
90/// Create appropriate source context for a resource dependency.
91///
92/// This function determines the correct source context based on the available
93/// information in the dependency and manifest context.
94pub fn create_source_context_for_dependency(
95    dep: &ResourceDependency,
96    manifest_dir: Option<&Path>,
97    repo_root: Option<&Path>,
98) -> SourceContext {
99    // Priority: Git context > Local context > Remote context
100    if let Some(source_name) = dep.get_source() {
101        // Git-backed dependency
102        if let Some(repo_root) = repo_root {
103            SourceContext::git(repo_root)
104        } else {
105            // Fallback to remote context when repo root is not available
106            SourceContext::remote(source_name)
107        }
108    } else if let Some(manifest_dir) = manifest_dir {
109        // Local dependency
110        SourceContext::local(manifest_dir)
111    } else {
112        // Last resort - use remote context with "unknown" source
113        SourceContext::remote("unknown")
114    }
115}
116
117/// Create source context from a locked resource.
118///
119/// This function determines the correct source context from a LockedResource.
120pub fn create_source_context_from_locked_resource(
121    resource: &crate::lockfile::LockedResource,
122    manifest_dir: Option<&Path>,
123) -> SourceContext {
124    if let Some(source_name) = &resource.source {
125        // Remote resource - use source name context
126        SourceContext::remote(source_name.clone())
127    } else {
128        // Local resource - use manifest directory if available
129        if let Some(manifest_dir) = manifest_dir {
130            SourceContext::local(manifest_dir)
131        } else {
132            // Fallback - use local context with current directory
133            SourceContext::local(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
134        }
135    }
136}
137
138/// Compute relative path from Git repository root to the dependency file.
139///
140/// For Git dependencies, we want to preserve the repository structure.
141fn compute_relative_to_repo(file_path: &Path, repo_root: &Path) -> String {
142    // Use the existing utility function which properly handles Path operations
143    compute_relative_path(repo_root, file_path)
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    use std::path::Path;
151
152    #[test]
153    fn test_source_context_creation() {
154        let local = SourceContext::local("/project");
155        assert!(local.is_local());
156        assert!(!local.is_git());
157        assert!(!local.is_remote());
158
159        let git = SourceContext::git("/repo");
160        assert!(!git.is_local());
161        assert!(git.is_git());
162        assert!(!git.is_remote());
163
164        let remote = SourceContext::remote("community");
165        assert!(!remote.is_local());
166        assert!(!remote.is_git());
167        assert!(remote.is_remote());
168    }
169
170    #[test]
171    fn test_compute_canonical_name_integration() {
172        // Local context - the path is relative to manifest directory
173        let local_ctx = SourceContext::local("/project");
174        let name = compute_canonical_name("/project/local-deps/agents/helper.md", &local_ctx);
175        assert_eq!(name, "local-deps/agents/helper");
176
177        // Git context
178        let git_ctx = SourceContext::git("/repo");
179        let name = compute_canonical_name("/repo/agents/helper.md", &git_ctx);
180        assert_eq!(name, "agents/helper");
181
182        // Remote context - preserves full repo-relative path
183        let remote_ctx = SourceContext::remote("community");
184        let name = compute_canonical_name("agents/helper.md", &remote_ctx);
185        assert_eq!(name, "agents/helper");
186    }
187
188    #[test]
189    fn test_compute_canonical_name_with_already_relative_path() {
190        // Regression test for Windows bug where relative paths were being passed to
191        // compute_relative_path, causing it to generate incorrect paths with ../../..
192        //
193        // When trans_dep.get_path() returns an already-relative path like
194        // "local-deps/snippets/agents/helper.md", it should not be passed through
195        // compute_relative_path again, as that function expects absolute paths.
196        let local_ctx = SourceContext::local("/project");
197
198        // Test with a relative path (like what trans_dep.get_path() returns)
199        let name = compute_canonical_name("local-deps/snippets/agents/helper.md", &local_ctx);
200        assert_eq!(name, "local-deps/snippets/agents/helper");
201
202        // Verify it doesn't generate paths with ../../../
203        assert!(!name.contains(".."), "Generated name should not contain '..' sequences");
204
205        // Test with nested relative path
206        let name = compute_canonical_name("local-deps/claude/agents/rust-expert.md", &local_ctx);
207        assert_eq!(name, "local-deps/claude/agents/rust-expert");
208        assert!(!name.contains(".."));
209    }
210
211    // Tests for helper functions
212    #[test]
213    fn test_create_source_context_for_dependency() {
214        use crate::manifest::{DetailedDependency, ResourceDependency};
215
216        // Local dependency
217        let local_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
218            path: "agents/helper.md".to_string(),
219            source: None,
220            version: None,
221            branch: None,
222            rev: None,
223            command: None,
224            args: None,
225            target: None,
226            filename: None,
227            dependencies: None,
228            tool: None,
229            flatten: None,
230            install: None,
231            template_vars: None,
232        }));
233
234        let manifest_dir = Path::new("/project");
235        let ctx = create_source_context_for_dependency(&local_dep, Some(manifest_dir), None);
236        assert!(ctx.is_local());
237
238        // Git dependency with repo root
239        let git_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
240            path: "agents/helper.md".to_string(),
241            source: Some("community".to_string()),
242            version: None,
243            branch: None,
244            rev: None,
245            command: None,
246            args: None,
247            target: None,
248            filename: None,
249            dependencies: None,
250            tool: None,
251            flatten: None,
252            install: None,
253            template_vars: None,
254        }));
255
256        let repo_root = Path::new("/repo");
257        let ctx =
258            create_source_context_for_dependency(&git_dep, Some(manifest_dir), Some(repo_root));
259        assert!(ctx.is_git());
260
261        // Git dependency without repo root (fallback to remote)
262        let ctx = create_source_context_for_dependency(&git_dep, Some(manifest_dir), None);
263        assert!(ctx.is_remote());
264    }
265
266    #[test]
267    fn test_create_source_context_from_locked_resource() {
268        use crate::lockfile::LockedResource;
269
270        // Local resource
271        let local_resource = LockedResource {
272            name: "helper".to_string(),
273            source: None,
274            url: None,
275            path: "agents/helper.md".to_string(),
276            version: None,
277            resolved_commit: None,
278            checksum: "abc123".to_string(),
279            installed_at: "agents/helper.md".to_string(),
280            dependencies: vec![],
281            resource_type: crate::core::ResourceType::Agent,
282            tool: Some("claude-code".to_string()),
283            manifest_alias: None,
284            context_checksum: None,
285            applied_patches: std::collections::BTreeMap::new(),
286            install: None,
287            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
288            is_private: false,
289        };
290
291        let manifest_dir = Path::new("/project");
292        let ctx = create_source_context_from_locked_resource(&local_resource, Some(manifest_dir));
293        assert!(ctx.is_local());
294
295        // Remote resource
296        let mut remote_resource = local_resource.clone();
297        remote_resource.source = Some("community".to_string());
298
299        let ctx = create_source_context_from_locked_resource(&remote_resource, Some(manifest_dir));
300        assert!(ctx.is_remote());
301        assert_eq!(format!("{:?}", ctx), "Remote(\"community\")");
302    }
303}