Skip to main content

arch_toolkit/deps/
reverse.rs

1//! Reverse dependency analysis for removal preflight checks.
2//!
3//! This module provides functionality to analyze reverse dependencies, finding all packages
4//! that depend on packages being removed. It uses breadth-first search (BFS) traversal
5//! with `pacman -Qi` queries to build a complete dependency graph.
6
7use crate::deps::query::get_installed_packages;
8use crate::error::{ArchToolkitError, Result};
9use crate::types::dependency::{
10    Dependency, DependencySource, DependencyStatus, PackageRef, ReverseDependencyReport,
11    ReverseDependencySummary,
12};
13use std::collections::{BTreeMap, HashMap, HashSet, VecDeque, hash_map::Entry};
14use std::process::{Command, Stdio};
15
16/// Reverse dependency analyzer for removal operations.
17///
18/// This struct provides the main entry point for analyzing reverse dependencies
19/// for packages being removed. It performs BFS traversal to find all packages
20/// that depend on the removal targets.
21pub struct ReverseDependencyAnalyzer;
22
23impl ReverseDependencyAnalyzer {
24    /// What: Create a new reverse dependency analyzer.
25    ///
26    /// Inputs:
27    /// - (none)
28    ///
29    /// Output:
30    /// - Returns a new `ReverseDependencyAnalyzer` instance.
31    ///
32    /// Details:
33    /// - Creates an analyzer with default configuration.
34    ///
35    /// # Example
36    ///
37    /// ```no_run
38    /// use arch_toolkit::deps::ReverseDependencyAnalyzer;
39    ///
40    /// let analyzer = ReverseDependencyAnalyzer::new();
41    /// ```
42    #[must_use]
43    pub const fn new() -> Self {
44        Self
45    }
46
47    /// What: Analyze reverse dependencies for packages being removed.
48    ///
49    /// Inputs:
50    /// - `packages`: A slice of `PackageRef` instances for packages being removed.
51    ///
52    /// Output:
53    /// - Returns a `Result` containing `ReverseDependencyReport` on success, or an `ArchToolkitError` on failure.
54    ///
55    /// Details:
56    /// - Performs breadth-first search (BFS) traversal using `pacman -Qi` metadata.
57    /// - Aggregates per-root relationships to track direct vs transitive dependents.
58    /// - Only analyzes installed packages (skips uninstalled packages).
59    /// - Returns empty report if no packages provided or all packages are uninstalled.
60    ///
61    /// # Errors
62    ///
63    /// This function can return an `ArchToolkitError` if underlying `pacman` commands fail
64    /// or if parsing their output encounters unexpected formats.
65    ///
66    /// # Example
67    ///
68    /// ```no_run
69    /// use arch_toolkit::deps::ReverseDependencyAnalyzer;
70    /// use arch_toolkit::{PackageRef, PackageSource};
71    ///
72    /// let analyzer = ReverseDependencyAnalyzer::new();
73    /// let packages = vec![
74    ///     PackageRef {
75    ///         name: "qt5-base".into(),
76    ///         version: "5.15.10".into(),
77    ///         source: PackageSource::Official {
78    ///             repo: "extra".into(),
79    ///             arch: "x86_64".into(),
80    ///         },
81    ///     },
82    /// ];
83    ///
84    /// let report = analyzer.analyze(&packages)?;
85    /// println!("{} packages would be affected", report.dependents.len());
86    /// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
87    /// ```
88    pub fn analyze(&self, packages: &[PackageRef]) -> Result<ReverseDependencyReport> {
89        tracing::info!(
90            "Starting reverse dependency resolution for {} target(s)",
91            packages.len()
92        );
93
94        if packages.is_empty() {
95            return Ok(ReverseDependencyReport::default());
96        }
97
98        let mut state = ReverseResolverState::new(packages);
99
100        for target in packages {
101            let root = target.name.trim();
102            if root.is_empty() {
103                continue;
104            }
105
106            if state.pkg_info(root).is_none() {
107                tracing::warn!(
108                    "Skipping reverse dependency walk for {} (not installed)",
109                    root
110                );
111                continue;
112            }
113
114            let mut visited: HashSet<String> = HashSet::new();
115            visited.insert(root.to_string());
116
117            let mut queue: VecDeque<(String, usize)> = VecDeque::new();
118            queue.push_back((root.to_string(), 0));
119
120            while let Some((current, depth)) = queue.pop_front() {
121                let Some(info) = state.pkg_info(&current) else {
122                    continue;
123                };
124
125                for dependent in info.required_by.iter().filter(|name| !name.is_empty()) {
126                    state.update_entry(dependent, &current, root, depth + 1);
127
128                    if visited.insert(dependent.clone()) {
129                        queue.push_back((dependent.clone(), depth + 1));
130                    }
131                }
132            }
133        }
134
135        let ReverseResolverState { aggregated, .. } = state;
136
137        let mut summary_map: HashMap<String, ReverseDependencySummary> = HashMap::new();
138        for entry in aggregated.values() {
139            for (root, relation) in &entry.per_root {
140                let summary =
141                    summary_map
142                        .entry(root.clone())
143                        .or_insert_with(|| ReverseDependencySummary {
144                            package: root.clone(),
145                            ..Default::default()
146                        });
147
148                if relation.parents.contains(root) || relation.min_depth() == 1 {
149                    summary.direct_dependents += 1;
150                } else {
151                    summary.transitive_dependents += 1;
152                }
153                summary.total_dependents =
154                    summary.direct_dependents + summary.transitive_dependents;
155            }
156        }
157
158        for target in packages {
159            summary_map
160                .entry(target.name.clone())
161                .or_insert_with(|| ReverseDependencySummary {
162                    package: target.name.clone(),
163                    ..Default::default()
164                });
165        }
166
167        let mut summaries: Vec<ReverseDependencySummary> = summary_map.into_values().collect();
168        summaries.sort_by(|a, b| a.package.cmp(&b.package));
169
170        let mut dependencies: Vec<Dependency> = aggregated
171            .into_iter()
172            .map(|(name, entry)| convert_entry(name, entry))
173            .collect();
174        dependencies.sort_by(|a, b| a.name.cmp(&b.name));
175
176        tracing::info!(
177            "Reverse dependency resolution complete ({} impacted packages)",
178            dependencies.len()
179        );
180
181        Ok(ReverseDependencyReport {
182            dependents: dependencies,
183            summaries,
184        })
185    }
186}
187
188impl Default for ReverseDependencyAnalyzer {
189    fn default() -> Self {
190        Self::new()
191    }
192}
193
194/// What: Internal working state used while traversing reverse dependencies.
195///
196/// Inputs:
197/// - Constructed from user-selected removal targets and lazily populated with pacman metadata.
198///
199/// Output:
200/// - Retains cached package information, aggregation maps, and bookkeeping sets during traversal.
201///
202/// Details:
203/// - Encapsulates shared collections so helper methods can mutate state without leaking implementation details.
204struct ReverseResolverState {
205    /// Aggregated reverse dependency entries by package name.
206    aggregated: HashMap<String, AggregatedEntry>,
207    /// Cache of package information by package name.
208    cache: HashMap<String, PkgInfo>,
209    /// Set of missing package names.
210    missing: HashSet<String>,
211    /// Set of target package names for reverse dependency resolution.
212    target_names: HashSet<String>,
213}
214
215impl ReverseResolverState {
216    /// What: Initialize traversal state for the provided removal targets.
217    ///
218    /// Inputs:
219    /// - `targets`: Packages selected for removal.
220    ///
221    /// Output:
222    /// - Returns a state object preloaded with target name bookkeeping.
223    ///
224    /// Details:
225    /// - Prepares aggregation maps and caches so subsequent queries can avoid redundant pacman calls.
226    fn new(targets: &[PackageRef]) -> Self {
227        let target_names = targets.iter().map(|pkg| pkg.name.clone()).collect();
228        Self {
229            aggregated: HashMap::new(),
230            cache: HashMap::new(),
231            missing: HashSet::new(),
232            target_names,
233        }
234    }
235
236    /// What: Fetch and cache package information for a given name.
237    ///
238    /// Inputs:
239    /// - `name`: Package whose metadata should be retrieved via `pacman -Qi`.
240    ///
241    /// Output:
242    /// - Returns package info when available; otherwise caches the miss and yields `None`.
243    ///
244    /// Details:
245    /// - Avoids repeated command executions by memoizing both hits and misses across the traversal.
246    fn pkg_info(&mut self, name: &str) -> Option<PkgInfo> {
247        if let Some(info) = self.cache.get(name) {
248            return Some(info.clone());
249        }
250        if self.missing.contains(name) {
251            return None;
252        }
253
254        match fetch_pkg_info(name) {
255            Ok(info) => {
256                self.cache.insert(name.to_string(), info.clone());
257                Some(info)
258            }
259            Err(err) => {
260                tracing::warn!("Failed to query pacman -Qi {}: {}", name, err);
261                self.missing.insert(name.to_string());
262                None
263            }
264        }
265    }
266
267    /// What: Update aggregation records to reflect a discovered reverse dependency relationship.
268    ///
269    /// Inputs:
270    /// - `dependent`: Package that depends on the current node.
271    /// - `parent`: Immediate package causing the dependency (may be empty).
272    /// - `root`: Root removal target currently being explored.
273    /// - `depth`: Distance from the root in the traversal.
274    ///
275    /// Output:
276    /// - Mutates internal maps to capture per-root relationships and selection flags.
277    ///
278    /// Details:
279    /// - Consolidates metadata per dependent package while preserving shortest depth and parent sets per root.
280    fn update_entry(&mut self, dependent: &str, parent: &str, root: &str, depth: usize) {
281        if dependent.eq_ignore_ascii_case(root) {
282            return;
283        }
284
285        let Some(info) = self.pkg_info(dependent) else {
286            return;
287        };
288
289        let selected = self.target_names.contains(dependent);
290        match self.aggregated.entry(dependent.to_owned()) {
291            Entry::Occupied(mut entry) => {
292                let data = entry.get_mut();
293                data.info = info;
294                if selected {
295                    data.selected_for_removal = true;
296                }
297                let relation = data
298                    .per_root
299                    .entry(root.to_string())
300                    .or_insert_with(RootRelation::new);
301                relation.record(parent, depth);
302            }
303            Entry::Vacant(slot) => {
304                let mut data = AggregatedEntry {
305                    info,
306                    per_root: HashMap::new(),
307                    selected_for_removal: selected,
308                };
309                data.per_root
310                    .entry(root.to_string())
311                    .or_insert_with(RootRelation::new)
312                    .record(parent, depth);
313                slot.insert(data);
314            }
315        }
316    }
317}
318
319/// What: Snapshot of metadata retrieved from pacman's local database for traversal decisions.
320///
321/// Inputs:
322/// - Filled by `fetch_pkg_info`, capturing fields relevant to reverse dependency aggregation.
323///
324/// Output:
325/// - Provides reusable package details to avoid multiple CLI invocations.
326///
327/// Details:
328/// - Stores only the subset of fields necessary for summarising conflicts and dependencies.
329#[derive(Clone, Debug)]
330struct PkgInfo {
331    /// Package name.
332    name: String,
333    /// Package version.
334    #[allow(dead_code)] // Version is fetched but not currently used in convert_entry
335    version: String,
336    /// Repository name (None for AUR packages).
337    repo: Option<String>,
338    /// Package groups.
339    groups: Vec<String>,
340    /// Packages that require this package.
341    required_by: Vec<String>,
342    /// Whether package was explicitly installed.
343    explicit: bool,
344}
345
346/// What: Aggregated view of a dependent package across all removal roots.
347///
348/// Inputs:
349/// - Populated incrementally as `update_entry` discovers new relationships.
350///
351/// Output:
352/// - Captures per-root metadata along with selection status for downstream conversion.
353///
354/// Details:
355/// - Maintains deduplicated parent sets for each root to explain conflict chains clearly.
356#[derive(Clone, Debug)]
357struct AggregatedEntry {
358    /// Package information.
359    info: PkgInfo,
360    /// Relationship information per removal root.
361    per_root: HashMap<String, RootRelation>,
362    /// Whether this package is selected for removal.
363    selected_for_removal: bool,
364}
365
366/// What: Relationship summary between a dependent package and a particular removal root.
367///
368/// Inputs:
369/// - Updated as traversal discovers parents contributing to the dependency.
370///
371/// Output:
372/// - Tracks unique parent names and the minimum depth from the root.
373///
374/// Details:
375/// - Used to distinguish direct versus transitive dependents in the final summary.
376#[derive(Clone, Debug)]
377struct RootRelation {
378    /// Set of parent package names that contribute to this dependency.
379    parents: HashSet<String>,
380    /// Minimum depth from the removal root to this package.
381    min_depth: usize,
382}
383
384impl RootRelation {
385    /// What: Construct an empty relation ready to collect parent metadata.
386    ///
387    /// Inputs:
388    /// - (none): Starts with default depth and empty parent set.
389    ///
390    /// Output:
391    /// - Returns a relation with `usize::MAX` depth and no parents recorded.
392    ///
393    /// Details:
394    /// - The sentinel depth ensures first updates always win when computing minimum distance.
395    fn new() -> Self {
396        Self {
397            parents: HashSet::new(),
398            min_depth: usize::MAX,
399        }
400    }
401
402    /// What: Record a traversal parent contributing to the dependency chain.
403    ///
404    /// Inputs:
405    /// - `parent`: Name of the package one level closer to the root.
406    /// - `depth`: Current depth from the root target.
407    ///
408    /// Output:
409    /// - Updates internal parent set and minimum depth as appropriate.
410    ///
411    /// Details:
412    /// - Ignores empty parent identifiers and keeps the shallowest depth observed for summarisation.
413    fn record(&mut self, parent: &str, depth: usize) {
414        if !parent.is_empty() {
415            self.parents.insert(parent.to_string());
416        }
417        if depth < self.min_depth {
418            self.min_depth = depth;
419        }
420    }
421
422    /// What: Report the closest distance from this dependent to the root target.
423    ///
424    /// Inputs:
425    /// - (none): Uses previously recorded depth values.
426    ///
427    /// Output:
428    /// - Returns the smallest depth stored during traversal.
429    ///
430    /// Details:
431    /// - Allows callers to classify dependencies as direct when the minimum depth is one.
432    const fn min_depth(&self) -> usize {
433        self.min_depth
434    }
435}
436
437/// What: Convert an aggregated reverse dependency entry into UI-facing metadata.
438///
439/// Inputs:
440/// - `name`: Canonical dependent package name.
441/// - `entry`: Aggregated structure containing metadata and per-root relations.
442///
443/// Output:
444/// - Returns a `Dependency` tailored for preflight summaries with conflict reasoning.
445///
446/// Details:
447/// - Merges parent sets, sorts presentation fields, and infers system/core flags for display.
448fn convert_entry(name: String, entry: AggregatedEntry) -> Dependency {
449    let AggregatedEntry {
450        info,
451        per_root,
452        selected_for_removal,
453    } = entry;
454
455    let PkgInfo {
456        name: pkg_name,
457        version: _,
458        repo,
459        groups,
460        required_by: _,
461        explicit,
462    } = info;
463
464    let mut required_by: Vec<String> = per_root.keys().cloned().collect();
465    required_by.sort();
466
467    let mut all_parents: HashSet<String> = HashSet::new();
468    for relation in per_root.values() {
469        all_parents.extend(relation.parents.iter().cloned());
470    }
471    let mut depends_on: Vec<String> = all_parents.into_iter().collect();
472    depends_on.sort();
473
474    let mut reason_parts: Vec<String> = Vec::new();
475    for (root, relation) in &per_root {
476        let depth = relation.min_depth();
477        let mut parents: Vec<String> = relation.parents.iter().cloned().collect();
478        parents.sort();
479
480        if depth <= 1 {
481            reason_parts.push(format!("requires {root}"));
482        } else {
483            let via = if parents.is_empty() {
484                "unknown".to_string()
485            } else {
486                parents.join(", ")
487            };
488            reason_parts.push(format!("blocks {root} (depth {depth} via {via})"));
489        }
490    }
491
492    if selected_for_removal {
493        reason_parts.push("already selected for removal".to_string());
494    }
495    if explicit {
496        reason_parts.push("explicitly installed".to_string());
497    }
498
499    reason_parts.sort();
500    let reason = if reason_parts.is_empty() {
501        "required by removal targets".to_string()
502    } else {
503        reason_parts.join("; ")
504    };
505
506    let source = match repo.as_deref() {
507        Some(repo) if repo.eq_ignore_ascii_case("local") || repo.is_empty() => {
508            DependencySource::Local
509        }
510        Some(repo) => DependencySource::Official {
511            repo: repo.to_string(),
512        },
513        None => DependencySource::Local,
514    };
515
516    let is_core = repo
517        .as_deref()
518        .is_some_and(|r| r.eq_ignore_ascii_case("core"));
519    let is_system = groups
520        .iter()
521        .any(|g| matches!(g.as_str(), "base" | "base-devel"));
522
523    let display_name = if pkg_name.is_empty() { name } else { pkg_name };
524
525    Dependency {
526        name: display_name,
527        version_req: String::new(), // Reverse deps don't have version requirements
528        status: DependencyStatus::Conflict { reason },
529        source,
530        required_by,
531        depends_on,
532        is_core,
533        is_system,
534    }
535}
536
537/// What: Query pacman for detailed information about an installed package.
538///
539/// Inputs:
540/// - `name`: Package name passed to `pacman -Qi`.
541///
542/// Output:
543/// - Returns a `PkgInfo` snapshot or an `ArchToolkitError` if the query fails.
544///
545/// Details:
546/// - Parses key-value fields such as repository, groups, and required-by lists for downstream processing.
547/// - Sets `LC_ALL=C` and `LANG=C` for consistent locale-independent output.
548fn fetch_pkg_info(name: &str) -> Result<PkgInfo> {
549    tracing::debug!("Running: pacman -Qi {}", name);
550    let output = Command::new("pacman")
551        .args(["-Qi", name])
552        .env("LC_ALL", "C")
553        .env("LANG", "C")
554        .stdin(Stdio::null())
555        .stdout(Stdio::piped())
556        .stderr(Stdio::piped())
557        .output()
558        .map_err(|e| ArchToolkitError::Parse(format!("pacman -Qi {name} failed: {e}")))?;
559
560    if !output.status.success() {
561        let stderr = String::from_utf8_lossy(&output.stderr);
562        return Err(ArchToolkitError::Parse(format!(
563            "pacman -Qi {name} exited with {:?}: {}",
564            output.status, stderr
565        )));
566    }
567
568    let text = String::from_utf8_lossy(&output.stdout);
569    let map = parse_key_value_output(&text);
570
571    let required_by = split_ws_or_none(map.get("Required By"));
572    let groups = split_ws_or_none(map.get("Groups"));
573    let version = map.get("Version").cloned().unwrap_or_default();
574    let repo = map.get("Repository").cloned();
575    let install_reason = map
576        .get("Install Reason")
577        .cloned()
578        .unwrap_or_default()
579        .to_lowercase();
580    let explicit = install_reason.contains("explicit");
581
582    Ok(PkgInfo {
583        name: map.get("Name").cloned().unwrap_or_else(|| name.to_string()),
584        version,
585        repo,
586        groups,
587        required_by,
588        explicit,
589    })
590}
591
592/// What: Parse pacman key-value output into a searchable map.
593///
594/// Inputs:
595/// - `text`: Multi-line output containing colon-separated fields with optional wrapped lines.
596///
597/// Output:
598/// - Returns a `BTreeMap` mapping field names to their consolidated string values.
599///
600/// Details:
601/// - Handles indented continuation lines by appending them to the most recently parsed key.
602fn parse_key_value_output(text: &str) -> BTreeMap<String, String> {
603    let mut map: BTreeMap<String, String> = BTreeMap::new();
604    let mut last_key: Option<String> = None;
605
606    for line in text.lines() {
607        if line.trim().is_empty() {
608            continue;
609        }
610
611        if let Some((k, v)) = line.split_once(':') {
612            let key = k.trim().to_string();
613            let val = v.trim().to_string();
614            last_key = Some(key.clone());
615            map.insert(key, val);
616        } else if (line.starts_with(' ') || line.starts_with('\t'))
617            && let Some(key) = &last_key
618        {
619            let entry = map.entry(key.clone()).or_default();
620            if !entry.ends_with(' ') {
621                entry.push(' ');
622            }
623            entry.push_str(line.trim());
624        }
625    }
626
627    map
628}
629
630/// What: Break a whitespace-separated field into individual tokens, ignoring sentinel values.
631///
632/// Inputs:
633/// - `field`: Optional string obtained from pacman metadata.
634///
635/// Output:
636/// - Returns a vector of tokens or an empty vector when the field is missing or marked as "None".
637///
638/// Details:
639/// - Trims surrounding whitespace before evaluating the contents to avoid spurious blank entries.
640fn split_ws_or_none(field: Option<&String>) -> Vec<String> {
641    field.map_or_else(Vec::new, |value| {
642        let trimmed = value.trim();
643        if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
644            Vec::new()
645        } else {
646            trimmed
647                .split_whitespace()
648                .map(ToString::to_string)
649                .collect()
650        }
651    })
652}
653
654/// What: Check if a package has any installed packages in its "Required By" field.
655///
656/// Inputs:
657/// - `name`: Package name to check.
658///
659/// Output:
660/// - Returns `true` if the package has at least one installed package in its "Required By" field, `false` otherwise.
661///
662/// Details:
663/// - Runs `pacman -Qi` to query package information and parses the "Required By" field.
664/// - Checks each package in "Required By" against the installed package cache.
665/// - Returns `false` if the package is not installed or if querying fails.
666/// - Gracefully degrades when pacman is unavailable (returns `false`).
667///
668/// # Example
669///
670/// ```no_run
671/// use arch_toolkit::deps::has_installed_required_by;
672///
673/// if has_installed_required_by("glibc") {
674///     println!("glibc has installed dependents");
675/// }
676/// ```
677#[must_use]
678pub fn has_installed_required_by(name: &str) -> bool {
679    let Ok(installed) = get_installed_packages() else {
680        tracing::debug!("Failed to get installed packages for has_installed_required_by");
681        return false;
682    };
683
684    match fetch_pkg_info(name) {
685        Ok(info) => info
686            .required_by
687            .iter()
688            .any(|pkg| installed.contains(pkg.as_str())),
689        Err(err) => {
690            tracing::debug!("Failed to query pacman -Qi {}: {}", name, err);
691            false
692        }
693    }
694}
695
696/// What: Get the list of installed packages that depend on a package.
697///
698/// Inputs:
699/// - `name`: Package name to check.
700///
701/// Output:
702/// - Returns a vector of package names that are installed and depend on the package, or an empty vector on failure.
703///
704/// Details:
705/// - Runs `pacman -Qi` to query package information and parses the "Required By" field.
706/// - Filters the "Required By" list to only include installed packages.
707/// - Returns an empty vector if the package is not installed or if querying fails.
708/// - Gracefully degrades when pacman is unavailable (returns empty vector).
709///
710/// # Example
711///
712/// ```no_run
713/// use arch_toolkit::deps::get_installed_required_by;
714///
715/// let dependents = get_installed_required_by("glibc");
716/// println!("Found {} installed dependents", dependents.len());
717/// ```
718#[must_use]
719pub fn get_installed_required_by(name: &str) -> Vec<String> {
720    let Ok(installed) = get_installed_packages() else {
721        tracing::debug!("Failed to get installed packages for get_installed_required_by");
722        return Vec::new();
723    };
724
725    match fetch_pkg_info(name) {
726        Ok(info) => info
727            .required_by
728            .iter()
729            .filter(|pkg| installed.contains(pkg.as_str()))
730            .cloned()
731            .collect(),
732        Err(err) => {
733            tracing::debug!("Failed to query pacman -Qi {}: {}", name, err);
734            Vec::new()
735        }
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742    use crate::types::dependency::PackageSource;
743
744    fn pkg_ref(name: &str) -> PackageRef {
745        PackageRef {
746            name: name.into(),
747            version: "1.0".into(),
748            source: PackageSource::Official {
749                repo: "extra".into(),
750                arch: "x86_64".into(),
751            },
752        }
753    }
754
755    fn pkg_info_stub(name: &str) -> PkgInfo {
756        PkgInfo {
757            name: name.into(),
758            version: "2.0".into(),
759            repo: Some("extra".into()),
760            groups: Vec::new(),
761            required_by: Vec::new(),
762            explicit: false,
763        }
764    }
765
766    #[test]
767    /// What: Verify `update_entry` marks target packages and records per-root relations correctly.
768    ///
769    /// Inputs:
770    /// - `targets`: Root and dependent package items forming the resolver seed.
771    /// - `state`: Fresh `ReverseResolverState` with cached info for the dependent package.
772    ///
773    /// Output:
774    /// - Aggregated entry reflects selection, contains relation for the root, and tracks parents.
775    ///
776    /// Details:
777    /// - Ensures depth calculation and parent recording occur when updating the entry for a target
778    ///   package linked to a specified root.
779    fn update_entry_tracks_root_relations_and_selection() {
780        let targets = vec![pkg_ref("root"), pkg_ref("app")];
781        let mut state = ReverseResolverState::new(&targets);
782        state.cache.insert("app".into(), pkg_info_stub("app"));
783
784        state.update_entry("app", "root", "root", 1);
785
786        let entry = state
787            .aggregated
788            .get("app")
789            .expect("aggregated entry populated");
790        assert!(entry.selected_for_removal, "target membership flagged");
791        assert_eq!(entry.info.name, "app");
792        let relation = entry
793            .per_root
794            .get("root")
795            .expect("relation stored for root");
796        assert_eq!(relation.min_depth(), 1);
797        assert!(relation.parents.contains("root"));
798    }
799
800    #[test]
801    /// What: Confirm `convert_entry` surfaces conflict reasons, metadata, and flags accurately.
802    ///
803    /// Inputs:
804    /// - `entry`: Aggregated dependency entry with multiple root relations and metadata toggles.
805    ///
806    /// Output:
807    /// - Resulting `Dependency` carries conflict status, sorted relations, and flag booleans.
808    ///
809    /// Details:
810    /// - Validates that reasons mention blocking roots, selection state, explicit install, and core/system
811    ///   classification while preserving alias names and parent ordering.
812    fn convert_entry_produces_conflict_reason_and_flags() {
813        let mut relation_a = RootRelation::new();
814        relation_a.record("root", 1);
815        let mut relation_b = RootRelation::new();
816        relation_b.record("parent_x", 2);
817        relation_b.record("parent_y", 2);
818
819        let entry = AggregatedEntry {
820            info: PkgInfo {
821                name: "dep_alias".into(),
822                version: "3.1".into(),
823                repo: Some("core".into()),
824                groups: vec!["base".into()],
825                required_by: Vec::new(),
826                explicit: true,
827            },
828            per_root: HashMap::from([("root".into(), relation_a), ("other".into(), relation_b)]),
829            selected_for_removal: true,
830        };
831
832        let info = convert_entry("dep".into(), entry);
833        let DependencyStatus::Conflict { reason } = &info.status else {
834            panic!("expected conflict status");
835        };
836        assert!(reason.contains("requires root"));
837        assert!(reason.contains("blocks other"));
838        assert!(reason.contains("already selected for removal"));
839        assert!(reason.contains("explicitly installed"));
840        assert_eq!(info.required_by, vec!["other", "root"]);
841        assert_eq!(info.depends_on, vec!["parent_x", "parent_y", "root"]);
842        assert!(info.is_core);
843        assert!(info.is_system);
844        assert_eq!(info.name, "dep_alias");
845    }
846
847    #[test]
848    /// What: Ensure pacman-style key/value parsing merges wrapped descriptions.
849    ///
850    /// Inputs:
851    /// - `sample`: Multi-line text where description continues on the next indented line.
852    ///
853    /// Output:
854    /// - Parsed map flattens wrapped lines and retains other keys verbatim.
855    ///
856    /// Details:
857    /// - Simulates `pacman -Qi` output to verify `parse_key_value_output` concatenates continuation
858    ///   lines into a single value.
859    fn parse_key_value_output_merges_wrapped_lines() {
860        let sample = "Name            : pkg\nDescription     : Short desc\n                continuation line\nRequired By     : foo bar\nInstall Reason  : Explicitly installed\n";
861        let map = parse_key_value_output(sample);
862        assert_eq!(map.get("Name"), Some(&"pkg".to_string()));
863        assert_eq!(
864            map.get("Description"),
865            Some(&"Short desc continuation line".to_string())
866        );
867        assert_eq!(map.get("Required By"), Some(&"foo bar".to_string()));
868    }
869
870    #[test]
871    /// What: Validate whitespace splitting helper ignores empty and "none" values.
872    ///
873    /// Inputs:
874    /// - `field`: Optional strings containing "None", whitespace, words, or `None`.
875    ///
876    /// Output:
877    /// - Returns empty vector for none-like inputs and splits valid whitespace-separated tokens.
878    ///
879    /// Details:
880    /// - Covers uppercase "None", blank strings, regular word lists, and the absence of a value.
881    fn split_ws_or_none_handles_none_and_empty() {
882        assert!(split_ws_or_none(Some(&"None".to_string())).is_empty());
883        assert!(split_ws_or_none(Some(&"   ".to_string())).is_empty());
884        let list = split_ws_or_none(Some(&"foo bar".to_string()));
885        assert_eq!(list, vec!["foo", "bar"]);
886        assert!(split_ws_or_none(None).is_empty());
887    }
888
889    #[test]
890    /// What: Test `RootRelation` depth tracking and parent recording.
891    ///
892    /// Inputs:
893    /// - `relation`: Fresh `RootRelation` instance.
894    ///
895    /// Output:
896    /// - Relation correctly tracks minimum depth and parent sets.
897    ///
898    /// Details:
899    /// - Verifies that depth is updated to minimum value and parents are accumulated.
900    fn root_relation_tracks_depth_and_parents() {
901        let mut relation = RootRelation::new();
902        assert_eq!(relation.min_depth(), usize::MAX);
903
904        relation.record("parent1", 2);
905        assert_eq!(relation.min_depth(), 2);
906        assert!(relation.parents.contains("parent1"));
907
908        relation.record("parent2", 1);
909        assert_eq!(relation.min_depth(), 1);
910        assert!(relation.parents.contains("parent1"));
911        assert!(relation.parents.contains("parent2"));
912
913        relation.record("", 3); // Empty parent should be ignored
914        assert_eq!(relation.min_depth(), 1);
915        assert_eq!(relation.parents.len(), 2);
916    }
917}