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