Skip to main content

sage_package/
resolver.rs

1//! Dependency resolution.
2
3use crate::cache::{PackageCache, ResolvedPackage, ResolvedPackagesMap};
4use crate::dependency::{parse_dependencies, resolve_path, DependencySpec, GitDependency};
5use crate::error::PackageError;
6use crate::lock::{LockFile, LockedPackage};
7use serde::Deserialize;
8use std::collections::{HashMap, HashSet};
9use std::path::Path;
10
11/// Result of dependency resolution.
12#[derive(Debug)]
13pub struct ResolvedPackages {
14    /// All resolved packages (including transitive).
15    pub packages: ResolvedPackagesMap,
16    /// The generated lock file.
17    pub lock_file: LockFile,
18}
19
20/// Minimal manifest for reading package info.
21#[derive(Debug, Deserialize)]
22struct PackageManifest {
23    project: ProjectInfo,
24    #[serde(default)]
25    dependencies: toml::Table,
26}
27
28#[derive(Debug, Deserialize)]
29struct ProjectInfo {
30    name: String,
31    #[serde(default = "default_version")]
32    version: String,
33}
34
35fn default_version() -> String {
36    "0.1.0".to_string()
37}
38
39/// Resolve all dependencies from a project's grove.toml.
40pub fn resolve_dependencies(
41    project_root: &Path,
42    deps: &HashMap<String, DependencySpec>,
43    existing_lock: Option<&LockFile>,
44) -> Result<ResolvedPackages, PackageError> {
45    let cache = PackageCache::new()?;
46    let mut resolver = Resolver::new(cache, existing_lock, project_root);
47
48    // Resolve all direct dependencies
49    for (name, spec) in deps {
50        resolver.resolve(name, spec, "root")?;
51    }
52
53    // Build the result
54    let packages = resolver.resolved;
55    let lock_file = LockFile {
56        version: 1,
57        packages: packages
58            .values()
59            .map(|p| {
60                if let Some(ref path) = p.source_path {
61                    LockedPackage::path(
62                        p.name.clone(),
63                        p.version.clone(),
64                        path.clone(),
65                        p.dependencies.clone(),
66                    )
67                } else {
68                    LockedPackage::git(
69                        p.name.clone(),
70                        p.version.clone(),
71                        p.git.clone().unwrap_or_default(),
72                        p.rev.clone().unwrap_or_default(),
73                        p.dependencies.clone(),
74                    )
75                }
76            })
77            .collect(),
78    };
79
80    // Save lock file
81    let lock_path = project_root.join("grove.lock");
82    lock_file.save(&lock_path)?;
83
84    Ok(ResolvedPackages {
85        packages,
86        lock_file,
87    })
88}
89
90/// Check if dependencies are up-to-date with lock file.
91pub fn check_lock_freshness(deps: &HashMap<String, DependencySpec>, lock: &LockFile) -> bool {
92    lock.matches_dependencies(deps)
93}
94
95/// Install dependencies from an existing lock file.
96pub fn install_from_lock(
97    project_root: &Path,
98    lock: &LockFile,
99) -> Result<ResolvedPackagesMap, PackageError> {
100    let cache = PackageCache::new()?;
101    let mut packages = ResolvedPackagesMap::new();
102
103    for locked in lock.in_dependency_order() {
104        if locked.is_path() {
105            // Path dependency - resolve directly
106            let path_str = locked.path.as_ref().unwrap();
107            let resolved_path = resolve_path(project_root, path_str);
108
109            if !resolved_path.exists() {
110                return Err(PackageError::IoError {
111                    message: format!(
112                        "path dependency '{}' not found at {}",
113                        locked.name,
114                        resolved_path.display()
115                    ),
116                    source: std::io::Error::new(
117                        std::io::ErrorKind::NotFound,
118                        "path dependency not found",
119                    ),
120                });
121            }
122
123            packages.insert(
124                locked.name.clone(),
125                ResolvedPackage {
126                    name: locked.name.clone(),
127                    version: locked.version.clone(),
128                    path: resolved_path,
129                    rev: None,
130                    git: None,
131                    source_path: Some(path_str.clone()),
132                    dependencies: locked.dependencies.clone(),
133                },
134            );
135        } else {
136            // Git dependency - fetch via cache
137            let git_url = locked.git.as_ref().unwrap();
138            let rev = locked.rev.as_ref().unwrap();
139            let spec = GitDependency {
140                git: git_url.clone(),
141                tag: None,
142                branch: None,
143                rev: Some(rev.clone()),
144            };
145
146            // Fetch (will use cache if available)
147            let (path, _) = cache.fetch(&locked.name, &spec)?;
148
149            packages.insert(
150                locked.name.clone(),
151                ResolvedPackage {
152                    name: locked.name.clone(),
153                    version: locked.version.clone(),
154                    path,
155                    rev: Some(rev.clone()),
156                    git: Some(git_url.clone()),
157                    source_path: None,
158                    dependencies: locked.dependencies.clone(),
159                },
160            );
161        }
162    }
163
164    Ok(packages)
165}
166
167struct Resolver<'a> {
168    cache: PackageCache,
169    resolved: ResolvedPackagesMap,
170    in_progress: HashSet<String>,
171    existing_lock: Option<&'a LockFile>,
172    project_root: &'a Path,
173}
174
175impl<'a> Resolver<'a> {
176    fn new(
177        cache: PackageCache,
178        existing_lock: Option<&'a LockFile>,
179        project_root: &'a Path,
180    ) -> Self {
181        Self {
182            cache,
183            resolved: ResolvedPackagesMap::new(),
184            in_progress: HashSet::new(),
185            existing_lock,
186            project_root,
187        }
188    }
189
190    fn resolve(
191        &mut self,
192        name: &str,
193        spec: &DependencySpec,
194        requirer: &str,
195    ) -> Result<(), PackageError> {
196        // Check for circular dependency
197        if self.in_progress.contains(name) {
198            // Circular deps in packages are allowed as long as we've already
199            // resolved this package - just return
200            return Ok(());
201        }
202
203        // Already resolved?
204        if let Some(existing) = self.resolved.get(name) {
205            // Check for source conflict
206            match spec {
207                DependencySpec::Git(g) => {
208                    if existing.git.as_ref() != Some(&g.git) {
209                        return Err(PackageError::IncompatibleVersions {
210                            package: name.to_string(),
211                            version_a: existing.rev.clone().unwrap_or_default(),
212                            requirer_a: "previously resolved".to_string(),
213                            version_b: g.ref_string().to_string(),
214                            requirer_b: requirer.to_string(),
215                        });
216                    }
217                }
218                DependencySpec::Path(p) => {
219                    if existing.source_path.as_ref() != Some(&p.path) {
220                        return Err(PackageError::IncompatibleVersions {
221                            package: name.to_string(),
222                            version_a: existing.source_path.clone().unwrap_or_default(),
223                            requirer_a: "previously resolved".to_string(),
224                            version_b: p.path.clone(),
225                            requirer_b: requirer.to_string(),
226                        });
227                    }
228                }
229            }
230            return Ok(());
231        }
232
233        self.in_progress.insert(name.to_string());
234
235        let (path, rev, git, source_path) = match spec {
236            DependencySpec::Path(p) => {
237                // Resolve path dependency directly
238                let resolved_path = resolve_path(self.project_root, &p.path);
239
240                if !resolved_path.exists() {
241                    return Err(PackageError::IoError {
242                        message: format!(
243                            "path dependency '{}' not found at {}",
244                            name,
245                            resolved_path.display()
246                        ),
247                        source: std::io::Error::new(
248                            std::io::ErrorKind::NotFound,
249                            "path dependency not found",
250                        ),
251                    });
252                }
253
254                (resolved_path, None, None, Some(p.path.clone()))
255            }
256            DependencySpec::Git(g) => {
257                // Check if we can use the lock file
258                let (path, rev) = if let Some(lock) = self.existing_lock {
259                    if let Some(locked) = lock.find(name) {
260                        if locked.git.as_ref() == Some(&g.git) {
261                            // Use locked version
262                            let locked_spec = GitDependency {
263                                git: g.git.clone(),
264                                tag: None,
265                                branch: None,
266                                rev: locked.rev.clone(),
267                            };
268                            self.cache.fetch(name, &locked_spec)?
269                        } else {
270                            // Git URL changed - resolve fresh
271                            self.cache.fetch(name, g)?
272                        }
273                    } else {
274                        // Not in lock file - resolve fresh
275                        self.cache.fetch(name, g)?
276                    }
277                } else {
278                    // No lock file - resolve fresh
279                    self.cache.fetch(name, g)?
280                };
281
282                (path, Some(rev), Some(g.git.clone()), None)
283            }
284        };
285
286        // Read the package's manifest
287        let manifest = self.read_manifest(&path, name)?;
288
289        // Verify package name matches
290        if manifest.project.name != name {
291            return Err(PackageError::PackageNameMismatch {
292                expected: name.to_string(),
293                found: manifest.project.name,
294            });
295        }
296
297        // Parse transitive dependencies
298        let trans_deps = parse_dependencies(&manifest.dependencies)?;
299        let dep_names: Vec<String> = trans_deps.keys().cloned().collect();
300
301        // Store the resolved package
302        self.resolved.insert(
303            name.to_string(),
304            ResolvedPackage {
305                name: name.to_string(),
306                version: manifest.project.version,
307                path: path.clone(),
308                rev,
309                git,
310                source_path,
311                dependencies: dep_names.clone(),
312            },
313        );
314
315        self.in_progress.remove(name);
316
317        // Resolve transitive dependencies
318        for (dep_name, dep_spec) in trans_deps {
319            self.resolve(&dep_name, &dep_spec, name)?;
320        }
321
322        Ok(())
323    }
324
325    fn read_manifest(&self, path: &Path, name: &str) -> Result<PackageManifest, PackageError> {
326        let manifest_path = path.join("grove.toml");
327        let contents =
328            std::fs::read_to_string(&manifest_path).map_err(|e| PackageError::IoError {
329                message: format!("failed to read manifest for '{name}'"),
330                source: e,
331            })?;
332
333        toml::from_str(&contents).map_err(|e| PackageError::InvalidManifest {
334            package: name.to_string(),
335            source: e,
336        })
337    }
338}
339
340/// Check if a package has a `run` statement (making it an executable, not a library).
341pub fn check_is_library(path: &Path) -> Result<bool, PackageError> {
342    // Read the entry file and check for `run` statement
343    let manifest_path = path.join("sage.toml");
344    let manifest_contents = std::fs::read_to_string(&manifest_path)?;
345    let _manifest: PackageManifest =
346        toml::from_str(&manifest_contents).map_err(|e| PackageError::InvalidManifest {
347            package: path.display().to_string(),
348            source: e,
349        })?;
350
351    // Get entry path
352    #[derive(Deserialize)]
353    struct FullManifest {
354        project: FullProjectInfo,
355    }
356    #[derive(Deserialize)]
357    struct FullProjectInfo {
358        #[serde(default = "default_entry")]
359        entry: String,
360    }
361    fn default_entry() -> String {
362        "src/main.sg".to_string()
363    }
364
365    let full: FullManifest =
366        toml::from_str(&manifest_contents).map_err(|e| PackageError::InvalidManifest {
367            package: path.display().to_string(),
368            source: e,
369        })?;
370
371    let entry_path = path.join(&full.project.entry);
372    if !entry_path.exists() {
373        // No entry file means it's a library
374        return Ok(true);
375    }
376
377    let entry_contents = std::fs::read_to_string(&entry_path)?;
378
379    // Simple check: look for `run` followed by identifier and semicolon
380    // This is a rough check - a proper check would use the parser
381    let has_run = entry_contents
382        .lines()
383        .any(|line| line.trim().starts_with("run ") && line.trim().ends_with(';'));
384
385    Ok(!has_run)
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn check_lock_freshness_matches_git() {
394        let mut deps = HashMap::new();
395        deps.insert(
396            "foo".to_string(),
397            DependencySpec::with_tag("https://github.com/example/foo", "v1.0.0"),
398        );
399
400        let lock = LockFile {
401            version: 1,
402            packages: vec![LockedPackage::git(
403                "foo".to_string(),
404                "1.0.0".to_string(),
405                "https://github.com/example/foo".to_string(),
406                "abc123".to_string(),
407                vec![],
408            )],
409        };
410
411        assert!(check_lock_freshness(&deps, &lock));
412    }
413
414    #[test]
415    fn check_lock_freshness_matches_path() {
416        let mut deps = HashMap::new();
417        deps.insert("local".to_string(), DependencySpec::with_path("../lib"));
418
419        let lock = LockFile {
420            version: 1,
421            packages: vec![LockedPackage::path(
422                "local".to_string(),
423                "0.1.0".to_string(),
424                "../lib".to_string(),
425                vec![],
426            )],
427        };
428
429        assert!(check_lock_freshness(&deps, &lock));
430    }
431
432    #[test]
433    fn check_lock_freshness_missing_dep() {
434        let mut deps = HashMap::new();
435        deps.insert(
436            "foo".to_string(),
437            DependencySpec::with_tag("https://github.com/example/foo", "v1.0.0"),
438        );
439        deps.insert(
440            "bar".to_string(),
441            DependencySpec::with_tag("https://github.com/example/bar", "v2.0.0"),
442        );
443
444        let lock = LockFile {
445            version: 1,
446            packages: vec![LockedPackage::git(
447                "foo".to_string(),
448                "1.0.0".to_string(),
449                "https://github.com/example/foo".to_string(),
450                "abc123".to_string(),
451                vec![],
452            )],
453        };
454
455        assert!(!check_lock_freshness(&deps, &lock));
456    }
457
458    #[test]
459    fn check_lock_freshness_path_mismatch() {
460        let mut deps = HashMap::new();
461        deps.insert(
462            "local".to_string(),
463            DependencySpec::with_path("../different-path"),
464        );
465
466        let lock = LockFile {
467            version: 1,
468            packages: vec![LockedPackage::path(
469                "local".to_string(),
470                "0.1.0".to_string(),
471                "../original-path".to_string(),
472                vec![],
473            )],
474        };
475
476        assert!(!check_lock_freshness(&deps, &lock));
477    }
478}