1use std::collections::{HashMap, HashSet, VecDeque};
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use tracing::{debug, error, warn};
7
8use crate::dependency::{Dependency, DependencyTag};
9use crate::error::{Result, SpsError};
10use crate::formulary::Formulary;
11use crate::keg::KegRegistry;
12use crate::model::formula::Formula;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum NodeInstallStrategy {
16 BottlePreferred,
17 SourceOnly,
18 BottleOrFail,
19}
20
21#[derive(Debug, Clone, Default)]
22pub struct PerTargetInstallPreferences {
23 pub force_source_build_targets: HashSet<String>,
24 pub force_bottle_only_targets: HashSet<String>,
25}
26
27pub struct ResolutionContext<'a> {
28 pub formulary: &'a Formulary,
29 pub keg_registry: &'a KegRegistry,
30 pub sps_prefix: &'a Path,
31 pub include_optional: bool,
32 pub include_test: bool,
33 pub skip_recommended: bool,
34 pub initial_target_preferences: &'a PerTargetInstallPreferences,
35 pub build_all_from_source: bool,
36 pub cascade_source_preference_to_dependencies: bool,
37 pub has_bottle_for_current_platform: fn(&Formula) -> bool,
38 pub initial_target_actions: &'a HashMap<String, crate::pipeline::JobAction>,
39}
40
41#[derive(Debug, Clone)]
42pub struct ResolvedDependency {
43 pub formula: Arc<Formula>,
44 pub keg_path: Option<PathBuf>,
45 pub opt_path: Option<PathBuf>,
46 pub status: ResolutionStatus,
47 pub accumulated_tags: DependencyTag,
48 pub determined_install_strategy: NodeInstallStrategy,
49 pub failure_reason: Option<String>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ResolutionStatus {
54 Installed,
55 Missing,
56 Requested,
57 SkippedOptional,
58 NotFound,
59 Failed,
60}
61
62#[derive(Debug, Clone, Default)]
63pub struct ResolvedGraph {
64 pub install_plan: Vec<ResolvedDependency>,
65 pub build_dependency_opt_paths: Vec<PathBuf>,
66 pub runtime_dependency_opt_paths: Vec<PathBuf>,
67 pub resolution_details: HashMap<String, ResolvedDependency>,
68}
69
70impl ResolvedGraph {
72 pub fn empty() -> Self {
73 Default::default()
74 }
75}
76
77pub struct DependencyResolver<'a> {
78 context: ResolutionContext<'a>,
79 formula_cache: HashMap<String, Arc<Formula>>,
80 visiting: HashSet<String>,
81 resolution_details: HashMap<String, ResolvedDependency>,
82 errors: HashMap<String, Arc<SpsError>>,
83}
84
85impl<'a> DependencyResolver<'a> {
86 pub fn new(context: ResolutionContext<'a>) -> Self {
87 Self {
88 context,
89 formula_cache: HashMap::new(),
90 visiting: HashSet::new(),
91 resolution_details: HashMap::new(),
92 errors: HashMap::new(),
93 }
94 }
95
96 fn determine_node_install_strategy(
97 &self,
98 formula_name: &str,
99 formula_arc: &Arc<Formula>,
100 is_initial_target: bool,
101 requesting_parent_strategy: Option<NodeInstallStrategy>,
102 ) -> NodeInstallStrategy {
103 if is_initial_target {
104 if self
105 .context
106 .initial_target_preferences
107 .force_source_build_targets
108 .contains(formula_name)
109 {
110 return NodeInstallStrategy::SourceOnly;
111 }
112 if self
113 .context
114 .initial_target_preferences
115 .force_bottle_only_targets
116 .contains(formula_name)
117 {
118 return NodeInstallStrategy::BottleOrFail;
119 }
120 }
121
122 if self.context.build_all_from_source {
123 return NodeInstallStrategy::SourceOnly;
124 }
125
126 if self.context.cascade_source_preference_to_dependencies
127 && matches!(
128 requesting_parent_strategy,
129 Some(NodeInstallStrategy::SourceOnly)
130 )
131 {
132 return NodeInstallStrategy::SourceOnly;
133 }
134 if matches!(
135 requesting_parent_strategy,
136 Some(NodeInstallStrategy::BottleOrFail)
137 ) {
138 return NodeInstallStrategy::BottleOrFail;
139 }
140
141 let strategy = if (self.context.has_bottle_for_current_platform)(formula_arc) {
142 NodeInstallStrategy::BottlePreferred
143 } else {
144 NodeInstallStrategy::SourceOnly
145 };
146
147 debug!(
148 "Install strategy for '{formula_name}': {:?} (initial_target={is_initial_target}, parent={:?}, bottle_available={})",
149 strategy,
150 requesting_parent_strategy,
151 (self.context.has_bottle_for_current_platform)(formula_arc)
152 );
153 strategy
154 }
155
156 pub fn resolve_targets(&mut self, targets: &[String]) -> Result<ResolvedGraph> {
157 debug!("Starting dependency resolution for targets: {:?}", targets);
158 self.visiting.clear();
159 self.resolution_details.clear();
160 self.errors.clear();
161
162 for target_name in targets {
163 if let Err(e) = self.resolve_recursive(target_name, DependencyTag::RUNTIME, true, None)
164 {
165 self.errors.insert(target_name.clone(), Arc::new(e));
166 warn!(
167 "Resolution failed for target '{}', but continuing for others.",
168 target_name
169 );
170 }
171 }
172
173 debug!(
174 "Raw resolved map after initial pass: {:?}",
175 self.resolution_details
176 .iter()
177 .map(|(k, v)| (k.clone(), v.status, v.accumulated_tags))
178 .collect::<Vec<_>>()
179 );
180
181 let sorted_list = match self.topological_sort() {
182 Ok(list) => list,
183 Err(e @ SpsError::DependencyError(_)) => {
184 error!("Topological sort failed due to dependency cycle: {}", e);
185 return Err(e);
186 }
187 Err(e) => {
188 error!("Topological sort failed: {}", e);
189 return Err(e);
190 }
191 };
192
193 let install_plan: Vec<ResolvedDependency> = sorted_list
194 .into_iter()
195 .filter(|dep| {
196 matches!(
197 dep.status,
198 ResolutionStatus::Missing | ResolutionStatus::Requested
199 )
200 })
201 .collect();
202
203 let mut build_paths = Vec::new();
204 let mut runtime_paths = Vec::new();
205 let mut seen_build_paths = HashSet::new();
206 let mut seen_runtime_paths = HashSet::new();
207
208 for dep in self.resolution_details.values() {
209 if matches!(
210 dep.status,
211 ResolutionStatus::Installed
212 | ResolutionStatus::Requested
213 | ResolutionStatus::Missing
214 ) {
215 if let Some(opt_path) = &dep.opt_path {
216 if dep.accumulated_tags.contains(DependencyTag::BUILD)
217 && seen_build_paths.insert(opt_path.clone())
218 {
219 debug!("Adding build dep path: {}", opt_path.display());
220 build_paths.push(opt_path.clone());
221 }
222 if dep.accumulated_tags.intersects(
223 DependencyTag::RUNTIME
224 | DependencyTag::RECOMMENDED
225 | DependencyTag::OPTIONAL,
226 ) && seen_runtime_paths.insert(opt_path.clone())
227 {
228 debug!("Adding runtime dep path: {}", opt_path.display());
229 runtime_paths.push(opt_path.clone());
230 }
231 } else if dep.status != ResolutionStatus::NotFound
232 && dep.status != ResolutionStatus::Failed
233 {
234 debug!(
235 "Warning: No opt_path found for resolved dependency {} ({:?})",
236 dep.formula.name(),
237 dep.status
238 );
239 }
240 }
241 }
242
243 if !self.errors.is_empty() {
244 warn!(
245 "Resolution encountered errors for specific targets: {:?}",
246 self.errors
247 .iter()
248 .map(|(k, v)| (k, v.to_string()))
249 .collect::<HashMap<_, _>>()
250 );
251 }
252
253 debug!(
254 "Final installation plan (needs install/build): {:?}",
255 install_plan
256 .iter()
257 .map(|d| (d.formula.name(), d.status))
258 .collect::<Vec<_>>()
259 );
260 debug!(
261 "Collected build dependency paths: {:?}",
262 build_paths.iter().map(|p| p.display()).collect::<Vec<_>>()
263 );
264 debug!(
265 "Collected runtime dependency paths: {:?}",
266 runtime_paths
267 .iter()
268 .map(|p| p.display())
269 .collect::<Vec<_>>()
270 );
271
272 Ok(ResolvedGraph {
273 install_plan,
274 build_dependency_opt_paths: build_paths,
275 runtime_dependency_opt_paths: runtime_paths,
276 resolution_details: self.resolution_details.clone(),
277 })
278 }
279
280 fn resolve_recursive(
281 &mut self,
282 name: &str,
283 tags_from_parent_edge: DependencyTag,
284 is_initial_target: bool,
285 requesting_parent_strategy: Option<NodeInstallStrategy>,
286 ) -> Result<()> {
287 debug!(
288 "Resolving: {} (requested as {:?}, is_target: {})",
289 name, tags_from_parent_edge, is_initial_target
290 );
291
292 if self.visiting.contains(name) {
293 error!("Dependency cycle detected involving: {}", name);
294 return Err(SpsError::DependencyError(format!(
295 "Dependency cycle detected involving '{name}'"
296 )));
297 }
298
299 if let Some(existing) = self.resolution_details.get_mut(name) {
300 let original_status = existing.status;
301 let original_tags = existing.accumulated_tags;
302
303 let mut new_status = original_status;
304 if is_initial_target && new_status == ResolutionStatus::Missing {
305 new_status = ResolutionStatus::Requested;
306 }
307 if new_status == ResolutionStatus::SkippedOptional
308 && (tags_from_parent_edge.contains(DependencyTag::RUNTIME)
309 || tags_from_parent_edge.contains(DependencyTag::BUILD)
310 || (tags_from_parent_edge.contains(DependencyTag::RECOMMENDED)
311 && !self.context.skip_recommended)
312 || (is_initial_target && self.context.include_optional))
313 {
314 new_status = if existing.keg_path.is_some() {
315 ResolutionStatus::Installed
316 } else if is_initial_target {
317 ResolutionStatus::Requested
318 } else {
319 ResolutionStatus::Missing
320 };
321 }
322
323 let mut needs_revisit = false;
324 if new_status != original_status {
325 debug!(
326 "Updating status for '{name}' from {:?} to {:?}",
327 original_status, new_status
328 );
329 existing.status = new_status;
330 needs_revisit = true;
331 }
332
333 let combined_tags = original_tags | tags_from_parent_edge;
334 if combined_tags != original_tags {
335 debug!(
336 "Updating tags for '{name}' from {:?} to {:?}",
337 original_tags, combined_tags
338 );
339 existing.accumulated_tags = combined_tags;
340 needs_revisit = true;
341 }
342
343 if !needs_revisit {
344 debug!("'{}' already resolved with compatible status/tags.", name);
345 return Ok(());
346 }
347
348 debug!(
349 "Re-evaluating dependencies for '{}' due to status/tag update",
350 name
351 );
352 } else {
353 self.visiting.insert(name.to_string());
354
355 let formula_arc = match self.formula_cache.get(name) {
356 Some(f) => f.clone(),
357 None => {
358 debug!("Loading formula definition for '{}'", name);
359 match self.context.formulary.load_formula(name) {
360 Ok(f) => {
361 let arc = Arc::new(f);
362 self.formula_cache.insert(name.to_string(), arc.clone());
363 arc
364 }
365 Err(e) => {
366 error!("Failed to load formula definition for '{}': {}", name, e);
367 let msg = e.to_string();
368 self.resolution_details.insert(
369 name.to_string(),
370 ResolvedDependency {
371 formula: Arc::new(Formula::placeholder(name)),
372 keg_path: None,
373 opt_path: None,
374 status: ResolutionStatus::NotFound,
375 accumulated_tags: tags_from_parent_edge,
376 determined_install_strategy:
377 NodeInstallStrategy::BottlePreferred,
378 failure_reason: Some(msg.clone()),
379 },
380 );
381 self.visiting.remove(name);
382 self.errors
383 .insert(name.to_string(), Arc::new(SpsError::NotFound(msg)));
384 return Ok(());
385 }
386 }
387 }
388 };
389
390 let current_node_strategy = self.determine_node_install_strategy(
391 name,
392 &formula_arc,
393 is_initial_target,
394 requesting_parent_strategy,
395 );
396
397 let (status, keg_path) = match current_node_strategy {
398 NodeInstallStrategy::SourceOnly => (
399 if is_initial_target {
400 ResolutionStatus::Requested
401 } else {
402 ResolutionStatus::Missing
403 },
404 None,
405 ),
406 NodeInstallStrategy::BottlePreferred | NodeInstallStrategy::BottleOrFail => {
407 if let Some(keg) = self.context.keg_registry.get_installed_keg(name)? {
408 let should_request_upgrade = is_initial_target
411 && self
412 .context
413 .initial_target_actions
414 .get(name)
415 .map(|action| {
416 matches!(action, crate::pipeline::JobAction::Upgrade { .. })
417 })
418 .unwrap_or(false);
419
420 debug!("[Resolver] Package '{}': is_initial_target={}, should_request_upgrade={}, action={:?}",
421 name, is_initial_target, should_request_upgrade,
422 self.context.initial_target_actions.get(name));
423
424 if should_request_upgrade {
425 debug!("[Resolver] Marking upgrade target '{}' as Requested (was installed)", name);
426 (ResolutionStatus::Requested, Some(keg.path))
427 } else {
428 debug!("[Resolver] Marking '{}' as Installed (normal case)", name);
429 (ResolutionStatus::Installed, Some(keg.path))
430 }
431 } else {
432 debug!(
433 "[Resolver] Package '{}' not installed, marking as {}",
434 name,
435 if is_initial_target {
436 "Requested"
437 } else {
438 "Missing"
439 }
440 );
441 (
442 if is_initial_target {
443 ResolutionStatus::Requested
444 } else {
445 ResolutionStatus::Missing
446 },
447 None,
448 )
449 }
450 }
451 };
452
453 debug!(
454 "Initial status for '{}': {:?}, keg: {:?}, opt: {}",
455 name,
456 status,
457 keg_path,
458 self.context.keg_registry.get_opt_path(name).display()
459 );
460
461 self.resolution_details.insert(
462 name.to_string(),
463 ResolvedDependency {
464 formula: formula_arc.clone(),
465 keg_path,
466 opt_path: Some(self.context.keg_registry.get_opt_path(name)),
467 status,
468 accumulated_tags: tags_from_parent_edge,
469 determined_install_strategy: current_node_strategy,
470 failure_reason: None,
471 },
472 );
473 }
474
475 let dep_snapshot = self
476 .resolution_details
477 .get(name)
478 .expect("just inserted")
479 .clone();
480
481 if matches!(
482 dep_snapshot.status,
483 ResolutionStatus::Failed | ResolutionStatus::NotFound
484 ) {
485 self.visiting.remove(name);
486 return Ok(());
487 }
488
489 for dep in dep_snapshot.formula.dependencies()? {
490 let dep_name = &dep.name;
491 let dep_tags = dep.tags;
492 let parent_name = dep_snapshot.formula.name();
493 let parent_strategy = dep_snapshot.determined_install_strategy;
494
495 debug!(
496 "RESOLVER: Evaluating edge: parent='{}' ({:?}), child='{}' ({:?})",
497 parent_name, parent_strategy, dep_name, dep_tags
498 );
499
500 if !self.should_consider_dependency(&dep) {
501 if !self.resolution_details.contains_key(dep_name.as_str()) {
502 debug!("RESOLVER: Child '{}' of '{}' globally SKIPPED (e.g. optional/test not included). Tags: {:?}", dep_name, parent_name, dep_tags);
503 }
504 continue;
505 }
506
507 let should_process = self.context.should_process_dependency_edge(
508 &dep_snapshot.formula,
509 dep_tags,
510 parent_strategy,
511 );
512
513 if !should_process {
514 debug!(
515 "RESOLVER: Edge from '{}' (Strategy: {:?}) to child '{}' (Tags: {:?}) was SKIPPED by should_process_dependency_edge.",
516 parent_name, parent_strategy, dep_name, dep_tags
517 );
518 continue;
519 }
520
521 debug!(
522 "RESOLVER: Edge from '{}' (Strategy: {:?}) to child '{}' (Tags: {:?}) WILL BE PROCESSED. Recursing.",
523 parent_name, parent_strategy, dep_name, dep_tags
524 );
525
526 if let Err(e) = self.resolve_recursive(dep_name, dep_tags, false, Some(parent_strategy))
527 {
528 warn!(
530 "Error resolving child dependency '{}' for parent '{}': {}",
531 dep_name, name, e
532 );
533 }
537 }
538
539 self.visiting.remove(name);
540 debug!("Finished resolving '{}'", name);
541 Ok(())
542 }
543
544 fn topological_sort(&self) -> Result<Vec<ResolvedDependency>> {
545 let mut in_degree: HashMap<String, usize> = HashMap::new();
546 let mut adj: HashMap<String, HashSet<String>> = HashMap::new();
547 let mut sorted_list = Vec::new();
548 let mut queue = VecDeque::new();
549
550 let relevant_nodes_map: HashMap<String, &ResolvedDependency> = self
551 .resolution_details
552 .iter()
553 .filter(|(_, dep)| {
554 !matches!(
555 dep.status,
556 ResolutionStatus::NotFound | ResolutionStatus::Failed
557 )
558 })
559 .map(|(k, v)| (k.clone(), v))
560 .collect();
561
562 for (parent_name, parent_rd) in &relevant_nodes_map {
563 adj.entry(parent_name.clone()).or_default();
564 in_degree.entry(parent_name.clone()).or_default();
565
566 let parent_strategy = parent_rd.determined_install_strategy;
567 if let Ok(dependencies) = parent_rd.formula.dependencies() {
568 for child_edge in dependencies {
569 let child_name = &child_edge.name;
570 if relevant_nodes_map.contains_key(child_name)
571 && self.context.should_process_dependency_edge(
572 &parent_rd.formula,
573 child_edge.tags,
574 parent_strategy,
575 )
576 && adj
577 .entry(parent_name.clone())
578 .or_default()
579 .insert(child_name.clone())
580 {
581 *in_degree.entry(child_name.clone()).or_default() += 1;
582 }
583 }
584 }
585 }
586
587 for name in relevant_nodes_map.keys() {
588 if *in_degree.get(name).unwrap_or(&1) == 0 {
589 queue.push_back(name.clone());
590 }
591 }
592
593 while let Some(u_name) = queue.pop_front() {
594 if let Some(resolved_dep) = relevant_nodes_map.get(&u_name) {
595 sorted_list.push((**resolved_dep).clone()); }
598 if let Some(neighbors) = adj.get(&u_name) {
599 for v_name in neighbors {
600 if relevant_nodes_map.contains_key(v_name) {
601 if let Some(degree) = in_degree.get_mut(v_name) {
603 *degree = degree.saturating_sub(1);
604 if *degree == 0 {
605 queue.push_back(v_name.clone());
606 }
607 }
608 }
609 }
610 }
611 }
612
613 let mut cycle_detected = false;
617 for (name, °ree) in &in_degree {
618 if degree > 0 && relevant_nodes_map.contains_key(name) {
619 if let Some(detail) = self.resolution_details.get(name) {
621 if self
622 .context
623 .should_consider_edge_globally(detail.accumulated_tags)
624 {
625 error!("Cycle detected or unresolved dependency: Node '{}' still has in-degree {}. Tags: {:?}", name, degree, detail.accumulated_tags);
626 cycle_detected = true;
627 } else {
628 debug!("Node '{}' has in-degree {} but was globally skipped. Tags: {:?}. Not a cycle error.", name, degree, detail.accumulated_tags);
629 }
630 }
631 }
632 }
633
634 if cycle_detected {
635 return Err(SpsError::DependencyError(
636 "Circular dependency detected or graph resolution incomplete".to_string(),
637 ));
638 }
639
640 Ok(sorted_list) }
642
643 fn should_consider_dependency(&self, dep: &Dependency) -> bool {
644 let tags = dep.tags;
645 if tags.contains(DependencyTag::TEST) && !self.context.include_test {
646 return false;
647 }
648 if tags.contains(DependencyTag::OPTIONAL) && !self.context.include_optional {
649 return false;
650 }
651 if tags.contains(DependencyTag::RECOMMENDED) && self.context.skip_recommended {
652 return false;
653 }
654 true
655 }
656}
657
658impl Formula {
659 fn placeholder(name: &str) -> Self {
660 Self {
661 name: name.to_string(),
662 stable_version_str: "0.0.0".to_string(),
663 version_semver: semver::Version::new(0, 0, 0), revision: 0,
665 desc: Some("Placeholder for unresolved formula".to_string()),
666 homepage: None,
667 url: String::new(),
668 sha256: String::new(),
669 mirrors: Vec::new(),
670 bottle: Default::default(),
671 dependencies: Vec::new(),
672 requirements: Vec::new(),
673 resources: Vec::new(),
674 install_keg_path: None,
675 }
676 }
677}
678
679impl<'a> ResolutionContext<'a> {
680 pub fn should_process_dependency_edge(
681 &self,
682 parent_formula_for_logging: &Arc<Formula>,
683 edge_tags: DependencyTag,
684 parent_node_determined_strategy: NodeInstallStrategy,
685 ) -> bool {
686 if !self.should_consider_edge_globally(edge_tags) {
687 debug!(
688 "Edge with tags {:?} for child of '{}' globally SKIPPED (e.g. optional/test not included).",
689 edge_tags, parent_formula_for_logging.name()
690 );
691 return false;
692 }
693
694 match parent_node_determined_strategy {
695 NodeInstallStrategy::BottlePreferred | NodeInstallStrategy::BottleOrFail => {
696 let is_purely_build_dependency = edge_tags.contains(DependencyTag::BUILD)
697 && !edge_tags.intersects(
698 DependencyTag::RUNTIME
699 | DependencyTag::RECOMMENDED
700 | DependencyTag::OPTIONAL,
701 );
702 if is_purely_build_dependency {
703 debug!("Edge with tags {:?} SKIPPED: Pure BUILD dependency of a bottle-installed parent '{}'.", edge_tags, parent_formula_for_logging.name());
704 return false;
705 }
706 }
707 NodeInstallStrategy::SourceOnly => {
708 }
710 }
711 debug!(
712 "Edge with tags {:?} WILL BE PROCESSED for parent '{}' (strategy {:?}).",
713 edge_tags,
714 parent_formula_for_logging.name(),
715 parent_node_determined_strategy
716 );
717 true
718 }
719
720 pub fn should_consider_edge_globally(&self, edge_tags: DependencyTag) -> bool {
721 if edge_tags.contains(DependencyTag::TEST) && !self.include_test {
722 return false;
723 }
724 if edge_tags.contains(DependencyTag::OPTIONAL) && !self.include_optional {
725 return false;
726 }
727 if edge_tags.contains(DependencyTag::RECOMMENDED) && self.skip_recommended {
728 return false;
729 }
730 true
731 }
732}