pacsea/logic/preflight/
mod.rs

1//! Preflight summary computation helpers.
2//!
3//! The routines in this module gather package metadata, estimate download and
4//! install deltas, and derive risk heuristics used to populate the preflight
5//! modal. All command execution is abstracted behind [`CommandRunner`] so the
6//! logic can be exercised in isolation.
7
8mod 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
24/// Packages that contribute additional risk when present in a transaction.
25const 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/// What: Outcome of preflight summary computation.
39///
40/// Inputs: Produced by the summary computation helpers from package items and dependencies.
41///
42/// Output:
43/// - `summary`: Structured data powering the Summary tab.
44/// - `header`: Condensed metrics displayed in the modal header and execution sidebar.
45/// - `reverse_deps_report`: Optional reverse dependency report for Remove actions,
46///   cached to avoid redundant resolution when switching to the Deps tab.
47///
48/// Details:
49/// - Bundled together so downstream code can reuse the derived chip data without recomputation.
50/// - Contains the preflight summary data along with header metrics and optional reverse dependency information.
51/// - For Remove actions, the reverse dependency report is computed during summary
52///   computation and cached here to avoid recomputation when the user switches tabs.
53#[derive(Debug, Clone)]
54pub struct PreflightSummaryOutcome {
55    /// Preflight summary data.
56    pub summary: PreflightSummaryData,
57    /// Header chip metrics.
58    pub header: PreflightHeaderChips,
59    /// Cached reverse dependency report for Remove actions (None for Install actions).
60    pub reverse_deps_report: Option<crate::logic::deps::ReverseDependencyReport>,
61}
62
63/// What: Compute preflight summary data using the system command runner.
64///
65/// Inputs:
66/// - `items`: Packages scheduled for install/update/remove.
67/// - `action`: Active operation (install vs. remove) shaping the analysis.
68///
69/// Output:
70/// - [`PreflightSummaryOutcome`] combining Summary tab data and header chips.
71///
72/// Details:
73/// - Delegates to [`compute_preflight_summary_with_runner`] with
74///   [`SystemCommandRunner`].
75/// - Metadata lookups that fail are logged and treated as best-effort.
76#[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
85/// What: Intermediate state accumulated during package processing.
86///
87/// Inputs: Built incrementally while iterating packages.
88///
89/// Output: Used to construct the final summary and risk calculations.
90///
91/// Details: Groups related mutable state to reduce parameter passing.
92struct ProcessingState {
93    /// Packages being processed for preflight.
94    packages: Vec<PreflightPackageSummary>,
95    /// Count of AUR packages.
96    aur_count: usize,
97    /// Total download size in bytes.
98    total_download_bytes: u64,
99    /// Total install size delta in bytes (can be negative).
100    total_install_delta_bytes: i64,
101    /// Packages with major version bumps.
102    major_bump_packages: Vec<String>,
103    /// Core system packages being updated.
104    core_system_updates: Vec<String>,
105    /// Whether any package has a major version bump.
106    any_major_bump: bool,
107    /// Whether any core system package is being updated.
108    any_core_update: bool,
109    /// Whether any AUR package is included.
110    any_aur: bool,
111}
112
113impl ProcessingState {
114    /// What: Create a new processing state with specified capacity.
115    ///
116    /// Inputs:
117    /// - `capacity`: Initial capacity for the packages vector.
118    ///
119    /// Output: New `ProcessingState` with empty collections.
120    ///
121    /// Details: Initializes all fields to default/empty values with the specified capacity.
122    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
137/// What: Process a single package item and update processing state.
138///
139/// Inputs:
140/// - `item`: Package to process.
141/// - `action`: Install vs. remove context.
142/// - `runner`: Command execution abstraction.
143/// - `installed_version`: Previously fetched installed version (if any).
144/// - `installed_size`: Previously fetched installed size (if any).
145/// - `state`: Mutable state accumulator.
146///
147/// Output: Updates `state` in place.
148///
149/// Details:
150/// - Fetches metadata for official packages.
151/// - Computes version comparisons and notes.
152/// - Detects core packages and major version bumps.
153fn 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    // For Install actions, add note about installed packages that depend on this package
211    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
240/// What: Fetch metadata for official and AUR packages.
241///
242/// Inputs:
243/// - `runner`: Command execution abstraction.
244/// - `item`: Package item to fetch metadata for.
245///
246/// Output: Tuple of (`download_bytes`, `install_size_target`), both `Option`.
247///
248/// Details:
249/// - For official packages: uses `pacman -Si`.
250/// - For AUR packages: checks local caches (pacman cache, AUR helper caches) for built package files.
251fn 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
285/// What: Calculate install size delta based on action type.
286///
287/// Inputs:
288/// - `action`: Install vs. remove context.
289/// - `install_size_target`: Target install size (for installs).
290/// - `installed_size`: Current installed size.
291///
292/// Output: Delta in bytes (positive for installs, negative for removes).
293///
294/// Details: Returns None if metadata is unavailable.
295fn 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            // For downgrade, calculate delta similar to install (replacing with older version)
312            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
320/// What: Analyze version changes and generate notes.
321///
322/// Inputs:
323/// - `installed_version`: Current installed version (if any).
324/// - `target_version`: Target version.
325/// - `action`: Install vs. remove context.
326/// - `package_name`: Name of the package.
327/// - `major_bump_packages`: Mutable list to append to if major bump detected.
328/// - `any_major_bump`: Mutable flag to set if major bump detected.
329///
330/// Output: Tuple of (`notes`, `is_major_bump`, `is_downgrade`).
331///
332/// Details: Detects downgrades, major version bumps, and new installations.
333fn 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
370/// What: Check if package is a core/system package and generate note.
371///
372/// Inputs:
373/// - `item`: Package item to check.
374/// - `action`: Install vs. remove context.
375/// - `core_system_updates`: Mutable list to append to if core package.
376/// - `any_core_update`: Mutable flag to set if core package.
377///
378/// Output: Optional note string if core package detected.
379///
380/// Details: Normalizes package name for comparison against critical packages list.
381fn 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
404/// What: Calculate risk reasons and score from processing state.
405///
406/// Inputs:
407/// - `state`: Processing state with accumulated flags.
408/// - `pacnew_candidates`: Count of packages that may produce .pacnew files.
409/// - `service_restart_units`: List of services that need restart.
410/// - `action`: Preflight action (Install vs Remove).
411/// - `dependent_count`: Number of packages that depend on packages being removed (for Remove actions).
412///
413/// Output: Tuple of (`risk_reasons`, `risk_score`, `risk_level`).
414///
415/// Details: Applies the risk heuristic scoring system.
416fn 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    // For Remove actions, add risk when removing packages with dependencies
447    if matches!(action, PreflightAction::Remove) && dependent_count > 0 {
448        let risk_points = if dependent_count >= 5 {
449            3 // High risk for many dependencies
450        } else if dependent_count >= 2 {
451            2 // Medium risk for multiple dependencies
452        } else {
453            1 // Low risk for single dependency
454        };
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    // For Install actions, add risk when updating packages with installed dependents
461    // Add +2 risk points for each installed package that depends on packages being updated
462    if matches!(action, PreflightAction::Install) && dependent_count > 0 {
463        let risk_points = dependent_count.saturating_mul(2).min(255); // +2 per dependent package, cap at u8::MAX
464        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
480/// What: Build summary notes from processing state.
481///
482/// Inputs:
483/// - `state`: Processing state with accumulated flags.
484///
485/// Output: Vector of summary note strings.
486///
487/// Details: Generates informational notes for the summary tab.
488fn 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
502/// What: Process all package items and populate processing state.
503///
504/// Inputs:
505/// - `items`: Packages to process.
506/// - `action`: Install vs. remove context.
507/// - `runner`: Command execution abstraction.
508/// - `state`: Mutable state accumulator.
509///
510/// Output: Updates `state` in place.
511///
512/// Details: Batch fetches installed versions/sizes and processes each package.
513fn 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
543/// What: Resolve reverse dependencies for Remove actions and count installed dependents for Install actions.
544///
545/// Inputs:
546/// - `items`: Packages being removed or installed/updated.
547/// - `action`: Preflight action (Install vs Remove).
548///
549/// Output: Tuple of (`dependent_count`, `reverse_deps_report`).
550///
551/// Details:
552/// - For Remove actions: resolves and counts all dependent packages.
553/// - For Install actions: counts the total number of installed packages that depend on packages being updated.
554fn 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        // For Install actions, count the total number of installed dependent packages
564        // across all packages being updated
565        let mut total_dependents = 0;
566        for item in items {
567            // Only check installed packages (updates/reinstalls)
568            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
577/// What: Build summary data structure from processing state and risk metrics.
578///
579/// Inputs:
580/// - `state`: Processing state with accumulated data.
581/// - `items`: Original package items (for count).
582/// - `risk_reasons`: Risk reason strings.
583/// - `risk_score`: Calculated risk score.
584/// - `risk_level`: Calculated risk level.
585///
586/// Output: [`PreflightSummaryData`] structure.
587///
588/// Details: Constructs the complete summary data structure.
589fn 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
622/// What: Build header chips from extracted state values and risk metrics.
623///
624/// Inputs:
625/// - `package_count`: Number of packages.
626/// - `download_bytes`: Total download size in bytes.
627/// - `install_delta_bytes`: Total install size delta in bytes.
628/// - `aur_count`: Number of AUR packages.
629/// - `risk_score`: Calculated risk score.
630/// - `risk_level`: Calculated risk level.
631///
632/// Output: [`PreflightHeaderChips`] structure.
633///
634/// Details: Constructs the header chip metrics.
635const 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
653/// What: Compute preflight summary data using a custom command runner.
654///
655/// Inputs:
656/// - `items`: Packages to analyse.
657/// - `action`: Install vs. remove context.
658/// - `runner`: Command execution abstraction (mockable).
659///
660/// Output:
661/// - [`PreflightSummaryOutcome`] with fully materialised Summary data and
662///   header chip metrics.
663///
664/// Details:
665/// - Fetches installed versions/sizes via `pacman` when possible.
666/// - Applies the initial risk heuristic outlined in the specification.
667/// - Gracefully degrades metrics when metadata is unavailable.
668pub 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;