sps_common/dependency/
resolver.rs

1// sps-common/src/dependency/resolver.rs
2use 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)] // Added Default
62pub 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
69// Added empty constructor
70impl 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                // Log the error but don't necessarily stop all resolution for this branch yet
497                warn!(
498                    "Error resolving child dependency '{}' for parent '{}': {}",
499                    dep_name, name, e
500                );
501                // Optionally, mark parent as failed if child error is critical
502                // self.errors.insert(name.to_string(), Arc::new(e)); // Storing error for parent if
503                // needed
504            }
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()); // Deref Arc then clone
564                                                            // ResolvedDependency
565            }
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                        // Check if neighbor is relevant
570                        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        // Check for cycles: if sorted_list's length doesn't match relevant_nodes_map's length
582        // (excluding already installed, skipped optional if not included, etc.)
583        // A more direct check is if in_degree still contains non-zero values for relevant nodes.
584        let mut cycle_detected = false;
585        for (name, &degree) in &in_degree {
586            if degree > 0 && relevant_nodes_map.contains_key(name) {
587                // Further check if this node should have been processed (not skipped globally)
588                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) // Return the full sorted list of relevant nodes
609    }
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), // Direct use
632            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                // Process all relevant (non-globally-skipped) dependencies for source builds
677            }
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}