Skip to main content

zoi_install/
resolver.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2
3use anyhow::{Result, anyhow};
4use pubgrub::{DependencyProvider, Ranges, resolve as pubgrub_resolve};
5use rustc_hash::FxHashMap;
6use semver::Version;
7use serde::{Deserialize, Serialize};
8use zoi_core::types::{self, InstallReason, Package};
9use zoi_deps as dependencies;
10use zoi_project::lockfile::FrozenLockPackage;
11use zoi_resolver::resolve;
12
13use crate::pubgrub::{PkgName, SemVersion, ZoiDependencyProvider};
14
15/// Represents a single package node within the resolved dependency graph.
16///
17/// This node encapsulates everything needed to install the package, including:
18/// - The fully parsed package metadata.
19/// - The exact resolved version and sub-package selection.
20/// - The reason it was included (Direct request vs. Dependency).
21/// - The specific options and optionals chosen by the user/resolver.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct InstallNode {
24    /// The core metadata definition for the package.
25    pub pkg: Package,
26    /// The exact resolved `SemVer` version.
27    pub version: String,
28    /// The package revision number (used for packaging updates).
29    pub revision: String,
30    /// The selected sub-package, if applicable (e.g. `Some("docs")`).
31    pub sub_package: Option<String>,
32    /// The tier of the repository this package belongs to (e.g. "official",
33    /// "community").
34    pub repo_type: String,
35    /// A short description of the package.
36    pub description: String,
37    /// Why this package is being installed (e.g. Direct request vs
38    /// Dependency).
39    pub reason: InstallReason,
40    /// The original source string used to request this package.
41    pub source: String,
42    /// The handle of the registry this package was resolved from.
43    pub registry_handle: String,
44    /// The names of explicit dependency options chosen by the user.
45    pub chosen_options: Vec<String>,
46    /// The names of optional dependencies chosen by the user.
47    pub chosen_optionals: Vec<String>,
48    /// The final list of runtime dependency source strings required by this
49    /// node.
50    pub dependencies: Vec<String>,
51    /// The Git commit SHA of the repository when this package was resolved
52    /// (used for lockfiles).
53    pub git_sha: Option<String>
54}
55
56/// A Directed Acyclic Graph (DAG) representing the installation plan.
57///
58/// The graph ensures that dependencies are installed before the packages
59/// that require them. The `$root` node acts as the entry point linking
60/// all direct user requests.
61#[derive(Default, Debug)]
62pub struct DependencyGraph {
63    /// A map of package IDs to their complete installation data.
64    pub nodes: HashMap<String, InstallNode>,
65    /// The adjacency list representing the directed edges (dependencies)
66    /// between packages.
67    pub adj: HashMap<String, HashSet<String>>
68}
69
70impl DependencyGraph {
71    /// Creates a new, empty `DependencyGraph`.
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// Performs a topological sort of the graph, returning packages grouped
77    /// into installation stages.
78    ///
79    /// Packages with no dependencies (leaf nodes) appear in the first stage,
80    /// followed by packages that only depend on those in the first stage,
81    /// and so on. This ensures that dependencies are always installed
82    /// before their dependents.
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if a cycle is detected in the dependency graph or if
87    /// there's an internal inconsistency in the graph structure.
88    pub fn toposort(&self) -> Result<Vec<Vec<String>>> {
89        let mut in_degree: HashMap<String, usize> =
90            self.nodes.keys().map(|id| (id.clone(), 0)).collect();
91
92        for (from, to_set) in &self.adj {
93            if from == "$root" {
94                continue;
95            }
96            for to in to_set {
97                if let Some(degree) = in_degree.get_mut(to) {
98                    *degree += 1;
99                }
100            }
101        }
102
103        let mut queue: VecDeque<String> = in_degree
104            .iter()
105            .filter(|&(_, d)| *d == 0)
106            .map(|(id, _)| id.clone())
107            .collect();
108
109        let mut stages = Vec::new();
110        let mut count = 0;
111
112        while !queue.is_empty() {
113            let mut stage = Vec::new();
114            for _ in 0..queue.len() {
115                let u = queue.pop_front().ok_or_else(|| {
116                    anyhow!("Queue length inconsistency in toposort")
117                })?;
118                stage.push(u.clone());
119                count += 1;
120
121                if let Some(neighbors) = self.adj.get(&u) {
122                    for v_id in neighbors {
123                        let degree =
124                            in_degree.get_mut(v_id).ok_or_else(|| {
125                                anyhow!("v_id '{v_id}' missing from in_degree")
126                            })?;
127                        *degree -= 1;
128                        if *degree == 0 {
129                            queue.push_back(v_id.clone());
130                        }
131                    }
132                }
133            }
134            stages.push(stage);
135        }
136
137        if count != self.nodes.len() {
138            return Err(anyhow!("Cycle detected in dependency graph."));
139        }
140
141        stages.reverse();
142
143        Ok(stages)
144    }
145}
146
147/// Extracts Zoi-specific dependencies from a `DependenciesV2` struct.
148fn extract_zoi_dependencies(deps: &types::DependenciesV2) -> Vec<String> {
149    let mut zoi_deps = Vec::new();
150
151    let mut to_process = deps.runtime.clone();
152    for b in &deps.build {
153        to_process.extend(b.packages.clone());
154    }
155
156    for dep_str in to_process {
157        if let Ok(dep) = dependencies::parse_dependency_string(&dep_str)
158            && dep.manager == "zoi"
159        {
160            zoi_deps.push(dep.package.to_string());
161        }
162    }
163    zoi_deps
164}
165
166/// Builds a dependency graph from a list of locked packages.
167///
168/// # Errors
169///
170/// Returns an error if any package in the lockfile cannot be resolved or if
171/// there are issues parsing dependency strings.
172pub fn build_graph_from_locked_packages(
173    locked_packages: &[FrozenLockPackage],
174    scope_override: Option<types::Scope>,
175    quiet: bool,
176    yes: bool
177) -> Result<(DependencyGraph, Vec<String>)> {
178    if !quiet {
179        println!(":: Resolving dependencies from zoi.lock...");
180    }
181
182    let mut graph = DependencyGraph::new();
183    let mut reverse_deps: HashMap<String, Vec<String>> = HashMap::new();
184    let mut pkg_id_to_locked_deps: HashMap<String, Vec<String>> =
185        HashMap::new();
186    let mut pkg_id_to_locked_deps_v2: HashMap<
187        String,
188        Option<types::DependenciesV2>
189    > = HashMap::new();
190
191    for locked in locked_packages {
192        let request = resolve::parse_source_string(&locked.source)?;
193        let (pkg, version_str, _, pkg_lua_path, handle, repo_type, git_sha) =
194            resolve::resolve_package_and_version(
195                &locked.source,
196                scope_override,
197                quiet,
198                yes
199            )?;
200
201        let mut pkg = pkg;
202        if let Some(scope) = scope_override {
203            pkg.scope = scope;
204        }
205
206        let pkg_id = if let Some(sub) = &request.sub_package {
207            format!("{}@{}:{}", pkg.name, version_str, sub)
208        } else {
209            format!("{}@{}", pkg.name, version_str)
210        };
211
212        let flattened_deps = if let Some(deps) = &locked.dependencies {
213            extract_zoi_dependencies(deps)
214        } else {
215            Vec::new()
216        };
217
218        pkg_id_to_locked_deps.insert(pkg_id.clone(), flattened_deps.clone());
219        pkg_id_to_locked_deps_v2
220            .insert(pkg_id.clone(), locked.dependencies.clone());
221
222        graph.nodes.insert(
223            pkg_id.clone(),
224            InstallNode {
225                description: pkg.description.clone(),
226                repo_type: repo_type
227                    .unwrap_or_else(|| "unofficial".to_string()),
228                pkg,
229                version: version_str,
230                revision: locked.revision.clone(),
231                sub_package: request.sub_package.clone(),
232                reason: if locked.direct {
233                    InstallReason::Direct
234                } else {
235                    InstallReason::Dependency {
236                        parent: "unknown".to_string()
237                    }
238                },
239                source: pkg_lua_path.to_string_lossy().to_string(),
240                registry_handle: handle
241                    .unwrap_or_else(|| "zoidberg".to_string()),
242                chosen_options: locked.chosen_options.clone(),
243                chosen_optionals: locked.chosen_optionals.clone(),
244                dependencies: Vec::new(),
245                git_sha: locked.git_sha.clone().or(git_sha)
246            }
247        );
248    }
249
250    for pkg_id in graph.nodes.keys() {
251        let mut children = HashSet::new();
252        if let Some(locked_deps) = pkg_id_to_locked_deps.get(pkg_id) {
253            for dep_pkg_source in locked_deps {
254                let dep_req = resolve::parse_source_string(dep_pkg_source)?;
255                let dep_version =
256                    dep_req.version_spec.as_deref().unwrap_or_default();
257                let dep_id = if let Some(sub) = dep_req.sub_package {
258                    format!("{}@{}:{}", dep_req.name, dep_version, sub)
259                } else {
260                    format!("{}@{}", dep_req.name, dep_version)
261                };
262
263                if graph.nodes.contains_key(&dep_id) {
264                    children.insert(dep_id.clone());
265                    reverse_deps
266                        .entry(dep_id)
267                        .or_default()
268                        .push(pkg_id.clone());
269                }
270            }
271        }
272        graph.adj.insert(pkg_id.clone(), children);
273    }
274
275    let direct_ids: Vec<String> = graph
276        .nodes
277        .iter()
278        .filter_map(|(pkg_id, node)| {
279            let is_direct = matches!(node.reason, InstallReason::Direct)
280                || !reverse_deps.contains_key(pkg_id);
281            is_direct.then(|| pkg_id.clone())
282        })
283        .collect();
284
285    graph
286        .adj
287        .insert("$root".to_string(), direct_ids.iter().cloned().collect());
288
289    let direct_id_set: HashSet<String> = direct_ids.iter().cloned().collect();
290
291    let mut parent_map = HashMap::new();
292    for (pkg_id, parents) in &reverse_deps {
293        if !direct_id_set.contains(pkg_id)
294            && let Some(parent_id) = parents.first()
295            && let Some(parent_node) = graph.nodes.get(parent_id)
296        {
297            let parent_source = zoi_resolver::local::package_source_string(
298                &parent_node.registry_handle,
299                &parent_node.pkg.repo,
300                &parent_node.pkg.name,
301                parent_node.sub_package.as_deref(),
302                &parent_node.version
303            );
304            parent_map.insert(pkg_id.clone(), parent_source);
305        }
306    }
307
308    let resolved_child_sources: HashMap<String, Vec<String>> = graph
309        .adj
310        .iter()
311        .map(|(pkg_id, children)| {
312            let deps = children
313                .iter()
314                .filter_map(|child| {
315                    graph.nodes.get(child).map(|child_node| {
316                        format!(
317                            "zoi:{}",
318                            zoi_resolver::local::package_source_string(
319                                &child_node.registry_handle,
320                                &child_node.pkg.repo,
321                                &child_node.pkg.name,
322                                child_node.sub_package.as_deref(),
323                                &child_node.version,
324                            )
325                        )
326                    })
327                })
328                .collect::<Vec<_>>();
329            (pkg_id.clone(), deps)
330        })
331        .collect();
332
333    for (pkg_id, node) in &mut graph.nodes {
334        if direct_id_set.contains(pkg_id) {
335            node.reason = InstallReason::Direct;
336        } else if let Some(parent_source) =
337            parent_map.get(pkg_key_as_str(pkg_id))
338        {
339            node.reason = InstallReason::Dependency {
340                parent: parent_source.clone()
341            };
342        }
343
344        let mut resolved_deps = resolved_child_sources
345            .get(pkg_id)
346            .cloned()
347            .unwrap_or_default();
348
349        if let Some(Some(deps_v2)) = pkg_id_to_locked_deps_v2.get(pkg_id) {
350            for dep_str in &deps_v2.runtime {
351                if let Ok(dep_req) =
352                    dependencies::parse_dependency_string(dep_str)
353                    && dep_req.manager != "zoi"
354                {
355                    resolved_deps.push(dep_str.clone());
356                }
357            }
358        }
359        node.dependencies = resolved_deps;
360    }
361
362    Ok((graph, Vec::new()))
363}
364
365/// Helper function to return a reference to a string.
366fn pkg_key_as_str(s: &String) -> &String {
367    s
368}
369
370/// Computes the complete dependency graph for a set of input sources.
371///
372/// This is the core "Resolution Engine" of Zoi. It:
373/// - Maps each source string to a `PkgName` in the `PubGrub` solver.
374/// - Uses the `PubGrub` SAT algorithm to find a set of versions that satisfy
375///   all `SemVer` requirements and constraints.
376/// - Handles backtracking and human-readable error reporting on failure.
377/// - Returns a `DependencyGraph` containing all nodes (packages) and edges
378///   (dependencies).
379///
380/// # Errors
381///
382/// Returns an error if dependency resolution fails, if any source strings are
383/// invalid, or if version requirements cannot be satisfied.
384pub fn resolve_dependency_graph(
385    initial_sources: &[String],
386    scope_override: Option<types::Scope>,
387    _force: bool,
388    yes: bool,
389    all_optional: bool,
390    build_type: Option<&str>,
391    quiet: bool,
392    project_config: Option<zoi_project::config::ProjectConfig>
393) -> Result<(DependencyGraph, Vec<String>)> {
394    if !quiet {
395        println!(":: Resolving dependencies...");
396    }
397
398    let mut non_zoi_deps = Vec::new();
399    let mut root_deps = FxHashMap::default();
400
401    for source in initial_sources {
402        let parse_result = dependencies::parse_dependency_string(source);
403        if let Ok(dep) = parse_result
404            && dep.manager != "zoi"
405        {
406            non_zoi_deps.push(source.clone());
407            continue;
408        }
409
410        let request = resolve::parse_source_string(source)?;
411        let resolved =
412            resolve::resolve_source(source, scope_override, quiet, yes)?;
413
414        let pkg_name = PkgName {
415            name: request.name,
416            sub_package: request.sub_package,
417            repo: resolved.repo_name.unwrap_or_default(),
418            registry: resolved
419                .registry_handle
420                .unwrap_or_else(|| "zoidberg".to_string()),
421            explicit_source: matches!(
422                resolved.source_type,
423                zoi_core::types::SourceType::LocalFile
424                    | zoi_core::types::SourceType::Url
425                    | zoi_core::types::SourceType::GitRepo(_)
426            )
427            .then(|| source.clone())
428        };
429
430        let range = if request.version_spec.is_some() {
431            let resolved_version = resolve::resolve_requested_version_spec(
432                source,
433                scope_override,
434                true,
435                true
436            )?
437            .ok_or_else(|| {
438                anyhow!("version spec missing despite check for '{source}'")
439            })?;
440            crate::pubgrub::semver_to_range(&resolved_version)
441        } else {
442            Ranges::full()
443        };
444
445        root_deps.insert(pkg_name, range);
446    }
447
448    let project_config =
449        project_config.or_else(|| zoi_project::config::load().ok());
450
451    let provider = ZoiDependencyProvider::new(
452        root_deps,
453        initial_sources.to_vec(),
454        scope_override,
455        quiet,
456        yes,
457        all_optional,
458        project_config,
459        build_type.map(std::string::ToString::to_string)
460    )?;
461    let root_pkg = PkgName {
462        name: "$root".to_string(),
463        sub_package: None,
464        repo: String::new(),
465        registry: String::new(),
466        explicit_source: None
467    };
468    let root_version = SemVersion {
469        epoch: 0,
470        v: Version::new(0, 0, 0),
471        original: "0.0.0".to_string()
472    };
473
474    let mut final_nodes = HashMap::new();
475    let mut final_adj: HashMap<String, HashSet<String>> = HashMap::new();
476
477    match pubgrub_resolve::<ZoiDependencyProvider>(
478        &provider,
479        root_pkg,
480        root_version
481    ) {
482        Ok(solution) => {
483            for (name, version) in solution.iter() {
484                if name.name == "$root" {
485                    continue;
486                }
487
488                let source = name
489                    .explicit_source
490                    .clone()
491                    .unwrap_or_else(|| format!("{name}@{version}"));
492                let (
493                    pkg,
494                    version_str,
495                    _,
496                    pkg_lua_path,
497                    handle,
498                    repo_type,
499                    git_sha
500                ) = resolve::resolve_package_and_version(
501                    &source,
502                    scope_override,
503                    quiet,
504                    yes
505                )?;
506
507                let mut pkg = pkg;
508                if let Some(s) = scope_override {
509                    pkg.scope = s;
510                }
511
512                let pkg_id = if let Some(sub) = &name.sub_package {
513                    format!("{}@{}:{}", pkg.name, version_str, sub)
514                } else {
515                    format!("{}@{}", pkg.name, version_str)
516                };
517
518                let cache_key = (name.clone(), version.clone());
519                let (chosen_options, chosen_optionals, all_req_deps) = provider
520                    .chosen_cache
521                    .borrow()
522                    .get(&cache_key)
523                    .cloned()
524                    .unwrap_or_default();
525
526                for dep_str in &all_req_deps {
527                    if let Ok(dep_req) =
528                        zoi_deps::parse_dependency_string(dep_str)
529                        && dep_req.manager != "zoi"
530                    {
531                        non_zoi_deps.push(dep_str.clone());
532                    }
533                }
534
535                let node = InstallNode {
536                    description: pkg.description.clone(),
537                    repo_type: repo_type
538                        .unwrap_or_else(|| "unofficial".to_string()),
539                    pkg: pkg.clone(),
540                    version: version_str,
541                    revision: pkg.revision.clone(),
542                    sub_package: name.sub_package.clone(),
543                    reason: InstallReason::Direct,
544                    source: pkg_lua_path.to_string_lossy().to_string(),
545                    registry_handle: handle
546                        .unwrap_or_else(|| "zoidberg".to_string()),
547                    chosen_options,
548                    chosen_optionals,
549                    dependencies: all_req_deps,
550                    git_sha
551                };
552                final_nodes.insert(pkg_id, node);
553            }
554
555            for (name, version) in solution.iter() {
556                let from_id = if name.name == "$root" {
557                    "$root".to_string()
558                } else if let Some(sub) = &name.sub_package {
559                    format!("{}@{}:{}", name.name, version, sub)
560                } else {
561                    format!("{}@{}", name.name, version)
562                };
563
564                if let Ok(pubgrub::Dependencies::Available(deps)) =
565                    provider.get_dependencies(name, version)
566                {
567                    for (dep_name, _) in deps {
568                        if let Some(dep_version) = solution.get(&dep_name) {
569                            let to_id = if let Some(sub) = &dep_name.sub_package
570                            {
571                                format!(
572                                    "{}@{}:{}",
573                                    dep_name.name, dep_version, sub
574                                )
575                            } else {
576                                format!("{}@{}", dep_name.name, dep_version)
577                            };
578                            final_adj
579                                .entry(from_id.clone())
580                                .or_default()
581                                .insert(to_id);
582                        }
583                    }
584                }
585            }
586            let mut direct_ids = HashSet::new();
587            if let Some(root_children) = final_adj.get("$root") {
588                direct_ids.clone_from(root_children);
589            }
590
591            let mut parent_map = HashMap::new();
592            for (from, to_set) in &final_adj {
593                if from != "$root"
594                    && let Some(parent_node) = final_nodes.get(from)
595                {
596                    let parent_id = format!(
597                        "#{}@{}/{}@{}",
598                        parent_node.registry_handle,
599                        parent_node.pkg.repo,
600                        parent_node.pkg.name,
601                        parent_node.version
602                    );
603                    for to in to_set {
604                        parent_map
605                            .entry(to.clone())
606                            .or_insert(parent_id.clone());
607                    }
608                }
609            }
610
611            let resolved_child_sources: HashMap<String, Vec<String>> =
612                final_adj
613                    .iter()
614                    .map(|(pkg_id, children)| {
615                        let deps = children
616                            .iter()
617                            .filter_map(|child| {
618                                final_nodes.get(child).map(|child_node| {
619                                    format!(
620                                    "zoi:{}",
621                                    zoi_resolver::local::package_source_string(
622                                        &child_node.registry_handle,
623                                        &child_node.pkg.repo,
624                                        &child_node.pkg.name,
625                                        child_node.sub_package.as_deref(),
626                                        &child_node.version,
627                                    )
628                                )
629                                })
630                            })
631                            .collect::<Vec<_>>();
632                        (pkg_id.clone(), deps)
633                    })
634                    .collect();
635
636            for (pkg_id, node) in &mut final_nodes {
637                let child_sources = resolved_child_sources
638                    .get(pkg_id)
639                    .cloned()
640                    .unwrap_or_default();
641
642                if direct_ids.contains(pkg_id) {
643                    node.reason = InstallReason::Direct;
644                } else {
645                    let parent_id = parent_map
646                        .get(pkg_id)
647                        .cloned()
648                        .unwrap_or_else(|| "unknown".to_string());
649                    node.reason =
650                        InstallReason::Dependency { parent: parent_id };
651                }
652
653                let mut resolved_deps = child_sources;
654                for dep_str in &node.dependencies {
655                    if let Ok(dep_req) =
656                        zoi_deps::parse_dependency_string(dep_str)
657                        && dep_req.manager != "zoi"
658                    {
659                        resolved_deps.push(dep_str.clone());
660                    }
661                }
662                node.dependencies = resolved_deps;
663            }
664        }
665        Err(e) => {
666            let error_msg = format!("{e:?}");
667            if error_msg.contains("DependencyProviderError") {
668                return Err(anyhow!(
669                    "Dependency resolution failed: {error_msg}"
670                ));
671            }
672            return Err(anyhow!("Dependency resolution failed: {e}"));
673        }
674    }
675
676    Ok((
677        DependencyGraph {
678            nodes: final_nodes,
679            adj: final_adj
680        },
681        non_zoi_deps
682    ))
683}