agpm_cli/lockfile/
lockfile_dependency_ref.rs

1//! Lockfile dependency reference handling.
2//!
3//! This module provides a structured way to parse and format the dependency references
4//! that appear in the lockfile's `dependencies` arrays. These use a specific compact format
5//! designed for lockfile serialization.
6
7use anyhow::{Result, bail};
8use std::fmt;
9use std::str::FromStr;
10
11use crate::core::ResourceType;
12
13/// A structured representation of a lockfile dependency reference.
14///
15/// This type represents dependencies as they appear in `agpm.lock` files.
16/// The format is compact and designed for lockfile serialization.
17///
18/// Supports the following formats:
19/// - Local: `<type>:<path>` (e.g., `snippet:snippets/commands/update-docstrings`)
20/// - Git: `<source>/<type>:<path>@<version>` (e.g., `agpm-resources/snippet:snippets/commands/update-docstrings@v0.0.1`)
21///
22/// Examples:
23/// ```
24/// use agpm_cli::lockfile::lockfile_dependency_ref::LockfileDependencyRef;
25/// use agpm_cli::core::ResourceType;
26/// use std::str::FromStr;
27///
28/// let local_dep = LockfileDependencyRef::from_str("snippet:snippets/commands/update-docstrings").unwrap();
29/// assert_eq!(local_dep.source, None);
30/// assert_eq!(local_dep.resource_type, ResourceType::Snippet);
31/// assert_eq!(local_dep.path, "snippets/commands/update-docstrings");
32/// assert_eq!(local_dep.version, None);
33///
34/// let git_dep = LockfileDependencyRef::from_str("agpm-resources/snippet:snippets/commands/update-docstrings@v0.0.1").unwrap();
35/// assert_eq!(git_dep.source, Some("agpm-resources".to_string()));
36/// assert_eq!(git_dep.resource_type, ResourceType::Snippet);
37/// assert_eq!(git_dep.path, "snippets/commands/update-docstrings");
38/// assert_eq!(git_dep.version, Some("v0.0.1".to_string()));
39/// ```
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct LockfileDependencyRef {
42    /// Optional source name (e.g., "agpm-resources")
43    pub source: Option<String>,
44    /// Resource type (agent, snippet, command, etc.)
45    pub resource_type: ResourceType,
46    /// Path within the source repository (e.g., "snippets/commands/update-docstrings")
47    pub path: String,
48    /// Optional version constraint (e.g., "v0.0.1")
49    pub version: Option<String>,
50}
51
52impl LockfileDependencyRef {
53    /// Create a new lockfile dependency reference.
54    pub fn new(
55        source: Option<String>,
56        resource_type: ResourceType,
57        path: String,
58        version: Option<String>,
59    ) -> Self {
60        Self {
61            source,
62            resource_type,
63            path,
64            version,
65        }
66    }
67
68    /// Create a local dependency reference (no source).
69    pub fn local(resource_type: ResourceType, path: String, version: Option<String>) -> Self {
70        Self {
71            source: None,
72            resource_type,
73            path,
74            version,
75        }
76    }
77
78    /// Create a Git dependency reference with source.
79    pub fn git(
80        source: String,
81        resource_type: ResourceType,
82        path: String,
83        version: Option<String>,
84    ) -> Self {
85        Self {
86            source: Some(source),
87            resource_type,
88            path,
89            version,
90        }
91    }
92}
93
94impl FromStr for LockfileDependencyRef {
95    type Err = anyhow::Error;
96
97    fn from_str(s: &str) -> Result<Self> {
98        // Parse version first (if present)
99        let (base_part, version) = if let Some(at_pos) = s.rfind('@') {
100            let base = &s[..at_pos];
101            let version = Some(s[at_pos + 1..].to_string());
102            (base, version)
103        } else {
104            (s, None)
105        };
106
107        // Determine if this is a Git dependency (has / before the first :) or Local dependency
108        let first_colon_pos = base_part.find(':').unwrap_or(0);
109        let has_slash_before_colon = if let Some(slash_pos) = base_part.find('/') {
110            slash_pos < first_colon_pos
111        } else {
112            false
113        };
114        let is_git = has_slash_before_colon;
115
116        let (source, type_path_part) = if is_git {
117            if let Some(slash_pos) = base_part.find('/') {
118                let source_part = &base_part[..slash_pos];
119                let rest = &base_part[slash_pos + 1..];
120                (Some(source_part.to_string()), rest)
121            } else {
122                bail!("Git dependency format requires / separator: {}", s);
123            }
124        } else {
125            (None, base_part)
126        };
127
128        // Parse type and path
129        if let Some(colon_pos) = type_path_part.find(':') {
130            let type_part = &type_path_part[..colon_pos];
131            let path_part = &type_path_part[colon_pos + 1..];
132
133            // Parse resource type
134            let resource_type = type_part.parse::<ResourceType>()?;
135
136            if path_part.is_empty() {
137                bail!("Dependency path cannot be empty in: {}", s);
138            }
139
140            Ok(Self {
141                source,
142                resource_type,
143                path: path_part.to_string(),
144                version,
145            })
146        } else {
147            bail!(
148                "Invalid dependency reference format: {}. Expected format: <type>:<path> or <source>/<type>:<path>",
149                s
150            );
151        }
152    }
153}
154
155impl fmt::Display for LockfileDependencyRef {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        // Always use forward slashes for lockfile storage (cross-platform compatibility)
158        let normalized_path = crate::utils::normalize_path_for_storage(&self.path);
159
160        match &self.source {
161            Some(source) => {
162                // Git dependency: source/type:path@version
163                write!(f, "{}/{}:{}", source, self.resource_type, normalized_path)?;
164                if let Some(version) = &self.version {
165                    write!(f, "@{}", version)?;
166                }
167                Ok(())
168            }
169            None => {
170                // Local dependency: type:path@version
171                write!(f, "{}:{}", self.resource_type, normalized_path)?;
172                if let Some(version) = &self.version {
173                    write!(f, "@{}", version)?;
174                }
175                Ok(())
176            }
177        }
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_parse_local_dependency_no_version() {
187        let dep =
188            LockfileDependencyRef::from_str("snippet:snippets/commands/update-docstrings").unwrap();
189        assert_eq!(dep.source, None);
190        assert_eq!(dep.resource_type, ResourceType::Snippet);
191        assert_eq!(dep.path, "snippets/commands/update-docstrings");
192        assert_eq!(dep.version, None);
193    }
194
195    #[test]
196    fn test_parse_local_dependency_with_version() {
197        let dep =
198            LockfileDependencyRef::from_str("snippet:snippets/commands/update-docstrings@v0.0.1")
199                .unwrap();
200        assert_eq!(dep.source, None);
201        assert_eq!(dep.resource_type, ResourceType::Snippet);
202        assert_eq!(dep.path, "snippets/commands/update-docstrings");
203        assert_eq!(dep.version, Some("v0.0.1".to_string()));
204    }
205
206    #[test]
207    fn test_parse_git_dependency_no_version() {
208        let dep = LockfileDependencyRef::from_str(
209            "agpm-resources/snippet:snippets/commands/update-docstrings",
210        )
211        .unwrap();
212        assert_eq!(dep.source, Some("agpm-resources".to_string()));
213        assert_eq!(dep.resource_type, ResourceType::Snippet);
214        assert_eq!(dep.path, "snippets/commands/update-docstrings");
215        assert_eq!(dep.version, None);
216    }
217
218    #[test]
219    fn test_parse_git_dependency_with_version() {
220        let dep = LockfileDependencyRef::from_str(
221            "agpm-resources/snippet:snippets/commands/update-docstrings@v0.0.1",
222        )
223        .unwrap();
224        assert_eq!(dep.source, Some("agpm-resources".to_string()));
225        assert_eq!(dep.resource_type, ResourceType::Snippet);
226        assert_eq!(dep.path, "snippets/commands/update-docstrings");
227        assert_eq!(dep.version, Some("v0.0.1".to_string()));
228    }
229
230    #[test]
231    fn test_parse_invalid_format() {
232        let result = LockfileDependencyRef::from_str("invalid-format");
233        assert!(result.is_err());
234    }
235
236    #[test]
237    fn test_parse_empty_path() {
238        let result = LockfileDependencyRef::from_str("snippet:");
239        assert!(result.is_err());
240    }
241
242    #[test]
243    fn test_display_local_dependency() {
244        let dep = LockfileDependencyRef::local(
245            ResourceType::Snippet,
246            "snippets/commands/update-docstrings".to_string(),
247            Some("v0.0.1".to_string()),
248        );
249        assert_eq!(dep.to_string(), "snippet:snippets/commands/update-docstrings@v0.0.1");
250    }
251
252    #[test]
253    fn test_display_local_dependency_no_version() {
254        let dep = LockfileDependencyRef::local(
255            ResourceType::Snippet,
256            "snippets/commands/update-docstrings".to_string(),
257            None,
258        );
259        assert_eq!(dep.to_string(), "snippet:snippets/commands/update-docstrings");
260    }
261
262    #[test]
263    fn test_display_git_dependency() {
264        let dep = LockfileDependencyRef::git(
265            "agpm-resources".to_string(),
266            ResourceType::Snippet,
267            "snippets/commands/update-docstrings".to_string(),
268            Some("v0.0.1".to_string()),
269        );
270        assert_eq!(
271            dep.to_string(),
272            "agpm-resources/snippet:snippets/commands/update-docstrings@v0.0.1"
273        );
274    }
275
276    #[test]
277    fn test_display_git_dependency_no_version() {
278        let dep = LockfileDependencyRef::git(
279            "agpm-resources".to_string(),
280            ResourceType::Snippet,
281            "snippets/commands/update-docstrings".to_string(),
282            None,
283        );
284        assert_eq!(dep.to_string(), "agpm-resources/snippet:snippets/commands/update-docstrings");
285    }
286
287    #[test]
288    fn test_roundtrip_conversion() {
289        let original = "agpm-resources/snippet:snippets/commands/update-docstrings@v0.0.1";
290        let parsed = LockfileDependencyRef::from_str(original).unwrap();
291        assert_eq!(parsed.to_string(), original);
292    }
293}