1mod batch;
9mod command;
10pub mod guardrails;
11mod metadata;
12mod version;
13
14use crate::state::modal::{
15 PreflightAction, PreflightHeaderChips, PreflightPackageSummary, PreflightSummaryData, RiskLevel,
16};
17use crate::state::types::{PackageItem, Source};
18use std::cmp::Ordering;
19
20pub use command::{CommandError, CommandRunner, SystemCommandRunner};
21
22use batch::{batch_fetch_installed_sizes, batch_fetch_installed_versions};
23use version::{compare_versions, is_major_version_bump};
24
25const CORE_CRITICAL_PACKAGES: &[&str] = &[
27 "linux",
28 "linux-lts",
29 "linux-zen",
30 "systemd",
31 "glibc",
32 "openssl",
33 "pacman",
34 "bash",
35 "util-linux",
36 "filesystem",
37];
38
39#[derive(Debug, Clone)]
55pub struct PreflightSummaryOutcome {
56 pub summary: PreflightSummaryData,
58 pub header: PreflightHeaderChips,
60 pub reverse_deps_report: Option<crate::logic::deps::ReverseDependencyReport>,
62}
63
64#[must_use]
78pub fn compute_preflight_summary(
79 items: &[PackageItem],
80 action: PreflightAction,
81) -> PreflightSummaryOutcome {
82 let runner = SystemCommandRunner;
83 compute_preflight_summary_with_runner(items, action, &runner)
84}
85
86struct ProcessingState {
94 packages: Vec<PreflightPackageSummary>,
96 aur_count: usize,
98 total_download_bytes: u64,
100 total_install_delta_bytes: i64,
102 major_bump_packages: Vec<String>,
104 core_system_updates: Vec<String>,
106 any_major_bump: bool,
108 any_core_update: bool,
110 any_aur: bool,
112}
113
114impl ProcessingState {
115 fn new(capacity: usize) -> Self {
124 Self {
125 packages: Vec::with_capacity(capacity),
126 aur_count: 0,
127 total_download_bytes: 0,
128 total_install_delta_bytes: 0,
129 major_bump_packages: Vec::new(),
130 core_system_updates: Vec::new(),
131 any_major_bump: false,
132 any_core_update: false,
133 any_aur: false,
134 }
135 }
136}
137
138fn process_package_item<R: CommandRunner>(
155 item: &PackageItem,
156 action: PreflightAction,
157 runner: &R,
158 installed_version: Option<String>,
159 installed_size: Option<u64>,
160 state: &mut ProcessingState,
161) {
162 if matches!(item.source, Source::Aur) {
163 state.aur_count += 1;
164 state.any_aur = true;
165 }
166
167 if installed_version.is_none() {
168 tracing::debug!(
169 "Preflight summary: failed to fetch installed version for {}",
170 item.name
171 );
172 }
173 if installed_size.is_none() {
174 tracing::debug!(
175 "Preflight summary: failed to fetch installed size for {}",
176 item.name
177 );
178 }
179
180 let (download_bytes, install_size_target) = fetch_package_metadata(runner, item);
181
182 let install_delta_bytes = calculate_install_delta(action, install_size_target, installed_size);
183
184 if let Some(bytes) = download_bytes {
185 state.total_download_bytes = state.total_download_bytes.saturating_add(bytes);
186 }
187 if let Some(delta) = install_delta_bytes {
188 state.total_install_delta_bytes = state.total_install_delta_bytes.saturating_add(delta);
189 }
190
191 let (notes, is_major_bump, is_downgrade) = analyze_version_changes(
192 installed_version.as_ref(),
193 &item.version,
194 action,
195 item.name.clone(),
196 &mut state.major_bump_packages,
197 &mut state.any_major_bump,
198 );
199
200 let core_note = check_core_package(
201 item,
202 action,
203 &mut state.core_system_updates,
204 &mut state.any_core_update,
205 );
206 let mut all_notes = notes;
207 if let Some(note) = core_note {
208 all_notes.push(note);
209 }
210
211 if matches!(action, PreflightAction::Install) && installed_version.is_some() {
213 let dependents = crate::logic::deps::get_installed_required_by(&item.name);
214 if !dependents.is_empty() {
215 let dependents_list = if dependents.len() <= 3 {
216 dependents.join(", ")
217 } else {
218 format!(
219 "{} (and {} more)",
220 dependents[..3].join(", "),
221 dependents.len() - 3
222 )
223 };
224 all_notes.push(format!("Required by installed packages: {dependents_list}"));
225 }
226 }
227
228 state.packages.push(PreflightPackageSummary {
229 name: item.name.clone(),
230 source: item.source.clone(),
231 installed_version,
232 target_version: item.version.clone(),
233 is_downgrade,
234 is_major_bump,
235 download_bytes,
236 install_delta_bytes,
237 notes: all_notes,
238 });
239}
240
241fn fetch_package_metadata<R: CommandRunner>(
253 runner: &R,
254 item: &PackageItem,
255) -> (Option<u64>, Option<u64>) {
256 match &item.source {
257 Source::Official { repo, .. } => {
258 match metadata::fetch_official_metadata(runner, repo, &item.name, item.version.as_str())
259 {
260 Ok(meta) => (meta.download_size, meta.install_size),
261 Err(err) => {
262 tracing::debug!(
263 "Preflight summary: failed to fetch metadata for {repo}/{pkg}: {err}",
264 pkg = item.name
265 );
266 (None, None)
267 }
268 }
269 }
270 Source::Aur => {
271 let meta =
272 metadata::fetch_aur_metadata(runner, &item.name, Some(item.version.as_str()));
273 if meta.download_size.is_some() || meta.install_size.is_some() {
274 tracing::debug!(
275 "Preflight summary: found AUR package sizes for {}: DL={:?}, Install={:?}",
276 item.name,
277 meta.download_size,
278 meta.install_size
279 );
280 }
281 (meta.download_size, meta.install_size)
282 }
283 }
284}
285
286fn calculate_install_delta(
297 action: PreflightAction,
298 install_size_target: Option<u64>,
299 installed_size: Option<u64>,
300) -> Option<i64> {
301 match action {
302 PreflightAction::Install => install_size_target.and_then(|target| {
303 let current = installed_size.unwrap_or(0);
304 let target_i64 = i64::try_from(target).ok()?;
305 let current_i64 = i64::try_from(current).ok()?;
306 Some(target_i64 - current_i64)
307 }),
308 PreflightAction::Remove => {
309 installed_size.and_then(|size| i64::try_from(size).ok().map(|s| -s))
310 }
311 PreflightAction::Downgrade => install_size_target.and_then(|target| {
312 let current = installed_size.unwrap_or(0);
314 let target_i64 = i64::try_from(target).ok()?;
315 let current_i64 = i64::try_from(current).ok()?;
316 Some(target_i64 - current_i64)
317 }),
318 }
319}
320
321fn analyze_version_changes(
335 installed_version: Option<&String>,
336 target_version: &str,
337 action: PreflightAction,
338 package_name: String,
339 major_bump_packages: &mut Vec<String>,
340 any_major_bump: &mut bool,
341) -> (Vec<String>, bool, bool) {
342 let mut notes = Vec::new();
343 let mut is_major_bump = false;
344 let mut is_downgrade = false;
345
346 if let Some(current) = installed_version {
347 match compare_versions(current, target_version) {
348 Ordering::Greater => {
349 if matches!(action, PreflightAction::Install) {
350 is_downgrade = true;
351 notes.push(format!("Downgrade detected: {current} → {target_version}"));
352 }
353 }
354 Ordering::Less => {
355 if is_major_version_bump(current, target_version) {
356 is_major_bump = true;
357 *any_major_bump = true;
358 major_bump_packages.push(package_name);
359 notes.push(format!("Major version bump: {current} → {target_version}"));
360 }
361 }
362 Ordering::Equal => {}
363 }
364 } else if matches!(action, PreflightAction::Install) {
365 notes.push("New installation".to_string());
366 }
367
368 (notes, is_major_bump, is_downgrade)
369}
370
371fn check_core_package(
383 item: &PackageItem,
384 action: PreflightAction,
385 core_system_updates: &mut Vec<String>,
386 any_core_update: &mut bool,
387) -> Option<String> {
388 let normalized_name = item.name.to_ascii_lowercase();
389 if CORE_CRITICAL_PACKAGES
390 .iter()
391 .any(|candidate| normalized_name == *candidate)
392 {
393 *any_core_update = true;
394 core_system_updates.push(item.name.clone());
395 Some(if matches!(action, PreflightAction::Remove) {
396 "Removing core/system package".to_string()
397 } else {
398 "Core/system package update".to_string()
399 })
400 } else {
401 None
402 }
403}
404
405fn calculate_risk_metrics(
418 state: &ProcessingState,
419 pacnew_candidates: usize,
420 service_restart_units: &[String],
421 action: PreflightAction,
422 dependent_count: usize,
423) -> (Vec<String>, u8, RiskLevel) {
424 let mut risk_reasons = Vec::new();
425 let mut risk_score: u8 = 0;
426
427 if state.any_core_update {
428 risk_reasons.push("Core/system packages involved (+3)".to_string());
429 risk_score = risk_score.saturating_add(3);
430 }
431 if state.any_major_bump {
432 risk_reasons.push("Major version bump detected (+2)".to_string());
433 risk_score = risk_score.saturating_add(2);
434 }
435 if state.any_aur {
436 risk_reasons.push("AUR packages included (+2)".to_string());
437 risk_score = risk_score.saturating_add(2);
438 }
439 if pacnew_candidates > 0 {
440 risk_reasons.push("Configuration files may produce .pacnew (+1)".to_string());
441 risk_score = risk_score.saturating_add(1);
442 }
443 if !service_restart_units.is_empty() {
444 risk_reasons.push("Services likely require restart (+1)".to_string());
445 risk_score = risk_score.saturating_add(1);
446 }
447 if matches!(action, PreflightAction::Remove) && dependent_count > 0 {
449 let risk_points = if dependent_count >= 5 {
450 3 } else if dependent_count >= 2 {
452 2 } else {
454 1 };
456 risk_reasons.push(format!(
457 "Removing packages with {dependent_count} dependent package(s) (+{risk_points})"
458 ));
459 risk_score = risk_score.saturating_add(risk_points);
460 }
461 if matches!(action, PreflightAction::Install) && dependent_count > 0 {
464 let risk_points = dependent_count.saturating_mul(2).min(255); let risk_points_u8 = u8::try_from(risk_points).unwrap_or(255);
466 risk_reasons.push(format!(
467 "{dependent_count} installed package(s) depend on packages being updated (+{risk_points_u8})"
468 ));
469 risk_score = risk_score.saturating_add(risk_points_u8);
470 }
471
472 let risk_level = match risk_score {
473 0 => RiskLevel::Low,
474 1..=4 => RiskLevel::Medium,
475 _ => RiskLevel::High,
476 };
477
478 (risk_reasons, risk_score, risk_level)
479}
480
481fn build_summary_notes(state: &ProcessingState) -> Vec<String> {
490 let mut notes = Vec::new();
491 if state.any_core_update {
492 notes.push("Core/system packages will be modified.".to_string());
493 }
494 if state.any_major_bump {
495 notes.push("Major version changes detected; review changelogs.".to_string());
496 }
497 if state.any_aur {
498 notes.push("AUR packages present; build steps may vary.".to_string());
499 }
500 notes
501}
502
503fn process_all_packages<R: CommandRunner>(
515 items: &[PackageItem],
516 action: PreflightAction,
517 runner: &R,
518 state: &mut ProcessingState,
519) {
520 let installed_versions = batch_fetch_installed_versions(runner, items);
521 let installed_sizes = batch_fetch_installed_sizes(runner, items);
522
523 for (idx, item) in items.iter().enumerate() {
524 let installed_version = installed_versions
525 .get(idx)
526 .and_then(|v| v.as_ref().ok())
527 .cloned();
528 let installed_size = installed_sizes
529 .get(idx)
530 .and_then(|s| s.as_ref().ok())
531 .copied();
532
533 process_package_item(
534 item,
535 action,
536 runner,
537 installed_version,
538 installed_size,
539 state,
540 );
541 }
542}
543
544fn resolve_reverse_deps(
556 items: &[PackageItem],
557 action: PreflightAction,
558) -> (usize, Option<crate::logic::deps::ReverseDependencyReport>) {
559 if matches!(action, PreflightAction::Remove) {
560 let report = crate::logic::deps::resolve_reverse_dependencies(items);
561 let count = report.dependencies.len();
562 (count, Some(report))
563 } else {
564 let mut total_dependents = 0;
567 for item in items {
568 if crate::index::is_installed(&item.name) {
570 let dependents = crate::logic::deps::get_installed_required_by(&item.name);
571 total_dependents += dependents.len();
572 }
573 }
574 (total_dependents, None)
575 }
576}
577
578fn build_summary_data(
591 state: ProcessingState,
592 items: &[PackageItem],
593 risk_reasons: &[String],
594 risk_score: u8,
595 risk_level: RiskLevel,
596) -> PreflightSummaryData {
597 let summary_notes = build_summary_notes(&state);
598 let mut summary_warnings = Vec::new();
599 if summary_warnings.is_empty() {
600 summary_warnings.extend(risk_reasons.iter().cloned());
601 }
602
603 PreflightSummaryData {
604 packages: state.packages,
605 package_count: items.len(),
606 aur_count: state.aur_count,
607 download_bytes: state.total_download_bytes,
608 install_delta_bytes: state.total_install_delta_bytes,
609 risk_score,
610 risk_level,
611 risk_reasons: risk_reasons.to_vec(),
612 major_bump_packages: state.major_bump_packages,
613 core_system_updates: state.core_system_updates,
614 pacnew_candidates: 0,
615 pacsave_candidates: 0,
616 config_warning_packages: Vec::new(),
617 service_restart_units: Vec::new(),
618 summary_warnings,
619 summary_notes,
620 }
621}
622
623const fn build_header_chips(
637 package_count: usize,
638 download_bytes: u64,
639 install_delta_bytes: i64,
640 aur_count: usize,
641 risk_score: u8,
642 risk_level: RiskLevel,
643) -> PreflightHeaderChips {
644 PreflightHeaderChips {
645 package_count,
646 download_bytes,
647 install_delta_bytes,
648 aur_count,
649 risk_score,
650 risk_level,
651 }
652}
653
654pub fn compute_preflight_summary_with_runner<R: CommandRunner>(
670 items: &[PackageItem],
671 action: PreflightAction,
672 runner: &R,
673) -> PreflightSummaryOutcome {
674 let _span = tracing::info_span!(
675 "compute_preflight_summary",
676 stage = "summary",
677 item_count = items.len()
678 )
679 .entered();
680 let start_time = std::time::Instant::now();
681
682 let mut state = ProcessingState::new(items.len());
683 process_all_packages(items, action, runner, &mut state);
684
685 let (dependent_count, reverse_deps_report) = resolve_reverse_deps(items, action);
686
687 let (risk_reasons, risk_score, risk_level) =
688 calculate_risk_metrics(&state, 0, &[], action, dependent_count);
689
690 let header = build_header_chips(
691 items.len(),
692 state.total_download_bytes,
693 state.total_install_delta_bytes,
694 state.aur_count,
695 risk_score,
696 risk_level,
697 );
698
699 let summary = build_summary_data(state, items, &risk_reasons, risk_score, risk_level);
700
701 let elapsed = start_time.elapsed();
702 let duration_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
703 tracing::info!(
704 stage = "summary",
705 item_count = items.len(),
706 duration_ms = duration_ms,
707 "Preflight summary computation complete"
708 );
709
710 PreflightSummaryOutcome {
711 summary,
712 header,
713 reverse_deps_report,
714 }
715}
716
717#[cfg(all(test, unix))]
718mod tests;