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}