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#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct InstallNode {
24 pub pkg: Package,
26 pub version: String,
28 pub revision: String,
30 pub sub_package: Option<String>,
32 pub repo_type: String,
35 pub description: String,
37 pub reason: InstallReason,
40 pub source: String,
42 pub registry_handle: String,
44 pub chosen_options: Vec<String>,
46 pub chosen_optionals: Vec<String>,
48 pub dependencies: Vec<String>,
51 pub git_sha: Option<String>
54}
55
56#[derive(Default, Debug)]
62pub struct DependencyGraph {
63 pub nodes: HashMap<String, InstallNode>,
65 pub adj: HashMap<String, HashSet<String>>
68}
69
70impl DependencyGraph {
71 pub fn new() -> Self {
73 Self::default()
74 }
75
76 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
147fn 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
166pub 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
365fn pkg_key_as_str(s: &String) -> &String {
367 s
368}
369
370pub 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}