Skip to main content

zoi_install/
resolver.rs

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