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