Skip to main content

sage_package/
dependency.rs

1//! Dependency specification parsing.
2
3use crate::error::PackageError;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::PathBuf;
7
8/// A dependency specification from grove.toml.
9///
10/// Can be either a git dependency or a local path dependency.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(untagged)]
13pub enum DependencySpec {
14    /// A git-based dependency with URL and ref.
15    Git(GitDependency),
16    /// A local path dependency.
17    Path(PathDependency),
18}
19
20/// A git-based dependency specification.
21///
22/// Requires a git URL and exactly one of: tag, branch, or rev.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct GitDependency {
25    /// Git repository URL.
26    pub git: String,
27    /// Git tag (e.g., "v1.0.0").
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub tag: Option<String>,
30    /// Git branch (e.g., "main").
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub branch: Option<String>,
33    /// Git revision (full or short SHA).
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub rev: Option<String>,
36}
37
38/// A local path dependency specification.
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub struct PathDependency {
41    /// Path to the local package (relative or absolute).
42    pub path: String,
43}
44
45impl DependencySpec {
46    /// Create a new git dependency spec with a tag.
47    pub fn with_tag(git: impl Into<String>, tag: impl Into<String>) -> Self {
48        Self::Git(GitDependency {
49            git: git.into(),
50            tag: Some(tag.into()),
51            branch: None,
52            rev: None,
53        })
54    }
55
56    /// Create a new git dependency spec with a branch.
57    pub fn with_branch(git: impl Into<String>, branch: impl Into<String>) -> Self {
58        Self::Git(GitDependency {
59            git: git.into(),
60            tag: None,
61            branch: Some(branch.into()),
62            rev: None,
63        })
64    }
65
66    /// Create a new git dependency spec with a revision.
67    pub fn with_rev(git: impl Into<String>, rev: impl Into<String>) -> Self {
68        Self::Git(GitDependency {
69            git: git.into(),
70            tag: None,
71            branch: None,
72            rev: Some(rev.into()),
73        })
74    }
75
76    /// Create a new path dependency spec.
77    pub fn with_path(path: impl Into<String>) -> Self {
78        Self::Path(PathDependency { path: path.into() })
79    }
80
81    /// Check if this is a path dependency.
82    pub fn is_path(&self) -> bool {
83        matches!(self, Self::Path(_))
84    }
85
86    /// Check if this is a git dependency.
87    pub fn is_git(&self) -> bool {
88        matches!(self, Self::Git(_))
89    }
90
91    /// Get the git URL if this is a git dependency.
92    pub fn git_url(&self) -> Option<&str> {
93        match self {
94            Self::Git(g) => Some(&g.git),
95            Self::Path(_) => None,
96        }
97    }
98
99    /// Get the path if this is a path dependency.
100    pub fn path(&self) -> Option<&str> {
101        match self {
102            Self::Path(p) => Some(&p.path),
103            Self::Git(_) => None,
104        }
105    }
106
107    /// Validate the dependency specification.
108    pub fn validate(&self, package_name: &str) -> Result<(), PackageError> {
109        match self {
110            Self::Git(g) => {
111                let count = [&g.tag, &g.branch, &g.rev]
112                    .iter()
113                    .filter(|x| x.is_some())
114                    .count();
115
116                if count != 1 {
117                    return Err(PackageError::InvalidDependencySpec {
118                        package: package_name.to_string(),
119                    });
120                }
121                Ok(())
122            }
123            Self::Path(_) => Ok(()), // Path deps are always valid if they exist
124        }
125    }
126
127    /// Get the ref string (tag, branch, or rev) for git deps.
128    pub fn ref_string(&self) -> &str {
129        match self {
130            Self::Git(g) => g
131                .tag
132                .as_deref()
133                .or(g.branch.as_deref())
134                .or(g.rev.as_deref())
135                .unwrap_or("HEAD"),
136            Self::Path(_) => "path",
137        }
138    }
139
140    /// Get the ref type for display.
141    pub fn ref_type(&self) -> &'static str {
142        match self {
143            Self::Git(g) => {
144                if g.tag.is_some() {
145                    "tag"
146                } else if g.branch.is_some() {
147                    "branch"
148                } else if g.rev.is_some() {
149                    "rev"
150                } else {
151                    "HEAD"
152                }
153            }
154            Self::Path(_) => "path",
155        }
156    }
157}
158
159impl GitDependency {
160    /// Get the ref string (tag, branch, or rev).
161    pub fn ref_string(&self) -> &str {
162        self.tag
163            .as_deref()
164            .or(self.branch.as_deref())
165            .or(self.rev.as_deref())
166            .unwrap_or("HEAD")
167    }
168}
169
170/// Parse dependencies from a TOML table.
171pub fn parse_dependencies(
172    table: &toml::Table,
173) -> Result<HashMap<String, DependencySpec>, PackageError> {
174    let mut deps = HashMap::new();
175
176    for (name, value) in table {
177        let spec = parse_dependency_value(name, value)?;
178        deps.insert(name.clone(), spec);
179    }
180
181    Ok(deps)
182}
183
184fn parse_dependency_value(name: &str, value: &toml::Value) -> Result<DependencySpec, PackageError> {
185    match value {
186        toml::Value::Table(t) => {
187            // Check if it's a path dependency
188            if let Some(path) = t.get("path").and_then(|v| v.as_str()) {
189                return Ok(DependencySpec::Path(PathDependency {
190                    path: path.to_string(),
191                }));
192            }
193
194            // Otherwise it's a git dependency
195            let git = t
196                .get("git")
197                .and_then(|v| v.as_str())
198                .ok_or_else(|| PackageError::MissingGitUrl {
199                    package: name.to_string(),
200                })?
201                .to_string();
202
203            let tag = t.get("tag").and_then(|v| v.as_str()).map(String::from);
204            let branch = t.get("branch").and_then(|v| v.as_str()).map(String::from);
205            let rev = t.get("rev").and_then(|v| v.as_str()).map(String::from);
206
207            let spec = DependencySpec::Git(GitDependency {
208                git,
209                tag,
210                branch,
211                rev,
212            });
213            spec.validate(name)?;
214            Ok(spec)
215        }
216        _ => Err(PackageError::InvalidDependencySpec {
217            package: name.to_string(),
218        }),
219    }
220}
221
222/// Resolve a path dependency to an absolute path.
223pub fn resolve_path(base_dir: &std::path::Path, path: &str) -> PathBuf {
224    let path = PathBuf::from(path);
225    if path.is_absolute() {
226        path
227    } else {
228        base_dir.join(path)
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn parse_tag_dependency() {
238        let toml_str = r#"
239git = "https://github.com/example/package"
240tag = "v1.0.0"
241"#;
242        let value: toml::Value = toml::from_str(toml_str).unwrap();
243        let spec = parse_dependency_value("test", &value).unwrap();
244
245        match spec {
246            DependencySpec::Git(g) => {
247                assert_eq!(g.git, "https://github.com/example/package");
248                assert_eq!(g.tag, Some("v1.0.0".to_string()));
249                assert_eq!(g.branch, None);
250                assert_eq!(g.rev, None);
251            }
252            _ => panic!("Expected git dependency"),
253        }
254    }
255
256    #[test]
257    fn parse_branch_dependency() {
258        let toml_str = r#"
259git = "https://github.com/example/package"
260branch = "develop"
261"#;
262        let value: toml::Value = toml::from_str(toml_str).unwrap();
263        let spec = parse_dependency_value("test", &value).unwrap();
264
265        match spec {
266            DependencySpec::Git(g) => {
267                assert_eq!(g.branch, Some("develop".to_string()));
268            }
269            _ => panic!("Expected git dependency"),
270        }
271    }
272
273    #[test]
274    fn parse_rev_dependency() {
275        let toml_str = r#"
276git = "https://github.com/example/package"
277rev = "abc123"
278"#;
279        let value: toml::Value = toml::from_str(toml_str).unwrap();
280        let spec = parse_dependency_value("test", &value).unwrap();
281
282        match spec {
283            DependencySpec::Git(g) => {
284                assert_eq!(g.rev, Some("abc123".to_string()));
285            }
286            _ => panic!("Expected git dependency"),
287        }
288    }
289
290    #[test]
291    fn parse_path_dependency() {
292        let toml_str = r#"
293path = "../my-local-lib"
294"#;
295        let value: toml::Value = toml::from_str(toml_str).unwrap();
296        let spec = parse_dependency_value("test", &value).unwrap();
297
298        match spec {
299            DependencySpec::Path(p) => {
300                assert_eq!(p.path, "../my-local-lib");
301            }
302            _ => panic!("Expected path dependency"),
303        }
304    }
305
306    #[test]
307    fn parse_absolute_path_dependency() {
308        let toml_str = r#"
309path = "/Users/someone/projects/my-lib"
310"#;
311        let value: toml::Value = toml::from_str(toml_str).unwrap();
312        let spec = parse_dependency_value("test", &value).unwrap();
313
314        assert!(spec.is_path());
315        assert_eq!(spec.path(), Some("/Users/someone/projects/my-lib"));
316    }
317
318    #[test]
319    fn reject_missing_git_for_git_dep() {
320        let toml_str = r#"
321tag = "v1.0.0"
322"#;
323        let value: toml::Value = toml::from_str(toml_str).unwrap();
324        let result = parse_dependency_value("test", &value);
325
326        assert!(matches!(result, Err(PackageError::MissingGitUrl { .. })));
327    }
328
329    #[test]
330    fn reject_multiple_refs() {
331        let toml_str = r#"
332git = "https://github.com/example/package"
333tag = "v1.0.0"
334branch = "main"
335"#;
336        let value: toml::Value = toml::from_str(toml_str).unwrap();
337        let result = parse_dependency_value("test", &value);
338
339        assert!(matches!(
340            result,
341            Err(PackageError::InvalidDependencySpec { .. })
342        ));
343    }
344
345    #[test]
346    fn reject_no_ref() {
347        let toml_str = r#"
348git = "https://github.com/example/package"
349"#;
350        let value: toml::Value = toml::from_str(toml_str).unwrap();
351        let result = parse_dependency_value("test", &value);
352
353        assert!(matches!(
354            result,
355            Err(PackageError::InvalidDependencySpec { .. })
356        ));
357    }
358
359    #[test]
360    fn parse_multiple_dependencies() {
361        let table: toml::Table = toml::from_str(
362            r#"
363[foo]
364git = "https://github.com/example/foo"
365tag = "v1.0.0"
366
367[bar]
368git = "https://github.com/example/bar"
369branch = "main"
370
371[local]
372path = "../local-lib"
373"#,
374        )
375        .unwrap();
376
377        let deps = parse_dependencies(&table).unwrap();
378        assert_eq!(deps.len(), 3);
379        assert!(deps.contains_key("foo"));
380        assert!(deps.contains_key("bar"));
381        assert!(deps.contains_key("local"));
382        assert!(deps.get("local").unwrap().is_path());
383    }
384
385    #[test]
386    fn resolve_relative_path() {
387        use std::path::Path;
388        let base = Path::new("/home/user/project");
389        let resolved = resolve_path(base, "../lib");
390        assert_eq!(resolved, PathBuf::from("/home/user/project/../lib"));
391    }
392
393    #[test]
394    fn resolve_absolute_path() {
395        use std::path::Path;
396        let base = Path::new("/home/user/project");
397        let resolved = resolve_path(base, "/opt/libs/mylib");
398        assert_eq!(resolved, PathBuf::from("/opt/libs/mylib"));
399    }
400
401    #[test]
402    fn dependency_spec_helpers() {
403        let git = DependencySpec::with_tag("https://example.com", "v1.0");
404        assert!(git.is_git());
405        assert!(!git.is_path());
406        assert_eq!(git.git_url(), Some("https://example.com"));
407        assert_eq!(git.path(), None);
408
409        let path = DependencySpec::with_path("../lib");
410        assert!(path.is_path());
411        assert!(!path.is_git());
412        assert_eq!(path.git_url(), None);
413        assert_eq!(path.path(), Some("../lib"));
414    }
415}