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#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct InstallNode {
22 pub pkg: Package,
24 pub version: String,
26 pub revision: String,
28 pub sub_package: Option<String>,
30 pub repo_type: String,
32 pub description: String,
34 pub reason: InstallReason,
36 pub source: String,
38 pub registry_handle: String,
40 pub chosen_options: Vec<String>,
42 pub chosen_optionals: Vec<String>,
44 pub dependencies: Vec<String>,
46 pub git_sha: Option<String>,
48}
49
50#[derive(Default, Debug)]
56pub struct DependencyGraph {
57 pub nodes: HashMap<String, InstallNode>,
59 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
323pub 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}