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(¤t) else {
122 continue;
123 };
124
125 for dependent in info.required_by.iter().filter(|name| !name.is_empty()) {
126 state.update_entry(dependent, ¤t, 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}