Skip to main content

tl_package/
resolver.rs

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/// Describes a single dependency change during resolution.
9#[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/// A report of all changes produced by a resolve operation.
18#[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/// A version conflict between two requesters of the same package.
50#[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
60/// Build a report by diffing an old lock file against a new set of packages.
61pub fn build_report(old_lock: &LockFile, new_packages: &[LockedPackage]) -> ResolveReport {
62    let mut changes = Vec::new();
63
64    // Check new packages against old
65    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    // Check for removed packages
94    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
109/// Detect version conflicts in the requirements map.
110/// `requirements` maps package name -> list of (requester_name, version_req_string).
111pub 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            // Find requesters whose requirement is NOT satisfied by the resolved version
128            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            // If some are unsatisfied, pair each unsatisfied with a satisfied one (or another unsatisfied)
142            for &u in &unsatisfied {
143                let other = if !satisfied.is_empty() {
144                    satisfied[0]
145                } else {
146                    // All unsatisfied — pair with first different one
147                    *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; // One conflict per package is enough
159                }
160            }
161        } else {
162            // No resolved version — flag that we can't resolve
163            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
177/// Resolve all dependencies with transitive resolution and produce a report.
178pub 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
200/// Core resolution logic with BFS transitive dependency resolution.
201fn 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    // Track requirements: package -> [(requester, version_req)]
210    let mut requirements: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
211
212    // BFS queue: (name, spec, is_direct, requester)
213    let mut queue: VecDeque<(String, DependencySpec, bool, String)> = VecDeque::new();
214
215    // Seed with direct dependencies
216    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        // Track version requirement
224        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        // Check if already locked and cached
246        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 from cached package
255            discover_transitive_deps(&name, locked, cache, &mut queue, &mut requirements);
256            continue;
257        }
258
259        // Fetch the dependency
260        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        // Discover transitive deps from the freshly fetched package
270        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                    // Track version requirement
277                    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    // Check for version conflicts
300    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
323/// Discover transitive dependencies from a locked (and cached) package.
324fn 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
359/// Preview what would change without modifying tl.lock.
360pub 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    // Simulate resolution — for registry deps, we'd query for latest matching version.
369    // For path/git deps, we read what they would resolve to.
370    // We reuse resolve_packages but don't save the lock file.
371    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
376/// Resolve all dependencies from a manifest, fetching as needed, and produce a lock file.
377pub 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
386/// Check if a dependency spec is compatible with what's locked.
387pub 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            // Check URL matches
395            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            // Registry deps match if version requirement is satisfied
404            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
415/// Check if a locked package is available (cached or path exists).
416fn is_available(name: &str, locked: &LockedPackage, cache: &PackageCache) -> bool {
417    if locked.is_path() {
418        // For path deps, check the source directory still exists
419        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
428/// Find the source directory for an installed package.
429/// Checks path deps first, then cache.
430pub 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    // Git/registry: look in cache
449    // We want the package root (containing tl.toml), not just src/
450    if cache.is_cached(name, &locked.version) {
451        Some(cache.package_dir(name, &locked.version))
452    } else {
453        None
454    }
455}
456
457/// Build a map of package name -> source root for all installed packages.
458pub 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    // --- Original tests ---
542
543    #[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    // --- ResolveReport tests ---
633
634    #[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    // --- Transitive resolution tests ---
688
689    #[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        // Create sub-dep (no deps of its own)
696        let sub_dep = tmp.path().join("sub-dep");
697        make_test_package(&sub_dep, "sub-dep", "0.5.0");
698
699        // Create lib that depends on sub-dep
700        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        // Should have both mylib (direct) and sub-dep (transitive)
713        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        // Create A that depends on B, and B that depends on A (circular)
728        let a_dir = tmp.path().join("a");
729        let b_dir = tmp.path().join("b");
730
731        // Create B first (depends on A)
732        make_test_package_with_deps(&b_dir, "b", "1.0.0", &[("a", &a_dir.to_string_lossy())]);
733
734        // Create A (depends on B)
735        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        // Should not loop infinitely
740        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        // D has no deps
751        let d_dir = tmp.path().join("d");
752        make_test_package(&d_dir, "d", "1.0.0");
753
754        // B depends on D
755        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        // C depends on D
759        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        // Project depends on B and C (both depend on D)
763        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        // Should have B, C, D (D resolved once)
804        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    // --- Conflict detection tests ---
812
813    #[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        // Resolved to 1.5.0, which satisfies ^1.0 but not ^2.0
824        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        // Resolved to 1.5.0, which satisfies both ^1.0 and ^1.2
843        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    // --- resolve_and_install_with_report test ---
854
855    #[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        // First install — everything is "Added"
869        assert_eq!(report.added_count(), 1);
870        assert!(report.has_changes());
871    }
872}