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    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
70// Added empty constructor
71impl 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                        // Check if this is an upgrade target - if so, mark as Requested even if
409                        // installed
410                        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                // Log the error but don't necessarily stop all resolution for this branch yet
529                warn!(
530                    "Error resolving child dependency '{}' for parent '{}': {}",
531                    dep_name, name, e
532                );
533                // Optionally, mark parent as failed if child error is critical
534                // self.errors.insert(name.to_string(), Arc::new(e)); // Storing error for parent if
535                // needed
536            }
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()); // Deref Arc then clone
596                                                            // ResolvedDependency
597            }
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                        // Check if neighbor is relevant
602                        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        // Check for cycles: if sorted_list's length doesn't match relevant_nodes_map's length
614        // (excluding already installed, skipped optional if not included, etc.)
615        // A more direct check is if in_degree still contains non-zero values for relevant nodes.
616        let mut cycle_detected = false;
617        for (name, &degree) in &in_degree {
618            if degree > 0 && relevant_nodes_map.contains_key(name) {
619                // Further check if this node should have been processed (not skipped globally)
620                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) // Return the full sorted list of relevant nodes
641    }
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), // Direct use
664            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                // Process all relevant (non-globally-skipped) dependencies for source builds
709            }
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}