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