1use crate::cache::PackageCache;
2use crate::fetch::{fetch_dependency, read_package_manifest};
3use crate::lockfile::{LockFile, LockedPackage};
4use crate::manifest::{DepSourceKind, DependencySpec, Manifest};
5use std::collections::{BTreeMap, HashSet, VecDeque};
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum DepChange {
11 Added { version: String },
12 Updated { from: String, to: String },
13 Unchanged { version: String },
14 Removed { version: String },
15}
16
17#[derive(Debug, Clone, Default)]
19pub struct ResolveReport {
20 pub changes: Vec<(String, DepChange)>,
21}
22
23impl ResolveReport {
24 pub fn added_count(&self) -> usize {
25 self.changes
26 .iter()
27 .filter(|(_, c)| matches!(c, DepChange::Added { .. }))
28 .count()
29 }
30 pub fn updated_count(&self) -> usize {
31 self.changes
32 .iter()
33 .filter(|(_, c)| matches!(c, DepChange::Updated { .. }))
34 .count()
35 }
36 pub fn removed_count(&self) -> usize {
37 self.changes
38 .iter()
39 .filter(|(_, c)| matches!(c, DepChange::Removed { .. }))
40 .count()
41 }
42 pub fn has_changes(&self) -> bool {
43 self.changes
44 .iter()
45 .any(|(_, c)| !matches!(c, DepChange::Unchanged { .. }))
46 }
47}
48
49#[derive(Debug, Clone)]
51pub struct VersionConflict {
52 pub package: String,
53 pub requester_a: String,
54 pub requirement_a: String,
55 pub requester_b: String,
56 pub requirement_b: String,
57 pub resolved_version: Option<String>,
58}
59
60pub fn build_report(old_lock: &LockFile, new_packages: &[LockedPackage]) -> ResolveReport {
62 let mut changes = Vec::new();
63
64 for pkg in new_packages {
66 if let Some(old) = old_lock.find(&pkg.name) {
67 if old.version == pkg.version {
68 changes.push((
69 pkg.name.clone(),
70 DepChange::Unchanged {
71 version: pkg.version.clone(),
72 },
73 ));
74 } else {
75 changes.push((
76 pkg.name.clone(),
77 DepChange::Updated {
78 from: old.version.clone(),
79 to: pkg.version.clone(),
80 },
81 ));
82 }
83 } else {
84 changes.push((
85 pkg.name.clone(),
86 DepChange::Added {
87 version: pkg.version.clone(),
88 },
89 ));
90 }
91 }
92
93 let new_names: HashSet<&str> = new_packages.iter().map(|p| p.name.as_str()).collect();
95 for old_pkg in &old_lock.packages {
96 if !new_names.contains(old_pkg.name.as_str()) {
97 changes.push((
98 old_pkg.name.clone(),
99 DepChange::Removed {
100 version: old_pkg.version.clone(),
101 },
102 ));
103 }
104 }
105
106 ResolveReport { changes }
107}
108
109pub fn detect_conflicts(
112 requirements: &BTreeMap<String, Vec<(String, String)>>,
113 resolved: &BTreeMap<String, String>,
114) -> Vec<VersionConflict> {
115 let mut conflicts = Vec::new();
116
117 for (pkg_name, requesters) in requirements {
118 if requesters.len() < 2 {
119 continue;
120 }
121 let resolved_version = resolved.get(pkg_name).cloned();
122 let resolved_ver = resolved_version
123 .as_deref()
124 .and_then(|v| crate::version::Version::parse(v).ok());
125
126 if let Some(ref ver) = resolved_ver {
127 let unsatisfied: Vec<usize> = (0..requesters.len())
129 .filter(|&i| {
130 crate::version::VersionReq::parse(&requesters[i].1)
131 .is_ok_and(|req| !req.matches(ver))
132 })
133 .collect();
134 let satisfied: Vec<usize> = (0..requesters.len())
135 .filter(|&i| {
136 crate::version::VersionReq::parse(&requesters[i].1)
137 .is_ok_and(|req| req.matches(ver))
138 })
139 .collect();
140
141 for &u in &unsatisfied {
143 let other = if !satisfied.is_empty() {
144 satisfied[0]
145 } else {
146 *unsatisfied.iter().find(|&&x| x != u).unwrap_or(&u)
148 };
149 if other != u {
150 conflicts.push(VersionConflict {
151 package: pkg_name.clone(),
152 requester_a: requesters[u].0.clone(),
153 requirement_a: requesters[u].1.clone(),
154 requester_b: requesters[other].0.clone(),
155 requirement_b: requesters[other].1.clone(),
156 resolved_version: resolved_version.clone(),
157 });
158 break; }
160 }
161 } else {
162 conflicts.push(VersionConflict {
164 package: pkg_name.clone(),
165 requester_a: requesters[0].0.clone(),
166 requirement_a: requesters[0].1.clone(),
167 requester_b: requesters[1].0.clone(),
168 requirement_b: requesters[1].1.clone(),
169 resolved_version: None,
170 });
171 }
172 }
173
174 conflicts
175}
176
177pub fn resolve_and_install_with_report(
179 project_root: &Path,
180 manifest: &Manifest,
181 cache: &PackageCache,
182) -> Result<(LockFile, ResolveReport), String> {
183 cache.ensure_dir()?;
184
185 let lock_path = project_root.join("tl.lock");
186 let old_lock = LockFile::load(&lock_path)?;
187
188 let new_packages = resolve_packages(project_root, manifest, &old_lock, cache)?;
189
190 let report = build_report(&old_lock, &new_packages);
191
192 let lock = LockFile {
193 packages: new_packages,
194 };
195 lock.save(&lock_path)?;
196
197 Ok((lock, report))
198}
199
200fn resolve_packages(
202 project_root: &Path,
203 manifest: &Manifest,
204 old_lock: &LockFile,
205 cache: &PackageCache,
206) -> Result<Vec<LockedPackage>, String> {
207 let mut resolved: Vec<LockedPackage> = Vec::new();
208 let mut visited: HashSet<String> = HashSet::new();
209 let mut requirements: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
211
212 let mut queue: VecDeque<(String, DependencySpec, bool, String)> = VecDeque::new();
214
215 for (name, spec) in &manifest.dependencies {
217 queue.push_back((
218 name.clone(),
219 spec.clone(),
220 true,
221 manifest.project.name.clone(),
222 ));
223 if let DependencySpec::Simple(req) = spec {
225 requirements
226 .entry(name.clone())
227 .or_default()
228 .push((manifest.project.name.clone(), req.clone()));
229 } else if let DependencySpec::Detailed(d) = spec
230 && let Some(ref v) = d.version
231 {
232 requirements
233 .entry(name.clone())
234 .or_default()
235 .push((manifest.project.name.clone(), v.clone()));
236 }
237 }
238
239 while let Some((name, spec, is_direct, _requester)) = queue.pop_front() {
240 if visited.contains(&name) {
241 continue;
242 }
243 visited.insert(name.clone());
244
245 if let Some(locked) = old_lock.find(&name)
247 && spec_matches_locked(&spec, locked)
248 && is_available(&name, locked, cache)
249 {
250 let mut pkg = locked.clone();
251 pkg.direct = is_direct;
252 resolved.push(pkg);
253
254 discover_transitive_deps(&name, locked, cache, &mut queue, &mut requirements);
256 continue;
257 }
258
259 let result = fetch_dependency(&name, &spec, project_root, cache)?;
261
262 let mut locked_pkg = LockedPackage::new(
263 result.name.clone(),
264 result.version.clone(),
265 result.source_desc,
266 );
267 locked_pkg.direct = is_direct;
268
269 let dep_dir = &result.cache_path;
271 if let Some(dep_manifest) = read_package_manifest(dep_dir) {
272 let mut dep_names = Vec::new();
273 for (dep_name, dep_spec) in &dep_manifest.dependencies {
274 dep_names.push(dep_name.clone());
275 if !visited.contains(dep_name) {
276 if let DependencySpec::Simple(req) = dep_spec {
278 requirements
279 .entry(dep_name.clone())
280 .or_default()
281 .push((name.clone(), req.clone()));
282 } else if let DependencySpec::Detailed(d) = dep_spec
283 && let Some(ref v) = d.version
284 {
285 requirements
286 .entry(dep_name.clone())
287 .or_default()
288 .push((name.clone(), v.clone()));
289 }
290 queue.push_back((dep_name.clone(), dep_spec.clone(), false, name.clone()));
291 }
292 }
293 locked_pkg.dependencies = dep_names;
294 }
295
296 resolved.push(locked_pkg);
297 }
298
299 let resolved_versions: BTreeMap<String, String> = resolved
301 .iter()
302 .map(|p| (p.name.clone(), p.version.clone()))
303 .collect();
304 let conflicts = detect_conflicts(&requirements, &resolved_versions);
305 if !conflicts.is_empty() {
306 let mut msg = String::from("Version conflicts detected:\n");
307 for c in &conflicts {
308 msg.push_str(&format!(
309 " {} required by {} ({}) and {} ({})",
310 c.package, c.requester_a, c.requirement_a, c.requester_b, c.requirement_b,
311 ));
312 if let Some(ref v) = c.resolved_version {
313 msg.push_str(&format!(", resolved to {v}"));
314 }
315 msg.push('\n');
316 }
317 return Err(msg);
318 }
319
320 Ok(resolved)
321}
322
323fn discover_transitive_deps(
325 name: &str,
326 locked: &LockedPackage,
327 cache: &PackageCache,
328 queue: &mut VecDeque<(String, DependencySpec, bool, String)>,
329 requirements: &mut BTreeMap<String, Vec<(String, String)>>,
330) {
331 let dir = if locked.is_path() {
332 locked.path_value().map(PathBuf::from)
333 } else {
334 Some(cache.package_dir(&locked.name, &locked.version))
335 };
336
337 if let Some(dir) = dir
338 && let Some(dep_manifest) = read_package_manifest(&dir)
339 {
340 for (dep_name, dep_spec) in &dep_manifest.dependencies {
341 if let DependencySpec::Simple(req) = dep_spec {
342 requirements
343 .entry(dep_name.clone())
344 .or_default()
345 .push((name.to_string(), req.clone()));
346 } else if let DependencySpec::Detailed(d) = dep_spec
347 && let Some(ref v) = d.version
348 {
349 requirements
350 .entry(dep_name.clone())
351 .or_default()
352 .push((name.to_string(), v.clone()));
353 }
354 queue.push_back((dep_name.clone(), dep_spec.clone(), false, name.to_string()));
355 }
356 }
357}
358
359pub fn resolve_dry_run(
361 project_root: &Path,
362 manifest: &Manifest,
363 cache: &PackageCache,
364) -> Result<ResolveReport, String> {
365 let lock_path = project_root.join("tl.lock");
366 let old_lock = LockFile::load(&lock_path)?;
367
368 cache.ensure_dir()?;
372 let new_packages = resolve_packages(project_root, manifest, &old_lock, cache)?;
373 Ok(build_report(&old_lock, &new_packages))
374}
375
376pub fn resolve_and_install(
378 project_root: &Path,
379 manifest: &Manifest,
380 cache: &PackageCache,
381) -> Result<LockFile, String> {
382 let (lock, _report) = resolve_and_install_with_report(project_root, manifest, cache)?;
383 Ok(lock)
384}
385
386pub fn spec_matches_locked(spec: &DependencySpec, locked: &LockedPackage) -> bool {
388 match spec.source_kind() {
389 DepSourceKind::Path => locked.is_path(),
390 DepSourceKind::Git => {
391 if !locked.is_git() {
392 return false;
393 }
394 if let DependencySpec::Detailed(d) = spec
396 && let (Some(spec_url), Some(locked_url)) = (d.git.as_deref(), locked.git_url())
397 {
398 return spec_url == locked_url;
399 }
400 false
401 }
402 DepSourceKind::Registry => {
403 if let DependencySpec::Simple(req_str) = spec
405 && let Ok(req) = crate::version::VersionReq::parse(req_str)
406 && let Ok(ver) = crate::version::Version::parse(&locked.version)
407 {
408 return req.matches(&ver);
409 }
410 false
411 }
412 }
413}
414
415fn is_available(name: &str, locked: &LockedPackage, cache: &PackageCache) -> bool {
417 if locked.is_path() {
418 if let Some(path) = locked.path_value() {
420 return Path::new(path).exists();
421 }
422 false
423 } else {
424 cache.is_cached(name, &locked.version)
425 }
426}
427
428pub fn find_package_source(
431 name: &str,
432 project_root: &Path,
433 cache: &PackageCache,
434) -> Option<PathBuf> {
435 let lock_path = project_root.join("tl.lock");
436 let lock = LockFile::load(&lock_path).ok()?;
437 let locked = lock.find(name)?;
438
439 if locked.is_path() {
440 let path = locked.path_value()?;
441 let abs = PathBuf::from(path);
442 if abs.exists() {
443 return Some(abs);
444 }
445 return None;
446 }
447
448 if cache.is_cached(name, &locked.version) {
451 Some(cache.package_dir(name, &locked.version))
452 } else {
453 None
454 }
455}
456
457pub fn build_package_roots(
459 project_root: &Path,
460 cache: &PackageCache,
461) -> std::collections::HashMap<String, PathBuf> {
462 let mut roots = std::collections::HashMap::new();
463 let lock_path = project_root.join("tl.lock");
464 if let Ok(lock) = LockFile::load(&lock_path) {
465 for pkg in &lock.packages {
466 if let Some(path) = find_single_package_source(pkg, cache) {
467 roots.insert(pkg.name.clone(), path);
468 }
469 }
470 }
471 roots
472}
473
474fn find_single_package_source(locked: &LockedPackage, cache: &PackageCache) -> Option<PathBuf> {
475 if locked.is_path() {
476 let path = locked.path_value()?;
477 let abs = PathBuf::from(path);
478 if abs.exists() {
479 return Some(abs);
480 }
481 return None;
482 }
483 Some(cache.package_dir(&locked.name, &locked.version))
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use crate::manifest::{DetailedDep, ProjectConfig};
490 use tempfile::TempDir;
491
492 fn make_test_package(dir: &Path, name: &str, version: &str) {
493 std::fs::create_dir_all(dir.join("src")).unwrap();
494 std::fs::write(
495 dir.join("tl.toml"),
496 format!("[project]\nname = \"{name}\"\nversion = \"{version}\"\n"),
497 )
498 .unwrap();
499 std::fs::write(dir.join("src/lib.tl"), "pub fn greet() { print(\"hi\") }\n").unwrap();
500 }
501
502 fn make_test_package_with_deps(dir: &Path, name: &str, version: &str, deps: &[(&str, &str)]) {
503 std::fs::create_dir_all(dir.join("src")).unwrap();
504 let mut toml =
505 format!("[project]\nname = \"{name}\"\nversion = \"{version}\"\n\n[dependencies]\n");
506 for (dep_name, dep_path) in deps {
507 toml.push_str(&format!("{dep_name} = {{ path = \"{dep_path}\" }}\n"));
508 }
509 std::fs::write(dir.join("tl.toml"), toml).unwrap();
510 std::fs::write(dir.join("src/lib.tl"), "pub fn greet() { print(\"hi\") }\n").unwrap();
511 }
512
513 fn test_manifest_with_path_dep(name: &str, path: &Path) -> Manifest {
514 Manifest {
515 project: ProjectConfig {
516 name: "test".into(),
517 version: "0.1.0".into(),
518 edition: None,
519 authors: None,
520 description: None,
521 entry: None,
522 },
523 dependencies: {
524 let mut deps = std::collections::BTreeMap::new();
525 deps.insert(
526 name.into(),
527 DependencySpec::Detailed(DetailedDep {
528 version: None,
529 git: None,
530 branch: None,
531 tag: None,
532 rev: None,
533 path: Some(path.to_string_lossy().into()),
534 }),
535 );
536 deps
537 },
538 }
539 }
540
541 #[test]
544 fn install_with_path_dep() {
545 let tmp = TempDir::new().unwrap();
546 let project = tmp.path().join("project");
547 let lib = tmp.path().join("mylib");
548 std::fs::create_dir_all(&project).unwrap();
549 make_test_package(&lib, "mylib", "1.0.0");
550
551 let manifest = test_manifest_with_path_dep("mylib", &lib);
552 let cache = PackageCache::new(tmp.path().join("cache"));
553 let lock = resolve_and_install(&project, &manifest, &cache).unwrap();
554 assert_eq!(lock.packages.len(), 1);
555 assert_eq!(lock.packages[0].name, "mylib");
556 assert_eq!(lock.packages[0].version, "1.0.0");
557 assert!(lock.packages[0].is_path());
558 assert!(project.join("tl.lock").exists());
559 }
560
561 #[test]
562 fn install_empty_deps() {
563 let tmp = TempDir::new().unwrap();
564 let project = tmp.path().join("project");
565 std::fs::create_dir_all(&project).unwrap();
566
567 let manifest = Manifest {
568 project: ProjectConfig {
569 name: "test".into(),
570 version: "0.1.0".into(),
571 edition: None,
572 authors: None,
573 description: None,
574 entry: None,
575 },
576 dependencies: std::collections::BTreeMap::new(),
577 };
578
579 let cache = PackageCache::new(tmp.path().join("cache"));
580 let lock = resolve_and_install(&project, &manifest, &cache).unwrap();
581 assert!(lock.packages.is_empty());
582 }
583
584 #[test]
585 fn lock_reuse() {
586 let tmp = TempDir::new().unwrap();
587 let project = tmp.path().join("project");
588 let lib = tmp.path().join("mylib");
589 std::fs::create_dir_all(&project).unwrap();
590 make_test_package(&lib, "mylib", "1.0.0");
591
592 let manifest = test_manifest_with_path_dep("mylib", &lib);
593 let cache = PackageCache::new(tmp.path().join("cache"));
594
595 let lock1 = resolve_and_install(&project, &manifest, &cache).unwrap();
596 let lock2 = resolve_and_install(&project, &manifest, &cache).unwrap();
597 assert_eq!(lock1.packages, lock2.packages);
598 }
599
600 #[test]
601 fn spec_matches_locked_path() {
602 let locked = LockedPackage::new("mylib", "1.0.0", LockedPackage::path_source("/tmp/mylib"));
603 let spec = DependencySpec::Detailed(DetailedDep {
604 version: None,
605 git: None,
606 branch: None,
607 tag: None,
608 rev: None,
609 path: Some("/tmp/mylib".into()),
610 });
611 assert!(spec_matches_locked(&spec, &locked));
612 }
613
614 #[test]
615 fn spec_matches_locked_git() {
616 let locked = LockedPackage::new(
617 "remote",
618 "2.0.0",
619 LockedPackage::git_source("https://github.com/user/remote.git", "abc123"),
620 );
621 let spec = DependencySpec::Detailed(DetailedDep {
622 version: None,
623 git: Some("https://github.com/user/remote.git".into()),
624 branch: Some("main".into()),
625 tag: None,
626 rev: None,
627 path: None,
628 });
629 assert!(spec_matches_locked(&spec, &locked));
630 }
631
632 #[test]
635 fn test_resolve_report_added() {
636 let old_lock = LockFile::default();
637 let new = vec![LockedPackage::new("newpkg", "1.0.0", "path+/new".into())];
638 let report = build_report(&old_lock, &new);
639 assert_eq!(report.changes.len(), 1);
640 assert!(matches!(&report.changes[0].1, DepChange::Added { version } if version == "1.0.0"));
641 assert_eq!(report.added_count(), 1);
642 assert!(report.has_changes());
643 }
644
645 #[test]
646 fn test_resolve_report_updated() {
647 let old_lock = LockFile {
648 packages: vec![LockedPackage::new("pkg", "1.0.0", "path+/p".into())],
649 };
650 let new = vec![LockedPackage::new("pkg", "1.2.0", "path+/p".into())];
651 let report = build_report(&old_lock, &new);
652 assert_eq!(report.changes.len(), 1);
653 assert!(
654 matches!(&report.changes[0].1, DepChange::Updated { from, to } if from == "1.0.0" && to == "1.2.0")
655 );
656 assert_eq!(report.updated_count(), 1);
657 }
658
659 #[test]
660 fn test_resolve_report_unchanged() {
661 let old_lock = LockFile {
662 packages: vec![LockedPackage::new("pkg", "1.0.0", "path+/p".into())],
663 };
664 let new = vec![LockedPackage::new("pkg", "1.0.0", "path+/p".into())];
665 let report = build_report(&old_lock, &new);
666 assert_eq!(report.changes.len(), 1);
667 assert!(
668 matches!(&report.changes[0].1, DepChange::Unchanged { version } if version == "1.0.0")
669 );
670 assert!(!report.has_changes());
671 }
672
673 #[test]
674 fn test_resolve_report_removed() {
675 let old_lock = LockFile {
676 packages: vec![LockedPackage::new("oldpkg", "2.0.0", "path+/old".into())],
677 };
678 let new: Vec<LockedPackage> = vec![];
679 let report = build_report(&old_lock, &new);
680 assert_eq!(report.changes.len(), 1);
681 assert!(
682 matches!(&report.changes[0].1, DepChange::Removed { version } if version == "2.0.0")
683 );
684 assert_eq!(report.removed_count(), 1);
685 }
686
687 #[test]
690 fn test_transitive_resolution() {
691 let tmp = TempDir::new().unwrap();
692 let project = tmp.path().join("project");
693 std::fs::create_dir_all(&project).unwrap();
694
695 let sub_dep = tmp.path().join("sub-dep");
697 make_test_package(&sub_dep, "sub-dep", "0.5.0");
698
699 let lib = tmp.path().join("mylib");
701 make_test_package_with_deps(
702 &lib,
703 "mylib",
704 "1.0.0",
705 &[("sub-dep", &sub_dep.to_string_lossy())],
706 );
707
708 let manifest = test_manifest_with_path_dep("mylib", &lib);
709 let cache = PackageCache::new(tmp.path().join("cache"));
710 let lock = resolve_and_install(&project, &manifest, &cache).unwrap();
711
712 assert_eq!(lock.packages.len(), 2);
714 let mylib = lock.packages.iter().find(|p| p.name == "mylib").unwrap();
715 let subdep = lock.packages.iter().find(|p| p.name == "sub-dep").unwrap();
716 assert!(mylib.direct);
717 assert!(!subdep.direct);
718 assert_eq!(mylib.dependencies, vec!["sub-dep".to_string()]);
719 }
720
721 #[test]
722 fn test_transitive_no_cycles() {
723 let tmp = TempDir::new().unwrap();
724 let project = tmp.path().join("project");
725 std::fs::create_dir_all(&project).unwrap();
726
727 let a_dir = tmp.path().join("a");
729 let b_dir = tmp.path().join("b");
730
731 make_test_package_with_deps(&b_dir, "b", "1.0.0", &[("a", &a_dir.to_string_lossy())]);
733
734 make_test_package_with_deps(&a_dir, "a", "1.0.0", &[("b", &b_dir.to_string_lossy())]);
736
737 let manifest = test_manifest_with_path_dep("a", &a_dir);
738 let cache = PackageCache::new(tmp.path().join("cache"));
739 let lock = resolve_and_install(&project, &manifest, &cache).unwrap();
741 assert_eq!(lock.packages.len(), 2);
742 }
743
744 #[test]
745 fn test_transitive_diamond() {
746 let tmp = TempDir::new().unwrap();
747 let project = tmp.path().join("project");
748 std::fs::create_dir_all(&project).unwrap();
749
750 let d_dir = tmp.path().join("d");
752 make_test_package(&d_dir, "d", "1.0.0");
753
754 let b_dir = tmp.path().join("b");
756 make_test_package_with_deps(&b_dir, "b", "1.0.0", &[("d", &d_dir.to_string_lossy())]);
757
758 let c_dir = tmp.path().join("c");
760 make_test_package_with_deps(&c_dir, "c", "1.0.0", &[("d", &d_dir.to_string_lossy())]);
761
762 let manifest = Manifest {
764 project: ProjectConfig {
765 name: "test".into(),
766 version: "0.1.0".into(),
767 edition: None,
768 authors: None,
769 description: None,
770 entry: None,
771 },
772 dependencies: {
773 let mut deps = std::collections::BTreeMap::new();
774 deps.insert(
775 "b".into(),
776 DependencySpec::Detailed(DetailedDep {
777 version: None,
778 git: None,
779 branch: None,
780 tag: None,
781 rev: None,
782 path: Some(b_dir.to_string_lossy().into()),
783 }),
784 );
785 deps.insert(
786 "c".into(),
787 DependencySpec::Detailed(DetailedDep {
788 version: None,
789 git: None,
790 branch: None,
791 tag: None,
792 rev: None,
793 path: Some(c_dir.to_string_lossy().into()),
794 }),
795 );
796 deps
797 },
798 };
799
800 let cache = PackageCache::new(tmp.path().join("cache"));
801 let lock = resolve_and_install(&project, &manifest, &cache).unwrap();
802
803 assert_eq!(lock.packages.len(), 3);
805 let d_count = lock.packages.iter().filter(|p| p.name == "d").count();
806 assert_eq!(d_count, 1, "D should appear exactly once");
807 let d = lock.packages.iter().find(|p| p.name == "d").unwrap();
808 assert!(!d.direct, "D should be transitive");
809 }
810
811 #[test]
814 fn test_conflict_detection() {
815 let mut requirements: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
816 requirements.insert(
817 "shared".into(),
818 vec![
819 ("pkg-a".into(), "^1.0".into()),
820 ("pkg-b".into(), "^2.0".into()),
821 ],
822 );
823 let mut resolved = BTreeMap::new();
825 resolved.insert("shared".into(), "1.5.0".into());
826
827 let conflicts = detect_conflicts(&requirements, &resolved);
828 assert!(!conflicts.is_empty(), "should detect version conflict");
829 assert_eq!(conflicts[0].package, "shared");
830 }
831
832 #[test]
833 fn test_conflict_compatible() {
834 let mut requirements: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
835 requirements.insert(
836 "shared".into(),
837 vec![
838 ("pkg-a".into(), "^1.0".into()),
839 ("pkg-b".into(), "^1.2".into()),
840 ],
841 );
842 let mut resolved = BTreeMap::new();
844 resolved.insert("shared".into(), "1.5.0".into());
845
846 let conflicts = detect_conflicts(&requirements, &resolved);
847 assert!(
848 conflicts.is_empty(),
849 "no conflict expected for compatible requirements"
850 );
851 }
852
853 #[test]
856 fn test_install_with_report() {
857 let tmp = TempDir::new().unwrap();
858 let project = tmp.path().join("project");
859 let lib = tmp.path().join("mylib");
860 std::fs::create_dir_all(&project).unwrap();
861 make_test_package(&lib, "mylib", "1.0.0");
862
863 let manifest = test_manifest_with_path_dep("mylib", &lib);
864 let cache = PackageCache::new(tmp.path().join("cache"));
865
866 let (lock, report) = resolve_and_install_with_report(&project, &manifest, &cache).unwrap();
867 assert_eq!(lock.packages.len(), 1);
868 assert_eq!(report.added_count(), 1);
870 assert!(report.has_changes());
871 }
872}