pacsea/logic/services/
mod.rs

1//! Service impact resolution for the preflight "Services" tab.
2
3mod binaries;
4mod command;
5mod systemd;
6mod units;
7
8use std::collections::BTreeMap;
9use std::path::Path;
10
11use crate::state::modal::{ServiceImpact, ServiceRestartDecision};
12use crate::state::{PackageItem, PreflightAction};
13
14use binaries::collect_binaries_for_package;
15use systemd::{fetch_active_service_binaries, fetch_active_units};
16use units::collect_service_units_for_package;
17
18/// What: Resolve systemd service impacts for the selected transaction items.
19///
20/// Inputs:
21/// - `items`: Packages being installed or removed.
22/// - `action`: Preflight action (install/update vs. remove).
23///
24/// Output:
25/// - Vector of `ServiceImpact` entries representing impacted systemd units.
26///
27/// Details:
28/// - Inspects `pacman -Fl` output for each package to find shipped unit files.
29/// - Determines which units are currently active via `systemctl list-units`.
30/// - Heuristically detects binaries that impact active units, even without unit files.
31/// - Computes a recommended restart decision; defaults to defer when the unit
32///   is inactive or the action is a removal.
33pub fn resolve_service_impacts(
34    items: &[PackageItem],
35    action: PreflightAction,
36) -> Vec<ServiceImpact> {
37    let _span = tracing::info_span!(
38        "resolve_service_impacts",
39        stage = "services",
40        item_count = items.len()
41    )
42    .entered();
43    let start_time = std::time::Instant::now();
44    let mut unit_to_providers: BTreeMap<String, Vec<String>> = BTreeMap::new();
45
46    // First pass: collect units shipped by packages
47    for item in items {
48        match collect_service_units_for_package(&item.name, &item.source) {
49            Ok(units) => {
50                for unit in units {
51                    let providers = unit_to_providers.entry(unit).or_default();
52                    if !providers.iter().any(|name| name == &item.name) {
53                        providers.push(item.name.clone());
54                    }
55                }
56            }
57            Err(err) => {
58                // Only warn for official packages - AUR packages are expected to fail with pacman -Fl
59                if matches!(item.source, crate::state::types::Source::Official { .. }) {
60                    tracing::warn!(
61                        "Failed to resolve service units for package {}: {}",
62                        item.name,
63                        err
64                    );
65                } else {
66                    tracing::debug!(
67                        "Could not resolve service units for AUR package {} (expected): {}",
68                        item.name,
69                        err
70                    );
71                }
72            }
73        }
74    }
75
76    let active_units = fetch_active_units().unwrap_or_else(|err| {
77        tracing::warn!("Unable to query active services: {}", err);
78        std::collections::BTreeSet::new()
79    });
80
81    // Second pass: detect binaries that impact active units (heuristic enhancement)
82    // For both Install and Remove: detect which active services use binaries from packages
83    if !active_units.is_empty() {
84        // Get ExecStart paths for all active services
85        let active_service_binaries = fetch_active_service_binaries(&active_units);
86
87        // For each package, check if any of its binaries match active service binaries
88        for item in items {
89            match collect_binaries_for_package(&item.name, &item.source) {
90                Ok(binaries) => {
91                    for binary in binaries {
92                        // Check if this binary is used by any active service
93                        for (unit_name, service_binaries) in &active_service_binaries {
94                            if service_binaries.iter().any(|sb| {
95                                // Match exact path, or match binary name
96                                // Handle cases like: service uses "/usr/bin/foo", package provides "/usr/bin/foo"
97                                // or service uses "/usr/bin/foo", package provides "foo"
98                                sb == &binary
99                                    || binary.ends_with(sb)
100                                    || sb.ends_with(&binary)
101                                    || (binary.contains('/')
102                                        && sb.contains('/')
103                                        && Path::new(sb).file_name()
104                                            == Path::new(&binary).file_name())
105                            }) {
106                                let providers =
107                                    unit_to_providers.entry(unit_name.clone()).or_default();
108                                if !providers.iter().any(|name| name == &item.name) {
109                                    providers.push(item.name.clone());
110                                    let action_desc = if matches!(action, PreflightAction::Install)
111                                    {
112                                        "installing"
113                                    } else {
114                                        "removing"
115                                    };
116                                    tracing::debug!(
117                                        "Detected binary impact: {} package {} provides {} used by active service {}",
118                                        action_desc,
119                                        item.name,
120                                        binary,
121                                        unit_name
122                                    );
123                                }
124                            }
125                        }
126                    }
127                }
128                Err(err) => {
129                    tracing::debug!(
130                        "Failed to collect binaries for package {}: {}",
131                        item.name,
132                        err
133                    );
134                }
135            }
136        }
137    }
138
139    let results: Vec<ServiceImpact> = unit_to_providers
140        .into_iter()
141        .map(|(unit_name, mut providers)| {
142            providers.sort();
143            let is_active = active_units.contains(&unit_name);
144            // For Install: services need restart after installing new packages
145            // For Remove: services will break if active (warn user, but no restart decision needed)
146            let needs_restart = matches!(action, PreflightAction::Install) && is_active;
147            let recommended_decision = if needs_restart {
148                ServiceRestartDecision::Restart
149            } else {
150                ServiceRestartDecision::Defer
151            };
152
153            ServiceImpact {
154                unit_name,
155                providers,
156                is_active,
157                needs_restart,
158                recommended_decision,
159                restart_decision: recommended_decision,
160            }
161        })
162        .collect();
163
164    let elapsed = start_time.elapsed();
165    let duration_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
166    tracing::info!(
167        stage = "services",
168        item_count = items.len(),
169        result_count = results.len(),
170        duration_ms = duration_ms,
171        "Service resolution complete"
172    );
173    results
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::state::modal::ServiceRestartDecision;
180
181    #[test]
182    /// What: Verify the recommended decision logic defaults to defer when inactive.
183    ///
184    /// Inputs:
185    /// - Crafted service impacts simulating inactive units.
186    ///
187    /// Output:
188    /// - Ensures `resolve_service_impacts` would compute `Defer` when `needs_restart` is false.
189    ///
190    /// Details:
191    /// - Uses direct struct construction to avoid spawning commands in the test.
192    fn recommended_decision_default_is_defer_when_inactive() {
193        let impact = ServiceImpact {
194            unit_name: "example.service".into(),
195            providers: vec!["pkg".into()],
196            is_active: false,
197            needs_restart: false,
198            recommended_decision: ServiceRestartDecision::Defer,
199            restart_decision: ServiceRestartDecision::Defer,
200        };
201        assert_eq!(impact.recommended_decision, ServiceRestartDecision::Defer);
202    }
203}