1use 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#[derive(Debug)]
13pub struct ResolvedPackages {
14 pub packages: ResolvedPackagesMap,
16 pub lock_file: LockFile,
18}
19
20#[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
39pub 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 for (name, spec) in deps {
50 resolver.resolve(name, spec, "root")?;
51 }
52
53 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 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
90pub fn check_lock_freshness(deps: &HashMap<String, DependencySpec>, lock: &LockFile) -> bool {
92 lock.matches_dependencies(deps)
93}
94
95pub 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 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 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 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 if self.in_progress.contains(name) {
198 return Ok(());
201 }
202
203 if let Some(existing) = self.resolved.get(name) {
205 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 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 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 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 self.cache.fetch(name, g)?
272 }
273 } else {
274 self.cache.fetch(name, g)?
276 }
277 } else {
278 self.cache.fetch(name, g)?
280 };
281
282 (path, Some(rev), Some(g.git.clone()), None)
283 }
284 };
285
286 let manifest = self.read_manifest(&path, name)?;
288
289 if manifest.project.name != name {
291 return Err(PackageError::PackageNameMismatch {
292 expected: name.to_string(),
293 found: manifest.project.name,
294 });
295 }
296
297 let trans_deps = parse_dependencies(&manifest.dependencies)?;
299 let dep_names: Vec<String> = trans_deps.keys().cloned().collect();
300
301 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 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
340pub fn check_is_library(path: &Path) -> Result<bool, PackageError> {
342 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 #[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 return Ok(true);
375 }
376
377 let entry_contents = std::fs::read_to_string(&entry_path)?;
378
379 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}