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#[derive(Debug, Clone)]
16pub struct ResolvedDependency {
17    pub formula: Arc<Formula>,
18    pub keg_path: Option<PathBuf>,
19    pub opt_path: Option<PathBuf>,
20    pub status: ResolutionStatus,
21    pub tags: DependencyTag,
22    pub failure_reason: Option<String>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ResolutionStatus {
27    Installed,
28    Missing,
29    Requested,
30    SkippedOptional,
31    NotFound,
32    Failed,
33}
34
35#[derive(Debug, Clone)]
36pub struct ResolvedGraph {
37    pub install_plan: Vec<ResolvedDependency>,
38    pub build_dependency_opt_paths: Vec<PathBuf>,
39    pub runtime_dependency_opt_paths: Vec<PathBuf>,
40    pub resolution_details: HashMap<String, ResolvedDependency>,
41}
42
43pub struct ResolutionContext<'a> {
44    pub formulary: &'a Formulary,
45    pub keg_registry: &'a KegRegistry,
46    pub sps_prefix: &'a Path,
47    pub include_optional: bool,
48    pub include_test: bool,
49    pub skip_recommended: bool,
50    pub force_build: bool,
51}
52
53pub struct DependencyResolver<'a> {
54    context: ResolutionContext<'a>,
55    formula_cache: HashMap<String, Arc<Formula>>,
56    visiting: HashSet<String>,
57    resolution_details: HashMap<String, ResolvedDependency>,
58    // Store Arc<SpsError> instead of SpsError
59    errors: HashMap<String, Arc<SpsError>>,
60}
61
62impl<'a> DependencyResolver<'a> {
63    pub fn new(context: ResolutionContext<'a>) -> Self {
64        Self {
65            context,
66            formula_cache: HashMap::new(),
67            visiting: HashSet::new(),
68            resolution_details: HashMap::new(),
69            errors: HashMap::new(),
70        }
71    }
72
73    pub fn resolve_targets(&mut self, targets: &[String]) -> Result<ResolvedGraph> {
74        debug!("Starting dependency resolution for targets: {:?}", targets);
75        self.visiting.clear();
76        self.resolution_details.clear();
77        self.errors.clear();
78
79        for target_name in targets {
80            if let Err(e) = self.resolve_recursive(target_name, DependencyTag::RUNTIME, true) {
81                // Wrap error in Arc for storage
82                self.errors.insert(target_name.clone(), Arc::new(e));
83                warn!(
84                    "Resolution failed for target '{}', but continuing for others.",
85                    target_name
86                );
87            }
88        }
89
90        debug!(
91            "Raw resolved map after initial pass: {:?}",
92            self.resolution_details
93                .iter()
94                .map(|(k, v)| (k.clone(), v.status, v.tags))
95                .collect::<Vec<_>>()
96        );
97
98        let sorted_list = match self.topological_sort() {
99            Ok(list) => list,
100            Err(e @ SpsError::DependencyError(_)) => {
101                error!("Topological sort failed due to dependency cycle: {}", e);
102                return Err(e);
103            }
104            Err(e) => {
105                error!("Topological sort failed: {}", e);
106                return Err(e);
107            }
108        };
109
110        let install_plan: Vec<ResolvedDependency> = sorted_list
111            .into_iter()
112            .filter(|dep| {
113                matches!(
114                    dep.status,
115                    ResolutionStatus::Missing | ResolutionStatus::Requested
116                )
117            })
118            .collect();
119
120        let mut build_paths = Vec::new();
121        let mut runtime_paths = Vec::new();
122        let mut seen_build_paths = HashSet::new();
123        let mut seen_runtime_paths = HashSet::new();
124
125        for dep in self.resolution_details.values() {
126            if matches!(
127                dep.status,
128                ResolutionStatus::Installed
129                    | ResolutionStatus::Requested
130                    | ResolutionStatus::Missing
131            ) {
132                if let Some(opt_path) = &dep.opt_path {
133                    if dep.tags.contains(DependencyTag::BUILD)
134                        && seen_build_paths.insert(opt_path.clone())
135                    {
136                        debug!("Adding build dep path: {}", opt_path.display());
137                        build_paths.push(opt_path.clone());
138                    }
139                    if dep.tags.intersects(
140                        DependencyTag::RUNTIME
141                            | DependencyTag::RECOMMENDED
142                            | DependencyTag::OPTIONAL,
143                    ) && seen_runtime_paths.insert(opt_path.clone())
144                    {
145                        debug!("Adding runtime dep path: {}", opt_path.display());
146                        runtime_paths.push(opt_path.clone());
147                    }
148                } else if dep.status != ResolutionStatus::NotFound
149                    && dep.status != ResolutionStatus::Failed
150                {
151                    debug!(
152                        "Warning: No opt_path found for resolved dependency {} ({:?})",
153                        dep.formula.name(),
154                        dep.status
155                    );
156                }
157            }
158        }
159
160        if !self.errors.is_empty() {
161            warn!(
162                "Resolution encountered errors for specific targets: {:?}",
163                self.errors
164                    .iter()
165                    .map(|(k, v)| (k, v.to_string()))
166                    .collect::<HashMap<_, _>>()
167            );
168        }
169
170        debug!(
171            "Final installation plan (needs install/build): {:?}",
172            install_plan
173                .iter()
174                .map(|d| (d.formula.name(), d.status))
175                .collect::<Vec<_>>()
176        );
177        debug!(
178            "Collected build dependency paths: {:?}",
179            build_paths.iter().map(|p| p.display()).collect::<Vec<_>>()
180        );
181        debug!(
182            "Collected runtime dependency paths: {:?}",
183            runtime_paths
184                .iter()
185                .map(|p| p.display())
186                .collect::<Vec<_>>()
187        );
188
189        Ok(ResolvedGraph {
190            install_plan,
191            build_dependency_opt_paths: build_paths,
192            runtime_dependency_opt_paths: runtime_paths,
193            resolution_details: self.resolution_details.clone(),
194        })
195    }
196
197    /// Walk a dependency node, collecting status and propagating errors
198    fn resolve_recursive(
199        &mut self,
200        name: &str,
201        tags_from_parent: DependencyTag,
202        is_target: bool,
203    ) -> Result<()> {
204        debug!(
205            "Resolving: {} (requested as {:?}, is_target: {})",
206            name, tags_from_parent, is_target
207        );
208
209        // -------- cycle guard -------------------------------------------------------------
210        if self.visiting.contains(name) {
211            error!("Dependency cycle detected involving: {}", name);
212            return Err(SpsError::DependencyError(format!(
213                "Dependency cycle detected involving '{name}'"
214            )));
215        }
216
217        // -------- if we have a previous entry, maybe promote status / tags -----------------
218        if let Some(existing) = self.resolution_details.get_mut(name) {
219            let original_status = existing.status;
220            let original_tags = existing.tags;
221
222            // status promotion rules -------------------------------------------------------
223            let mut new_status = original_status;
224            if is_target && new_status == ResolutionStatus::Missing {
225                new_status = ResolutionStatus::Requested;
226            }
227            if new_status == ResolutionStatus::SkippedOptional
228                && (tags_from_parent.contains(DependencyTag::RUNTIME)
229                    || tags_from_parent.contains(DependencyTag::BUILD)
230                    || (tags_from_parent.contains(DependencyTag::RECOMMENDED)
231                        && !self.context.skip_recommended)
232                    || (is_target && self.context.include_optional))
233            {
234                new_status = if existing.keg_path.is_some() {
235                    ResolutionStatus::Installed
236                } else if is_target {
237                    ResolutionStatus::Requested
238                } else {
239                    ResolutionStatus::Missing
240                };
241            }
242
243            // apply any changes ------------------------------------------------------------
244            let mut needs_revisit = false;
245            if new_status != original_status {
246                debug!(
247                    "Updating status for '{name}' from {:?} to {:?}",
248                    original_status, new_status
249                );
250                existing.status = new_status;
251                needs_revisit = true;
252            }
253
254            let combined_tags = original_tags | tags_from_parent;
255            if combined_tags != original_tags {
256                debug!(
257                    "Updating tags for '{name}' from {:?} to {:?}",
258                    original_tags, combined_tags
259                );
260                existing.tags = combined_tags;
261                needs_revisit = true;
262            }
263
264            // nothing else to do
265            if !needs_revisit {
266                debug!("'{}' already resolved with compatible status/tags.", name);
267                return Ok(());
268            }
269
270            debug!(
271                "Re-evaluating dependencies for '{}' due to status/tag update",
272                name
273            );
274        }
275        // -------- first time we see this node ---------------------------------------------
276        else {
277            self.visiting.insert(name.to_string());
278
279            // load / cache the formula -----------------------------------------------------
280            let formula: Arc<Formula> = match self.formula_cache.get(name) {
281                Some(f) => f.clone(),
282                None => {
283                    debug!("Loading formula definition for '{}'", name);
284                    match self.context.formulary.load_formula(name) {
285                        Ok(f) => {
286                            let arc = Arc::new(f);
287                            self.formula_cache.insert(name.to_string(), arc.clone());
288                            arc
289                        }
290                        Err(e) => {
291                            error!("Failed to load formula definition for '{}': {}", name, e);
292
293                            let msg = e.to_string();
294                            self.resolution_details.insert(
295                                name.to_string(),
296                                ResolvedDependency {
297                                    formula: Arc::new(Formula::placeholder(name)),
298                                    keg_path: None,
299                                    opt_path: None,
300                                    status: ResolutionStatus::NotFound,
301                                    tags: tags_from_parent,
302                                    failure_reason: Some(msg.clone()),
303                                },
304                            );
305                            self.visiting.remove(name);
306
307                            self.errors
308                                .insert(name.to_string(), Arc::new(SpsError::NotFound(msg)));
309
310                            return Ok(()); // treat “not found” as a soft failure
311                        }
312                    }
313                }
314            };
315
316            // work out installation state --------------------------------------------------
317            let installed_keg = if self.context.force_build {
318                None
319            } else {
320                self.context.keg_registry.get_installed_keg(name)?
321            };
322            let opt_path = self.context.keg_registry.get_opt_path(name);
323
324            let (status, keg_path) = match installed_keg {
325                Some(keg) => (ResolutionStatus::Installed, Some(keg.path)),
326                None => (
327                    if is_target {
328                        ResolutionStatus::Requested
329                    } else {
330                        ResolutionStatus::Missing
331                    },
332                    None,
333                ),
334            };
335
336            debug!(
337                "Initial status for '{}': {:?}, keg: {:?}, opt: {}",
338                name,
339                status,
340                keg_path,
341                opt_path.display()
342            );
343
344            self.resolution_details.insert(
345                name.to_string(),
346                ResolvedDependency {
347                    formula,
348                    keg_path,
349                    opt_path: Some(opt_path),
350                    status,
351                    tags: tags_from_parent,
352                    failure_reason: None,
353                },
354            );
355        }
356
357        // --------------------------------------------------------------------- recurse ----
358        let dep_snapshot = self
359            .resolution_details
360            .get(name)
361            .expect("just inserted")
362            .clone();
363
364        // if this node is already irrecoverably broken, stop here
365        if matches!(
366            dep_snapshot.status,
367            ResolutionStatus::Failed | ResolutionStatus::NotFound
368        ) {
369            self.visiting.remove(name);
370            return Ok(());
371        }
372
373        // iterate its declared dependencies -----------------------------------------------
374        for dep in dep_snapshot.formula.dependencies()? {
375            let dep_name = &dep.name;
376            let dep_tags = dep.tags;
377
378            debug!(
379                "Processing dependency '{}' for '{}' with tags {:?}",
380                dep_name, name, dep_tags
381            );
382
383            // optional / test filtering
384            if !self.should_consider_dependency(&dep) {
385                if !self.resolution_details.contains_key(dep_name.as_str()) {
386                    debug!("Marking '{}' as SkippedOptional", dep_name);
387
388                    if let Ok(f) = self.context.formulary.load_formula(dep_name) {
389                        let arc = Arc::new(f);
390                        let opt = self.context.keg_registry.get_opt_path(dep_name);
391
392                        self.formula_cache.insert(dep_name.to_string(), arc.clone());
393                        self.resolution_details.insert(
394                            dep_name.to_string(),
395                            ResolvedDependency {
396                                formula: arc,
397                                keg_path: None,
398                                opt_path: Some(opt),
399                                status: ResolutionStatus::SkippedOptional,
400                                tags: dep_tags,
401                                failure_reason: None,
402                            },
403                        );
404                    }
405                }
406                continue;
407            }
408
409            // --- real recursion -----------------------------------------------------------
410            if let Err(e) = self.resolve_recursive(dep_name, dep_tags, false) {
411                warn!(
412                    "Recursive resolution for '{}' (child of '{}') failed: {}",
413                    dep_name, name, e
414                );
415
416                // we’ll need the details after moving `e`, so harvest now
417                let is_cycle = matches!(e, SpsError::DependencyError(_));
418                let msg = e.to_string();
419
420                // move `e` into the error map
421                self.errors
422                    .entry(dep_name.to_string())
423                    .or_insert_with(|| Arc::new(e));
424
425                // mark the node as failed
426                if let Some(node) = self.resolution_details.get_mut(dep_name.as_str()) {
427                    node.status = ResolutionStatus::Failed;
428                    node.failure_reason = Some(msg);
429                }
430
431                // propagate cycles upward
432                if is_cycle {
433                    self.visiting.remove(name);
434                    return Err(SpsError::DependencyError(
435                        "Circular dependency detected".into(),
436                    ));
437                }
438            }
439        }
440
441        self.visiting.remove(name);
442        debug!("Finished resolving '{}'", name);
443        Ok(())
444    }
445
446    fn topological_sort(&self) -> Result<Vec<ResolvedDependency>> {
447        debug!("Starting topological sort");
448        let mut in_degree: HashMap<String, usize> = HashMap::new();
449        let mut adj: HashMap<String, HashSet<String>> = HashMap::new();
450        let mut sorted_list = Vec::new();
451        let mut queue = VecDeque::new();
452
453        let relevant_nodes: Vec<_> = self
454            .resolution_details
455            .iter()
456            .filter(|(_, dep)| {
457                matches!(
458                    dep.status,
459                    ResolutionStatus::Installed
460                        | ResolutionStatus::Missing
461                        | ResolutionStatus::Requested
462                )
463            })
464            .map(|(name, _)| name.clone())
465            .collect();
466
467        for name in &relevant_nodes {
468            in_degree.entry(name.clone()).or_insert(0);
469            adj.entry(name.clone()).or_default();
470        }
471
472        for name in &relevant_nodes {
473            let resolved_dep = self.resolution_details.get(name).unwrap();
474            match resolved_dep.formula.dependencies() {
475                Ok(dependencies) => {
476                    for dep in dependencies {
477                        if relevant_nodes.contains(&dep.name)
478                            && self.should_consider_dependency(&dep)
479                            && adj
480                                .entry(dep.name.clone())
481                                .or_default()
482                                .insert(name.clone())
483                        {
484                            *in_degree.entry(name.clone()).or_insert(0) += 1;
485                        }
486                    }
487                }
488                Err(e) => {
489                    error!(
490                        "Failed to get dependencies for '{}' during sort: {}",
491                        name, e
492                    );
493                    return Err(e);
494                }
495            }
496        }
497
498        debug!("In-degrees (relevant nodes only): {:?}", in_degree);
499
500        for name in &relevant_nodes {
501            if *in_degree.get(name).unwrap_or(&1) == 0 {
502                queue.push_back(name.clone());
503            }
504        }
505
506        debug!("Initial queue: {:?}", queue);
507
508        while let Some(u_name) = queue.pop_front() {
509            if let Some(resolved_dep) = self.resolution_details.get(&u_name) {
510                if matches!(
511                    resolved_dep.status,
512                    ResolutionStatus::Installed
513                        | ResolutionStatus::Missing
514                        | ResolutionStatus::Requested
515                ) {
516                    sorted_list.push(resolved_dep.clone());
517                }
518            } else {
519                error!(
520                    "Error: Node '{}' from queue not found in resolved map!",
521                    u_name
522                );
523                return Err(SpsError::Generic(format!(
524                    "Topological sort inconsistency: node {u_name} not found"
525                )));
526            }
527
528            if let Some(neighbors) = adj.get(&u_name) {
529                for v_name in neighbors {
530                    if relevant_nodes.contains(v_name) {
531                        if let Some(degree) = in_degree.get_mut(v_name) {
532                            *degree = degree.saturating_sub(1);
533                            if *degree == 0 {
534                                queue.push_back(v_name.clone());
535                            }
536                        }
537                    }
538                }
539            }
540        }
541
542        if sorted_list.len() != relevant_nodes.len() {
543            error!(
544                "Cycle detected! Sorted count ({}) != Relevant node count ({}).",
545                sorted_list.len(),
546                relevant_nodes.len()
547            );
548            let cyclic_nodes: Vec<_> = relevant_nodes
549                .iter()
550                .filter(|n| in_degree.get(*n).unwrap_or(&0) > &0)
551                .cloned()
552                .collect();
553            error!(
554                "Nodes potentially involved in cycle (relevant, in-degree > 0): {:?}",
555                cyclic_nodes
556            );
557            return Err(SpsError::DependencyError(
558                "Circular dependency detected".to_string(),
559            ));
560        }
561
562        debug!(
563            "Topological sort successful. {} relevant nodes in sorted list.",
564            sorted_list.len()
565        );
566        Ok(sorted_list)
567    }
568
569    fn should_consider_dependency(&self, dep: &Dependency) -> bool {
570        let tags = dep.tags;
571        if tags.contains(DependencyTag::TEST) && !self.context.include_test {
572            return false;
573        }
574        if tags.contains(DependencyTag::OPTIONAL) && !self.context.include_optional {
575            return false;
576        }
577        if tags.contains(DependencyTag::RECOMMENDED) && self.context.skip_recommended {
578            return false;
579        }
580        true
581    }
582}
583
584impl Formula {
585    fn placeholder(name: &str) -> Self {
586        Self {
587            name: name.to_string(),
588            stable_version_str: "0.0.0".to_string(),
589            version_semver: semver::Version::new(0, 0, 0),
590            revision: 0,
591            desc: Some("Placeholder for unresolved formula".to_string()),
592            homepage: None,
593            url: String::new(),
594            sha256: String::new(),
595            mirrors: Vec::new(),
596            bottle: Default::default(),
597            dependencies: Vec::new(),
598            requirements: Vec::new(),
599            resources: Vec::new(),
600            install_keg_path: None,
601        }
602    }
603}