sps_common/dependency/
resolver.rs

1// FILE: sps-core/src/dependency/resolver.rs
2
3use 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// --- NodeInstallStrategy ---
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum NodeInstallStrategy {
18    BottlePreferred,
19    SourceOnly,
20    BottleOrFail,
21}
22
23// --- PerTargetInstallPreferences (local, minimal) ---
24#[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
30// --- ResolutionContext ---
31pub 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    /// Function pointer to check if a bottle is available for a formula.
42    pub has_bottle_for_current_platform: fn(&Formula) -> bool,
43}
44
45// --- ResolvedDependency ---
46#[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    // Store Arc<SpsError> instead of SpsError
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        // ── 1. Per‑target overrides ────────────────────────────────────────────────
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        // ── 2. Global --build‑from‑source flag ─────────────────────────────────────
123        if self.context.build_all_from_source {
124            return NodeInstallStrategy::SourceOnly;
125        }
126
127        // ── 3. Cascade rules from parent ───────────────────────────────────────────
128        if self.context.cascade_source_preference_to_dependencies
129            && matches!(
130                requesting_parent_strategy,
131                Some(NodeInstallStrategy::SourceOnly)
132            )
133        {
134            return NodeInstallStrategy::SourceOnly;
135        }
136        if matches!(
137            requesting_parent_strategy,
138            Some(NodeInstallStrategy::BottleOrFail)
139        ) {
140            return NodeInstallStrategy::BottleOrFail;
141        }
142
143        // ── 4. Default heuristic: bottle if we have one, else build ───────────────
144        let strategy = if (self.context.has_bottle_for_current_platform)(formula_arc) {
145            NodeInstallStrategy::BottlePreferred
146        } else {
147            NodeInstallStrategy::SourceOnly
148        };
149
150        debug!(
151            "Install strategy for '{formula_name}': {:?} (initial_target={is_initial_target}, parent={:?}, bottle_available={})",
152            strategy,
153            requesting_parent_strategy,
154            (self.context.has_bottle_for_current_platform)(formula_arc)
155        );
156        strategy
157    }
158
159    pub fn resolve_targets(&mut self, targets: &[String]) -> Result<ResolvedGraph> {
160        debug!("Starting dependency resolution for targets: {:?}", targets);
161        self.visiting.clear();
162        self.resolution_details.clear();
163        self.errors.clear();
164
165        for target_name in targets {
166            if let Err(e) = self.resolve_recursive(target_name, DependencyTag::RUNTIME, true, None)
167            {
168                self.errors.insert(target_name.clone(), Arc::new(e));
169                warn!(
170                    "Resolution failed for target '{}', but continuing for others.",
171                    target_name
172                );
173            }
174        }
175
176        debug!(
177            "Raw resolved map after initial pass: {:?}",
178            self.resolution_details
179                .iter()
180                .map(|(k, v)| (k.clone(), v.status, v.accumulated_tags))
181                .collect::<Vec<_>>()
182        );
183
184        let sorted_list = match self.topological_sort() {
185            Ok(list) => list,
186            Err(e @ SpsError::DependencyError(_)) => {
187                error!("Topological sort failed due to dependency cycle: {}", e);
188                return Err(e);
189            }
190            Err(e) => {
191                error!("Topological sort failed: {}", e);
192                return Err(e);
193            }
194        };
195
196        let install_plan: Vec<ResolvedDependency> = sorted_list
197            .into_iter()
198            .filter(|dep| {
199                matches!(
200                    dep.status,
201                    ResolutionStatus::Missing | ResolutionStatus::Requested
202                )
203            })
204            .collect();
205
206        let mut build_paths = Vec::new();
207        let mut runtime_paths = Vec::new();
208        let mut seen_build_paths = HashSet::new();
209        let mut seen_runtime_paths = HashSet::new();
210
211        for dep in self.resolution_details.values() {
212            if matches!(
213                dep.status,
214                ResolutionStatus::Installed
215                    | ResolutionStatus::Requested
216                    | ResolutionStatus::Missing
217            ) {
218                if let Some(opt_path) = &dep.opt_path {
219                    if dep.accumulated_tags.contains(DependencyTag::BUILD)
220                        && seen_build_paths.insert(opt_path.clone())
221                    {
222                        debug!("Adding build dep path: {}", opt_path.display());
223                        build_paths.push(opt_path.clone());
224                    }
225                    if dep.accumulated_tags.intersects(
226                        DependencyTag::RUNTIME
227                            | DependencyTag::RECOMMENDED
228                            | DependencyTag::OPTIONAL,
229                    ) && seen_runtime_paths.insert(opt_path.clone())
230                    {
231                        debug!("Adding runtime dep path: {}", opt_path.display());
232                        runtime_paths.push(opt_path.clone());
233                    }
234                } else if dep.status != ResolutionStatus::NotFound
235                    && dep.status != ResolutionStatus::Failed
236                {
237                    debug!(
238                        "Warning: No opt_path found for resolved dependency {} ({:?})",
239                        dep.formula.name(),
240                        dep.status
241                    );
242                }
243            }
244        }
245
246        if !self.errors.is_empty() {
247            warn!(
248                "Resolution encountered errors for specific targets: {:?}",
249                self.errors
250                    .iter()
251                    .map(|(k, v)| (k, v.to_string()))
252                    .collect::<HashMap<_, _>>()
253            );
254        }
255
256        debug!(
257            "Final installation plan (needs install/build): {:?}",
258            install_plan
259                .iter()
260                .map(|d| (d.formula.name(), d.status))
261                .collect::<Vec<_>>()
262        );
263        debug!(
264            "Collected build dependency paths: {:?}",
265            build_paths.iter().map(|p| p.display()).collect::<Vec<_>>()
266        );
267        debug!(
268            "Collected runtime dependency paths: {:?}",
269            runtime_paths
270                .iter()
271                .map(|p| p.display())
272                .collect::<Vec<_>>()
273        );
274
275        Ok(ResolvedGraph {
276            install_plan,
277            build_dependency_opt_paths: build_paths,
278            runtime_dependency_opt_paths: runtime_paths,
279            resolution_details: self.resolution_details.clone(),
280        })
281    }
282
283    /// Walk a dependency node, collecting status and propagating errors
284    fn resolve_recursive(
285        &mut self,
286        name: &str,
287        tags_from_parent_edge: DependencyTag,
288        is_initial_target: bool,
289        requesting_parent_strategy: Option<NodeInstallStrategy>,
290    ) -> Result<()> {
291        debug!(
292            "Resolving: {} (requested as {:?}, is_target: {})",
293            name, tags_from_parent_edge, is_initial_target
294        );
295
296        // -------- cycle guard -------------------------------------------------------------
297        if self.visiting.contains(name) {
298            error!("Dependency cycle detected involving: {}", name);
299            return Err(SpsError::DependencyError(format!(
300                "Dependency cycle detected involving '{name}'"
301            )));
302        }
303
304        // -------- if we have a previous entry, maybe promote status / tags -----------------
305        if let Some(existing) = self.resolution_details.get_mut(name) {
306            // ─────────────────────────────────────────────────────────────────────
307            // PROMOTION RULES
308            //
309            // A node can be reached through multiple edges in the graph.  Each time
310            // it is revisited we may need to *promote* its ResolutionStatus or merge
311            // DependencyTag bits so that the strictest requirements win:
312            //
313            //   Installed  >  Requested  >  Missing  >  SkippedOptional
314            //
315            // Tags are OR‑combined so BUILD / RUNTIME / OPTIONAL flags accumulate.
316            // Without these promotions an edge marked OPTIONAL could suppress the
317            // installation later required by a hard RUNTIME edge.
318            // ─────────────────────────────────────────────────────────────────────
319            // status promotion rules -------------------------------------------------------
320            let original_status = existing.status;
321            let original_tags = existing.accumulated_tags;
322
323            let mut new_status = original_status;
324            if is_initial_target && new_status == ResolutionStatus::Missing {
325                new_status = ResolutionStatus::Requested;
326            }
327            if new_status == ResolutionStatus::SkippedOptional
328                && (tags_from_parent_edge.contains(DependencyTag::RUNTIME)
329                    || tags_from_parent_edge.contains(DependencyTag::BUILD)
330                    || (tags_from_parent_edge.contains(DependencyTag::RECOMMENDED)
331                        && !self.context.skip_recommended)
332                    || (is_initial_target && self.context.include_optional))
333            {
334                new_status = if existing.keg_path.is_some() {
335                    ResolutionStatus::Installed
336                } else if is_initial_target {
337                    ResolutionStatus::Requested
338                } else {
339                    ResolutionStatus::Missing
340                };
341            }
342
343            // apply any changes ------------------------------------------------------------
344            let mut needs_revisit = false;
345            if new_status != original_status {
346                debug!(
347                    "Updating status for '{name}' from {:?} to {:?}",
348                    original_status, new_status
349                );
350                existing.status = new_status;
351                needs_revisit = true;
352            }
353
354            let combined_tags = original_tags | tags_from_parent_edge;
355            if combined_tags != original_tags {
356                debug!(
357                    "Updating tags for '{name}' from {:?} to {:?}",
358                    original_tags, combined_tags
359                );
360                existing.accumulated_tags = combined_tags;
361                needs_revisit = true;
362            }
363
364            // nothing else to do
365            if !needs_revisit {
366                debug!("'{}' already resolved with compatible status/tags.", name);
367                return Ok(());
368            }
369
370            debug!(
371                "Re-evaluating dependencies for '{}' due to status/tag update",
372                name
373            );
374        }
375        // -------- first time we see this node ---------------------------------------------
376        else {
377            self.visiting.insert(name.to_string());
378
379            // load / cache the formula -----------------------------------------------------
380            let formula_arc = match self.formula_cache.get(name) {
381                Some(f) => f.clone(),
382                None => {
383                    debug!("Loading formula definition for '{}'", name);
384                    match self.context.formulary.load_formula(name) {
385                        Ok(f) => {
386                            let arc = Arc::new(f);
387                            self.formula_cache.insert(name.to_string(), arc.clone());
388                            arc
389                        }
390                        Err(e) => {
391                            error!("Failed to load formula definition for '{}': {}", name, e);
392                            let msg = e.to_string();
393                            self.resolution_details.insert(
394                                name.to_string(),
395                                ResolvedDependency {
396                                    formula: Arc::new(Formula::placeholder(name)),
397                                    keg_path: None,
398                                    opt_path: None,
399                                    status: ResolutionStatus::NotFound,
400                                    accumulated_tags: tags_from_parent_edge,
401                                    determined_install_strategy:
402                                        NodeInstallStrategy::BottlePreferred,
403                                    failure_reason: Some(msg.clone()),
404                                },
405                            );
406                            self.visiting.remove(name);
407                            self.errors
408                                .insert(name.to_string(), Arc::new(SpsError::NotFound(msg)));
409                            return Ok(()); // treat “not found” as a soft failure
410                        }
411                    }
412                }
413            };
414
415            let current_node_strategy = self.determine_node_install_strategy(
416                name,
417                &formula_arc,
418                is_initial_target,
419                requesting_parent_strategy,
420            );
421
422            let (status, keg_path) = match current_node_strategy {
423                NodeInstallStrategy::SourceOnly => (
424                    if is_initial_target {
425                        ResolutionStatus::Requested
426                    } else {
427                        ResolutionStatus::Missing
428                    },
429                    None,
430                ),
431                NodeInstallStrategy::BottlePreferred | NodeInstallStrategy::BottleOrFail => {
432                    if let Some(keg) = self.context.keg_registry.get_installed_keg(name)? {
433                        (ResolutionStatus::Installed, Some(keg.path))
434                    } else {
435                        (
436                            if is_initial_target {
437                                ResolutionStatus::Requested
438                            } else {
439                                ResolutionStatus::Missing
440                            },
441                            None,
442                        )
443                    }
444                }
445            };
446
447            debug!(
448                "Initial status for '{}': {:?}, keg: {:?}, opt: {}",
449                name,
450                status,
451                keg_path,
452                self.context.keg_registry.get_opt_path(name).display()
453            );
454
455            self.resolution_details.insert(
456                name.to_string(),
457                ResolvedDependency {
458                    formula: formula_arc.clone(),
459                    keg_path,
460                    opt_path: Some(self.context.keg_registry.get_opt_path(name)),
461                    status,
462                    accumulated_tags: tags_from_parent_edge,
463                    determined_install_strategy: current_node_strategy,
464                    failure_reason: None,
465                },
466            );
467        }
468
469        // --------------------------------------------------------------------- recurse ----
470        let dep_snapshot = self
471            .resolution_details
472            .get(name)
473            .expect("just inserted")
474            .clone();
475
476        // if this node is already irrecoverably broken, stop here
477        if matches!(
478            dep_snapshot.status,
479            ResolutionStatus::Failed | ResolutionStatus::NotFound
480        ) {
481            self.visiting.remove(name);
482            return Ok(());
483        }
484
485        // iterate its declared dependencies -----------------------------------------------
486        for dep in dep_snapshot.formula.dependencies()? {
487            let dep_name = &dep.name;
488            let dep_tags = dep.tags;
489            let parent_name = dep_snapshot.formula.name();
490            let parent_strategy = dep_snapshot.determined_install_strategy;
491
492            debug!(
493                "RESOLVER: Evaluating edge: parent='{}' ({:?}), child='{}' ({:?})",
494                parent_name, parent_strategy, dep_name, dep_tags
495            );
496
497            // optional / test filtering
498            if !self.should_consider_dependency(&dep) {
499                if !self.resolution_details.contains_key(dep_name.as_str()) {
500                    debug!("RESOLVER: Child '{}' of '{}' globally SKIPPED (e.g. optional/test not included). Tags: {:?}", dep_name, parent_name, dep_tags);
501                    // ...existing code for SkippedOptional...
502                }
503                continue;
504            }
505
506            // Specific edge processing based on parent strategy
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                // ...existing code for skipping...
519                continue;
520            }
521
522            debug!(
523                "RESOLVER: Edge from '{}' (Strategy: {:?}) to child '{}' (Tags: {:?}) WILL BE PROCESSED. Recursing.",
524                parent_name, parent_strategy, dep_name, dep_tags
525            );
526
527            // --- real recursion -----------------------------------------------------------
528            if let Err(_e) =
529                self.resolve_recursive(dep_name, dep_tags, false, Some(parent_strategy))
530            {
531                // ...existing code...
532            }
533        }
534
535        self.visiting.remove(name);
536        debug!("Finished resolving '{}'", name);
537        Ok(())
538    }
539
540    fn topological_sort(&self) -> Result<Vec<ResolvedDependency>> {
541        let mut in_degree: HashMap<String, usize> = HashMap::new();
542        let mut adj: HashMap<String, HashSet<String>> = HashMap::new();
543        let mut sorted_list = Vec::new();
544        let mut queue = VecDeque::new();
545
546        let relevant_nodes_map: HashMap<String, &ResolvedDependency> = self
547            .resolution_details
548            .iter()
549            .filter(|(_, dep)| {
550                !matches!(
551                    dep.status,
552                    ResolutionStatus::NotFound | ResolutionStatus::Failed
553                )
554            })
555            .map(|(k, v)| (k.clone(), v))
556            .collect();
557
558        for (parent_name, parent_rd) in &relevant_nodes_map {
559            adj.entry(parent_name.clone()).or_default();
560            in_degree.entry(parent_name.clone()).or_default();
561
562            let parent_strategy = parent_rd.determined_install_strategy;
563            if let Ok(dependencies) = parent_rd.formula.dependencies() {
564                for child_edge in dependencies {
565                    let child_name = &child_edge.name;
566                    if relevant_nodes_map.contains_key(child_name)
567                        && self.context.should_process_dependency_edge(
568                            &parent_rd.formula,
569                            child_edge.tags,
570                            parent_strategy,
571                        )
572                        && adj
573                            .entry(parent_name.clone())
574                            .or_default()
575                            .insert(child_name.clone())
576                    {
577                        *in_degree.entry(child_name.clone()).or_default() += 1;
578                    }
579                }
580            }
581        }
582
583        for name in relevant_nodes_map.keys() {
584            if *in_degree.get(name).unwrap_or(&1) == 0 {
585                queue.push_back(name.clone());
586            }
587        }
588
589        while let Some(u_name) = queue.pop_front() {
590            if let Some(resolved_dep) = relevant_nodes_map.get(&u_name) {
591                sorted_list.push((**resolved_dep).clone());
592            }
593            if let Some(neighbors) = adj.get(&u_name) {
594                for v_name in neighbors {
595                    if relevant_nodes_map.contains_key(v_name) {
596                        if let Some(degree) = in_degree.get_mut(v_name) {
597                            *degree = degree.saturating_sub(1);
598                            if *degree == 0 {
599                                queue.push_back(v_name.clone());
600                            }
601                        }
602                    }
603                }
604            }
605        }
606
607        let install_plan: Vec<ResolvedDependency> = sorted_list
608            .into_iter()
609            .filter(|dep| {
610                matches!(
611                    dep.status,
612                    ResolutionStatus::Missing | ResolutionStatus::Requested
613                )
614            })
615            .collect();
616
617        let expected_installable_count = relevant_nodes_map
618            .values()
619            .filter(|dep| {
620                matches!(
621                    dep.status,
622                    ResolutionStatus::Missing | ResolutionStatus::Requested
623                )
624            })
625            .count();
626
627        #[cfg(debug_assertions)]
628        {
629            use std::collections::HashMap;
630            // map formula name → index in install_plan
631            let index_map: HashMap<&str, usize> = install_plan
632                .iter()
633                .enumerate()
634                .map(|(i, d)| (d.formula.name(), i))
635                .collect();
636
637            for (parent_name, parent_rd) in &relevant_nodes_map {
638                if let Ok(edges) = parent_rd.formula.dependencies() {
639                    for edge in edges {
640                        let child = &edge.name;
641                        if relevant_nodes_map.contains_key(child)
642                            && self.context.should_process_dependency_edge(
643                                &parent_rd.formula,
644                                edge.tags,
645                                parent_rd.determined_install_strategy,
646                            )
647                        {
648                            if let (Some(&p_idx), Some(&c_idx)) = (
649                                index_map.get(parent_name.as_str()),
650                                index_map.get(child.as_str()),
651                            ) {
652                                debug_assert!(
653                                    p_idx > c_idx,
654                                    "Topological order violation: parent '{parent_name}' appears before child '{child}'"
655                                );
656                            }
657                        }
658                    }
659                }
660            }
661        }
662
663        if install_plan.len() != expected_installable_count && expected_installable_count > 0 {
664            error!(
665                "Cycle detected! Sorted count ({}) != Relevant node count ({}).",
666                install_plan.len(),
667                expected_installable_count
668            );
669            return Err(SpsError::DependencyError(
670                "Circular dependency detected".to_string(),
671            ));
672        }
673        Ok(install_plan)
674    }
675
676    fn should_consider_dependency(&self, dep: &Dependency) -> bool {
677        let tags = dep.tags;
678        if tags.contains(DependencyTag::TEST) && !self.context.include_test {
679            return false;
680        }
681        if tags.contains(DependencyTag::OPTIONAL) && !self.context.include_optional {
682            return false;
683        }
684        if tags.contains(DependencyTag::RECOMMENDED) && self.context.skip_recommended {
685            return false;
686        }
687        true
688    }
689}
690
691impl Formula {
692    fn placeholder(name: &str) -> Self {
693        Self {
694            name: name.to_string(),
695            stable_version_str: "0.0.0".to_string(),
696            version_semver: semver::Version::new(0, 0, 0),
697            revision: 0,
698            desc: Some("Placeholder for unresolved formula".to_string()),
699            homepage: None,
700            url: String::new(),
701            sha256: String::new(),
702            mirrors: Vec::new(),
703            bottle: Default::default(),
704            dependencies: Vec::new(),
705            requirements: Vec::new(),
706            resources: Vec::new(),
707            install_keg_path: None,
708        }
709    }
710}
711
712// --- ResolutionContext methods ---
713impl<'a> ResolutionContext<'a> {
714    pub fn should_process_dependency_edge(
715        &self,
716        parent_formula_for_logging: &Arc<Formula>,
717        edge_tags: DependencyTag,
718        parent_node_determined_strategy: NodeInstallStrategy,
719    ) -> bool {
720        debug!(
721            "should_process_dependency_edge: Parent='{}', EdgeTags={:?}, ParentStrategy={:?}",
722            parent_formula_for_logging.name(),
723            edge_tags,
724            parent_node_determined_strategy
725        );
726
727        if edge_tags.contains(DependencyTag::TEST) && !self.include_test {
728            debug!(
729                "Edge with tags {:?} skipped: TEST dependencies excluded by context.",
730                edge_tags
731            );
732            return false;
733        }
734        if edge_tags.contains(DependencyTag::OPTIONAL) && !self.include_optional {
735            debug!(
736                "Edge with tags {:?} skipped: OPTIONAL dependencies excluded by context.",
737                edge_tags
738            );
739            return false;
740        }
741        if edge_tags.contains(DependencyTag::RECOMMENDED) && self.skip_recommended {
742            debug!(
743                "Edge with tags {:?} skipped: RECOMMENDED dependencies excluded by context.",
744                edge_tags
745            );
746            return false;
747        }
748        match parent_node_determined_strategy {
749            NodeInstallStrategy::BottlePreferred | NodeInstallStrategy::BottleOrFail => {
750                let is_purely_build_dependency = edge_tags.contains(DependencyTag::BUILD)
751                    && !edge_tags.intersects(
752                        DependencyTag::RUNTIME
753                            | DependencyTag::RECOMMENDED
754                            | DependencyTag::OPTIONAL,
755                    );
756                debug!(
757                    "Parent is bottled. For edge with tags {:?}, is_purely_build_dependency: {}",
758                    edge_tags, is_purely_build_dependency
759                );
760                if is_purely_build_dependency {
761                    debug!("Edge with tags {:?} SKIPPED: Pure BUILD dependency of a bottle-installed parent '{}'.", edge_tags, parent_formula_for_logging.name());
762                    return false;
763                }
764            }
765            NodeInstallStrategy::SourceOnly => {
766                debug!("Parent is SourceOnly. Edge with tags {:?} will be processed (unless globally skipped).", edge_tags);
767            }
768        }
769        debug!(
770            "Edge with tags {:?} WILL BE PROCESSED for parent '{}' with strategy {:?}.",
771            edge_tags,
772            parent_formula_for_logging.name(),
773            parent_node_determined_strategy
774        );
775        true
776    }
777
778    pub fn should_consider_edge_globally(&self, edge_tags: DependencyTag) -> bool {
779        if edge_tags.contains(DependencyTag::TEST) && !self.include_test {
780            return false;
781        }
782        if edge_tags.contains(DependencyTag::OPTIONAL) && !self.include_optional {
783            return false;
784        }
785        if edge_tags.contains(DependencyTag::RECOMMENDED) && self.skip_recommended {
786            return false;
787        }
788        true
789    }
790}