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(&current) else {
312                continue;
313            };
314
315            for dependent in info.required_by.iter().filter(|name| !name.is_empty()) {
316                state.update_entry(dependent, &current, 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}