Skip to main content

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