1use anyhow::{Result, bail};
8use std::fmt;
9use std::str::FromStr;
10
11use crate::core::ResourceType;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct LockfileDependencyRef {
42 pub source: Option<String>,
44 pub resource_type: ResourceType,
46 pub path: String,
48 pub version: Option<String>,
50}
51
52impl LockfileDependencyRef {
53 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 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 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 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 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 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 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 let normalized_path = crate::utils::normalize_path_for_storage(&self.path);
159
160 match &self.source {
161 Some(source) => {
162 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 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}