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