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, compute relative path from manifest directory
74            let relative = compute_relative_path(manifest_dir, &without_ext);
75            normalize_path_for_storage(relative)
76        }
77        SourceContext::Git(repo_root) => compute_relative_to_repo(&without_ext, repo_root),
78        SourceContext::Remote(_source_name) => {
79            // For remote sources, use full path relative to repository root
80            // This preserves the directory structure (e.g., "agents/helper.md" -> "agents/helper")
81            // Uniqueness is ensured by (name, source) tuple
82            normalize_path_for_storage(&without_ext)
83        }
84    }
85}
86
87/// Create appropriate source context for a resource dependency.
88///
89/// This function determines the correct source context based on the available
90/// information in the dependency and manifest context.
91pub fn create_source_context_for_dependency(
92    dep: &ResourceDependency,
93    manifest_dir: Option<&Path>,
94    repo_root: Option<&Path>,
95) -> SourceContext {
96    // Priority: Git context > Local context > Remote context
97    if let Some(source_name) = dep.get_source() {
98        // Git-backed dependency
99        if let Some(repo_root) = repo_root {
100            SourceContext::git(repo_root)
101        } else {
102            // Fallback to remote context when repo root is not available
103            SourceContext::remote(source_name)
104        }
105    } else if let Some(manifest_dir) = manifest_dir {
106        // Local dependency
107        SourceContext::local(manifest_dir)
108    } else {
109        // Last resort - use remote context with "unknown" source
110        SourceContext::remote("unknown")
111    }
112}
113
114/// Create source context from a locked resource.
115///
116/// This function determines the correct source context from a LockedResource.
117pub fn create_source_context_from_locked_resource(
118    resource: &crate::lockfile::LockedResource,
119    manifest_dir: Option<&Path>,
120) -> SourceContext {
121    if let Some(source_name) = &resource.source {
122        // Remote resource - use source name context
123        SourceContext::remote(source_name.clone())
124    } else {
125        // Local resource - use manifest directory if available
126        if let Some(manifest_dir) = manifest_dir {
127            SourceContext::local(manifest_dir)
128        } else {
129            // Fallback - use local context with current directory
130            SourceContext::local(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
131        }
132    }
133}
134
135/// Compute relative path from Git repository root to the dependency file.
136///
137/// For Git dependencies, we want to preserve the repository structure.
138fn compute_relative_to_repo(file_path: &Path, repo_root: &Path) -> String {
139    // Use the existing utility function which properly handles Path operations
140    compute_relative_path(repo_root, file_path)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    use std::path::Path;
148
149    #[test]
150    fn test_source_context_creation() {
151        let local = SourceContext::local("/project");
152        assert!(local.is_local());
153        assert!(!local.is_git());
154        assert!(!local.is_remote());
155
156        let git = SourceContext::git("/repo");
157        assert!(!git.is_local());
158        assert!(git.is_git());
159        assert!(!git.is_remote());
160
161        let remote = SourceContext::remote("community");
162        assert!(!remote.is_local());
163        assert!(!remote.is_git());
164        assert!(remote.is_remote());
165    }
166
167    #[test]
168    fn test_compute_canonical_name_integration() {
169        // Local context - the path is relative to manifest directory
170        let local_ctx = SourceContext::local("/project");
171        let name = compute_canonical_name("/project/local-deps/agents/helper.md", &local_ctx);
172        assert_eq!(name, "local-deps/agents/helper");
173
174        // Git context
175        let git_ctx = SourceContext::git("/repo");
176        let name = compute_canonical_name("/repo/agents/helper.md", &git_ctx);
177        assert_eq!(name, "agents/helper");
178
179        // Remote context - preserves full repo-relative path
180        let remote_ctx = SourceContext::remote("community");
181        let name = compute_canonical_name("agents/helper.md", &remote_ctx);
182        assert_eq!(name, "agents/helper");
183    }
184
185    // Tests for helper functions
186    #[test]
187    fn test_create_source_context_for_dependency() {
188        use crate::manifest::{DetailedDependency, ResourceDependency};
189
190        // Local dependency
191        let local_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
192            path: "agents/helper.md".to_string(),
193            source: None,
194            version: None,
195            branch: None,
196            rev: None,
197            command: None,
198            args: None,
199            target: None,
200            filename: None,
201            dependencies: None,
202            tool: None,
203            flatten: None,
204            install: None,
205            template_vars: None,
206        }));
207
208        let manifest_dir = Path::new("/project");
209        let ctx = create_source_context_for_dependency(&local_dep, Some(manifest_dir), None);
210        assert!(ctx.is_local());
211
212        // Git dependency with repo root
213        let git_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
214            path: "agents/helper.md".to_string(),
215            source: Some("community".to_string()),
216            version: None,
217            branch: None,
218            rev: None,
219            command: None,
220            args: None,
221            target: None,
222            filename: None,
223            dependencies: None,
224            tool: None,
225            flatten: None,
226            install: None,
227            template_vars: None,
228        }));
229
230        let repo_root = Path::new("/repo");
231        let ctx =
232            create_source_context_for_dependency(&git_dep, Some(manifest_dir), Some(repo_root));
233        assert!(ctx.is_git());
234
235        // Git dependency without repo root (fallback to remote)
236        let ctx = create_source_context_for_dependency(&git_dep, Some(manifest_dir), None);
237        assert!(ctx.is_remote());
238    }
239
240    #[test]
241    fn test_create_source_context_from_locked_resource() {
242        use crate::lockfile::LockedResource;
243
244        // Local resource
245        let local_resource = LockedResource {
246            name: "helper".to_string(),
247            source: None,
248            url: None,
249            path: "agents/helper.md".to_string(),
250            version: None,
251            resolved_commit: None,
252            checksum: "abc123".to_string(),
253            installed_at: "agents/helper.md".to_string(),
254            dependencies: vec![],
255            resource_type: crate::core::ResourceType::Agent,
256            tool: Some("claude-code".to_string()),
257            manifest_alias: None,
258            context_checksum: None,
259            applied_patches: std::collections::BTreeMap::new(),
260            install: None,
261            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
262        };
263
264        let manifest_dir = Path::new("/project");
265        let ctx = create_source_context_from_locked_resource(&local_resource, Some(manifest_dir));
266        assert!(ctx.is_local());
267
268        // Remote resource
269        let mut remote_resource = local_resource.clone();
270        remote_resource.source = Some("community".to_string());
271
272        let ctx = create_source_context_from_locked_resource(&remote_resource, Some(manifest_dir));
273        assert!(ctx.is_remote());
274        assert_eq!(format!("{:?}", ctx), "Remote(\"community\")");
275    }
276}