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) => {
81            // For Git dependencies, check if path is already relative
82            // (Pattern expansion returns relative paths from PatternResolver)
83            // Only compute relative path if the input is absolute
84            if without_ext.is_absolute() {
85                compute_relative_to_repo(&without_ext, repo_root)
86            } else {
87                // Path is already relative to repo root, just normalize it
88                normalize_path_for_storage(&without_ext)
89            }
90        }
91        SourceContext::Remote(_source_name) => {
92            // For remote sources, use full path relative to repository root
93            // This preserves the directory structure (e.g., "agents/helper.md" -> "agents/helper")
94            // Uniqueness is ensured by (name, source) tuple
95            normalize_path_for_storage(&without_ext)
96        }
97    }
98}
99
100/// Create appropriate source context for a resource dependency.
101///
102/// This function determines the correct source context based on the available
103/// information in the dependency and manifest context.
104pub fn create_source_context_for_dependency(
105    dep: &ResourceDependency,
106    manifest_dir: Option<&Path>,
107    repo_root: Option<&Path>,
108) -> SourceContext {
109    // Priority: Git context > Local context > Remote context
110    if let Some(source_name) = dep.get_source() {
111        // Git-backed dependency
112        if let Some(repo_root) = repo_root {
113            SourceContext::git(repo_root)
114        } else {
115            // Fallback to remote context when repo root is not available
116            SourceContext::remote(source_name)
117        }
118    } else if let Some(manifest_dir) = manifest_dir {
119        // Local dependency
120        SourceContext::local(manifest_dir)
121    } else {
122        // Last resort - use remote context with "unknown" source
123        SourceContext::remote("unknown")
124    }
125}
126
127/// Create source context from a locked resource.
128///
129/// This function determines the correct source context from a LockedResource.
130pub fn create_source_context_from_locked_resource(
131    resource: &crate::lockfile::LockedResource,
132    manifest_dir: Option<&Path>,
133) -> SourceContext {
134    if let Some(source_name) = &resource.source {
135        // Remote resource - use source name context
136        SourceContext::remote(source_name.clone())
137    } else {
138        // Local resource - use manifest directory if available
139        if let Some(manifest_dir) = manifest_dir {
140            SourceContext::local(manifest_dir)
141        } else {
142            // Fallback - use local context with current directory
143            SourceContext::local(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
144        }
145    }
146}
147
148/// Compute relative path from Git repository root to the dependency file.
149///
150/// For Git dependencies, we want to preserve the repository structure.
151fn compute_relative_to_repo(file_path: &Path, repo_root: &Path) -> String {
152    // Use the existing utility function which properly handles Path operations
153    compute_relative_path(repo_root, file_path)
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    use std::path::Path;
161
162    #[test]
163    fn test_source_context_creation() {
164        let local = SourceContext::local("/project");
165        assert!(local.is_local());
166        assert!(!local.is_git());
167        assert!(!local.is_remote());
168
169        let git = SourceContext::git("/repo");
170        assert!(!git.is_local());
171        assert!(git.is_git());
172        assert!(!git.is_remote());
173
174        let remote = SourceContext::remote("community");
175        assert!(!remote.is_local());
176        assert!(!remote.is_git());
177        assert!(remote.is_remote());
178    }
179
180    #[test]
181    fn test_compute_canonical_name_integration() {
182        // Use platform-appropriate absolute paths for tests
183        #[cfg(windows)]
184        let (project_dir, repo_dir) = ("C:\\project", "C:\\repo");
185        #[cfg(not(windows))]
186        let (project_dir, repo_dir) = ("/project", "/repo");
187
188        // Local context - the path is relative to manifest directory
189        let local_ctx = SourceContext::local(project_dir);
190        #[cfg(windows)]
191        let local_path = "C:\\project\\local-deps\\agents\\helper.md";
192        #[cfg(not(windows))]
193        let local_path = "/project/local-deps/agents/helper.md";
194        let name = compute_canonical_name(local_path, &local_ctx);
195        assert_eq!(name, "local-deps/agents/helper");
196
197        // Git context with absolute path
198        let git_ctx = SourceContext::git(repo_dir);
199        #[cfg(windows)]
200        let git_path = "C:\\repo\\agents\\helper.md";
201        #[cfg(not(windows))]
202        let git_path = "/repo/agents/helper.md";
203        let name = compute_canonical_name(git_path, &git_ctx);
204        assert_eq!(name, "agents/helper");
205
206        // Remote context - preserves full repo-relative path
207        let remote_ctx = SourceContext::remote("community");
208        let name = compute_canonical_name("agents/helper.md", &remote_ctx);
209        assert_eq!(name, "agents/helper");
210    }
211
212    #[test]
213    fn test_compute_canonical_name_with_already_relative_path() {
214        // Regression test for Windows bug where relative paths were being passed to
215        // compute_relative_path, causing it to generate incorrect paths with ../../..
216        //
217        // When trans_dep.get_path() returns an already-relative path like
218        // "local-deps/snippets/agents/helper.md", it should not be passed through
219        // compute_relative_path again, as that function expects absolute paths.
220        let local_ctx = SourceContext::local("/project");
221
222        // Test with a relative path (like what trans_dep.get_path() returns)
223        let name = compute_canonical_name("local-deps/snippets/agents/helper.md", &local_ctx);
224        assert_eq!(name, "local-deps/snippets/agents/helper");
225
226        // Verify it doesn't generate paths with ../../../
227        assert!(!name.contains(".."), "Generated name should not contain '..' sequences");
228
229        // Test with nested relative path
230        let name = compute_canonical_name("local-deps/claude/agents/rust-expert.md", &local_ctx);
231        assert_eq!(name, "local-deps/claude/agents/rust-expert");
232        assert!(!name.contains(".."));
233    }
234
235    #[test]
236    fn test_compute_canonical_name_git_context_with_relative_path() {
237        // Regression test for glob-expanded transitive deps bug
238        // When PatternResolver returns relative paths for Git repos,
239        // compute_canonical_name should handle them correctly
240        #[cfg(windows)]
241        let repo_root = "C:\\Users\\x\\.agpm\\cache\\worktrees\\repo_abc";
242        #[cfg(not(windows))]
243        let repo_root = "/Users/x/.agpm/cache/worktrees/repo_abc";
244
245        let git_ctx = SourceContext::git(repo_root);
246
247        // Relative path (like what PatternResolver returns for matched files)
248        let name = compute_canonical_name("cc-artifacts/agents/specialists/helper.md", &git_ctx);
249        assert_eq!(name, "cc-artifacts/agents/specialists/helper");
250        assert!(!name.contains(".."), "Generated name should not contain '..' sequences");
251
252        // Absolute path should also work
253        #[cfg(windows)]
254        let abs_path = "C:\\Users\\x\\.agpm\\cache\\worktrees\\repo_abc\\agents\\helper.md";
255        #[cfg(not(windows))]
256        let abs_path = "/Users/x/.agpm/cache/worktrees/repo_abc/agents/helper.md";
257
258        let name = compute_canonical_name(abs_path, &git_ctx);
259        assert_eq!(name, "agents/helper");
260    }
261
262    // Tests for helper functions
263    #[test]
264    fn test_create_source_context_for_dependency() {
265        use crate::manifest::{DetailedDependency, ResourceDependency};
266
267        // Local dependency
268        let local_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
269            path: "agents/helper.md".to_string(),
270            source: None,
271            version: None,
272            branch: None,
273            rev: None,
274            command: None,
275            args: None,
276            target: None,
277            filename: None,
278            dependencies: None,
279            tool: None,
280            flatten: None,
281            install: None,
282            template_vars: None,
283        }));
284
285        let manifest_dir = Path::new("/project");
286        let ctx = create_source_context_for_dependency(&local_dep, Some(manifest_dir), None);
287        assert!(ctx.is_local());
288
289        // Git dependency with repo root
290        let git_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
291            path: "agents/helper.md".to_string(),
292            source: Some("community".to_string()),
293            version: None,
294            branch: None,
295            rev: None,
296            command: None,
297            args: None,
298            target: None,
299            filename: None,
300            dependencies: None,
301            tool: None,
302            flatten: None,
303            install: None,
304            template_vars: None,
305        }));
306
307        let repo_root = Path::new("/repo");
308        let ctx =
309            create_source_context_for_dependency(&git_dep, Some(manifest_dir), Some(repo_root));
310        assert!(ctx.is_git());
311
312        // Git dependency without repo root (fallback to remote)
313        let ctx = create_source_context_for_dependency(&git_dep, Some(manifest_dir), None);
314        assert!(ctx.is_remote());
315    }
316
317    #[test]
318    fn test_create_source_context_from_locked_resource() {
319        use crate::lockfile::LockedResource;
320
321        // Local resource
322        let local_resource = LockedResource {
323            name: "helper".to_string(),
324            source: None,
325            url: None,
326            path: "agents/helper.md".to_string(),
327            version: None,
328            resolved_commit: None,
329            checksum: "abc123".to_string(),
330            installed_at: "agents/helper.md".to_string(),
331            dependencies: vec![],
332            resource_type: crate::core::ResourceType::Agent,
333            tool: Some("claude-code".to_string()),
334            manifest_alias: None,
335            context_checksum: None,
336            applied_patches: std::collections::BTreeMap::new(),
337            install: None,
338            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
339            is_private: false,
340            approximate_token_count: None,
341        };
342
343        let manifest_dir = Path::new("/project");
344        let ctx = create_source_context_from_locked_resource(&local_resource, Some(manifest_dir));
345        assert!(ctx.is_local());
346
347        // Remote resource
348        let mut remote_resource = local_resource.clone();
349        remote_resource.source = Some("community".to_string());
350
351        let ctx = create_source_context_from_locked_resource(&remote_resource, Some(manifest_dir));
352        assert!(ctx.is_remote());
353        assert_eq!(format!("{:?}", ctx), "Remote(\"community\")");
354    }
355}