Skip to main content

pacsea/events/search/
preflight_helpers.rs

1use crate::state::{AppState, PackageItem};
2
3/// What: Filter cached dependencies for current items.
4///
5/// Inputs:
6/// - `app`: Application state with cached dependencies.
7/// - `item_names`: Set of current package names.
8///
9/// Output:
10/// - Vector of matching cached dependencies.
11fn filter_cached_dependencies(
12    app: &AppState,
13    item_names: &std::collections::HashSet<String>,
14) -> Vec<crate::state::modal::DependencyInfo> {
15    app.install_list_deps
16        .iter()
17        .filter(|dep| {
18            dep.required_by
19                .iter()
20                .any(|req_by| item_names.contains(req_by))
21        })
22        .cloned()
23        .collect()
24}
25
26/// What: Filter cached files for current items.
27///
28/// Inputs:
29/// - `app`: Application state with cached files.
30/// - `item_names`: Set of current package names.
31///
32/// Output:
33/// - Vector of matching cached files.
34fn filter_cached_files(
35    app: &AppState,
36    item_names: &std::collections::HashSet<String>,
37) -> Vec<crate::state::modal::PackageFileInfo> {
38    app.install_list_files
39        .iter()
40        .filter(|file_info| item_names.contains(&file_info.name))
41        .cloned()
42        .collect()
43}
44
45/// What: Trigger background resolution for preflight data.
46///
47/// Inputs:
48/// - `app`: Application state.
49/// - `items`: Packages to resolve.
50/// - `dependency_info`: Current dependency info (empty triggers resolution).
51/// - `cached_files`: Current cached files (empty triggers resolution).
52///
53/// Output:
54/// - Updates app state with resolution flags and items.
55fn trigger_background_resolution(
56    app: &mut AppState,
57    items: &[PackageItem],
58    dependency_info: &[crate::state::modal::DependencyInfo],
59    cached_files: &[crate::state::modal::PackageFileInfo],
60) {
61    if dependency_info.is_empty() {
62        app.preflight_deps_items = Some((
63            items.to_vec(),
64            crate::state::modal::PreflightAction::Install,
65        ));
66        app.preflight_deps_resolving = true;
67    }
68    if cached_files.is_empty() {
69        app.preflight_files_items = Some(items.to_vec());
70        app.preflight_files_resolving = true;
71    }
72    app.preflight_services_items = Some(items.to_vec());
73    app.preflight_services_resolving = true;
74    let aur_items: Vec<_> = items
75        .iter()
76        .filter(|p| matches!(p.source, crate::state::Source::Aur))
77        .cloned()
78        .collect();
79    if !aur_items.is_empty() {
80        app.preflight_sandbox_items = Some(aur_items);
81        app.preflight_sandbox_resolving = true;
82    }
83}
84
85/// What: Create preflight modal with cached data.
86///
87/// Inputs:
88/// - `app`: Application state.
89/// - `items`: Packages under review.
90/// - `summary`: Preflight summary data.
91/// - `header`: Header chips data.
92/// - `dependency_info`: Dependency information.
93/// - `cached_files`: Cached file information.
94///
95/// Output:
96/// - Creates and sets the Preflight modal in app state.
97fn create_preflight_modal_with_cache(
98    app: &mut AppState,
99    items: Vec<PackageItem>,
100    summary: crate::state::modal::PreflightSummaryData,
101    header: crate::state::modal::PreflightHeaderChips,
102    dependency_info: Vec<crate::state::modal::DependencyInfo>,
103    cached_files: Vec<crate::state::modal::PackageFileInfo>,
104) {
105    app.modal = crate::state::Modal::Preflight {
106        items,
107        action: crate::state::PreflightAction::Install,
108        tab: crate::state::PreflightTab::Deps,
109        summary: Some(Box::new(summary)),
110        summary_scroll: 0,
111        header_chips: header,
112        dependency_info,
113        dep_selected: 0,
114        dep_tree_expanded: std::collections::HashSet::new(),
115        deps_error: None,
116        file_info: cached_files,
117        file_selected: 0,
118        file_tree_expanded: std::collections::HashSet::new(),
119        files_error: None,
120        service_info: Vec::new(),
121        service_selected: 0,
122        services_loaded: false,
123        services_error: None,
124        sandbox_info: Vec::new(),
125        sandbox_selected: 0,
126        sandbox_tree_expanded: std::collections::HashSet::new(),
127        sandbox_loaded: false,
128        sandbox_error: None,
129        selected_optdepends: std::collections::HashMap::new(),
130        cascade_mode: app.remove_cascade_mode,
131        cached_reverse_deps_report: None,
132    };
133}
134
135/// What: Create preflight modal for insert mode (background computation).
136///
137/// Inputs:
138/// - `app`: Application state.
139/// - `items`: Packages under review.
140///
141/// Output:
142/// - Creates and sets the Preflight modal with background computation queued.
143fn create_preflight_modal_insert_mode(app: &mut AppState, items: Vec<PackageItem>) {
144    let items_clone = items.clone();
145    app.preflight_cancelled
146        .store(false, std::sync::atomic::Ordering::Relaxed);
147    app.preflight_summary_items = Some((items_clone, crate::state::PreflightAction::Install));
148    app.preflight_summary_resolving = true;
149    app.pending_service_plan.clear();
150    app.modal = crate::state::Modal::Preflight {
151        items,
152        action: crate::state::PreflightAction::Install,
153        tab: crate::state::PreflightTab::Summary,
154        summary: None,
155        summary_scroll: 0,
156        header_chips: crate::state::modal::PreflightHeaderChips {
157            package_count: 1,
158            download_bytes: 0,
159            install_delta_bytes: 0,
160            aur_count: 0,
161            risk_score: 0,
162            risk_level: crate::state::modal::RiskLevel::Low,
163        },
164        dependency_info: Vec::new(),
165        dep_selected: 0,
166        dep_tree_expanded: std::collections::HashSet::new(),
167        deps_error: None,
168        file_info: Vec::new(),
169        file_selected: 0,
170        file_tree_expanded: std::collections::HashSet::new(),
171        files_error: None,
172        service_info: Vec::new(),
173        service_selected: 0,
174        services_loaded: false,
175        services_error: None,
176        sandbox_info: Vec::new(),
177        sandbox_selected: 0,
178        sandbox_tree_expanded: std::collections::HashSet::new(),
179        sandbox_loaded: false,
180        sandbox_error: None,
181        selected_optdepends: std::collections::HashMap::new(),
182        cascade_mode: app.remove_cascade_mode,
183        cached_reverse_deps_report: None,
184    };
185}
186
187/// What: Open preflight modal with cached dependencies and files, or trigger background resolution.
188///
189/// Inputs:
190/// - `app`: Mutable application state
191/// - `items`: Packages to open preflight for
192/// - `use_cache`: Whether to use cached dependencies/files or trigger background resolution
193///
194/// Output:
195/// - None (modifies app state directly)
196///
197/// Details:
198/// - If `use_cache` is true, checks cache and uses cached data if available, otherwise triggers background resolution.
199/// - If `use_cache` is false, always triggers background resolution (used in insert mode).
200/// - Sets up all preflight resolution flags and initializes the modal state.
201pub fn open_preflight_modal(app: &mut AppState, items: Vec<PackageItem>, use_cache: bool) {
202    if crate::theme::settings().skip_preflight {
203        if crate::events::install::try_open_warn_aur_repo_duplicate_modal(
204            app,
205            &items,
206            crate::state::modal::PreflightHeaderChips::default(),
207        ) {
208            return;
209        }
210        // Direct install - check for reinstalls first, then batch updates
211        // First, check if we're installing packages that are already installed (reinstall scenario)
212        // BUT exclude packages that have updates available (those should go through normal update flow)
213        let installed_set = crate::logic::deps::get_installed_packages();
214        let provided_set = crate::logic::deps::get_provided_packages(&installed_set);
215        let upgradable_set = crate::logic::deps::get_upgradable_packages();
216
217        let installed_packages: Vec<crate::state::PackageItem> = items
218            .iter()
219            .filter(|item| {
220                // Check if package is installed or provided by an installed package
221                let is_installed = crate::logic::deps::is_package_installed_or_provided(
222                    &item.name,
223                    &installed_set,
224                    &provided_set,
225                );
226
227                if !is_installed {
228                    return false;
229                }
230
231                // Check if package has an update available
232                // For official packages: check if it's in upgradable_set OR version differs from installed
233                // For AUR packages: check if target version is different from installed version
234                let has_update = if upgradable_set.contains(&item.name) {
235                    // Package is in upgradable set (pacman -Qu)
236                    true
237                } else if !item.version.is_empty() {
238                    // Normalize target version by removing revision suffix (same as installed version normalization)
239                    let normalized_target_version =
240                        item.version.split('-').next().unwrap_or(&item.version);
241                    // Compare normalized target version with normalized installed version
242                    // This works for both official and AUR packages
243                    crate::logic::deps::get_installed_version(&item.name).is_ok_and(
244                        |installed_version| normalized_target_version != installed_version,
245                    )
246                } else {
247                    // No version info available, no update
248                    false
249                };
250
251                // Only show reinstall confirmation if installed AND no update available
252                // If update is available, it should go through normal update flow
253                !has_update
254            })
255            .cloned()
256            .collect();
257
258        if !installed_packages.is_empty() {
259            // Show reinstall confirmation modal
260            // Store both installed packages (for display) and all packages (for installation)
261            app.modal = crate::state::Modal::ConfirmReinstall {
262                items: installed_packages,
263                all_items: items,
264                header_chips: crate::state::modal::PreflightHeaderChips::default(),
265            };
266            return;
267        }
268
269        // Check if this is a batch update scenario requiring confirmation
270        // Only show if there's actually an update available (package is upgradable)
271        // AND the package has installed packages in its "Required By" field (dependency risk)
272        let has_versions = items.iter().any(|item| {
273            matches!(item.source, crate::state::Source::Official { .. }) && !item.version.is_empty()
274        });
275        let has_upgrade_available = items.iter().any(|item| {
276            matches!(item.source, crate::state::Source::Official { .. })
277                && upgradable_set.contains(&item.name)
278        });
279
280        // Only show warning if package has installed packages in "Required By" (dependency risk)
281        let has_installed_required_by = items.iter().any(|item| {
282            matches!(item.source, crate::state::Source::Official { .. })
283                && crate::index::is_installed(&item.name)
284                && crate::logic::deps::has_installed_required_by(&item.name)
285        });
286
287        if has_versions && has_upgrade_available && has_installed_required_by {
288            // Show confirmation modal for batch updates (only if update is actually available
289            // AND package has installed dependents that could be affected)
290            app.modal = crate::state::Modal::ConfirmBatchUpdate {
291                items,
292                dry_run: app.dry_run,
293            };
294            return;
295        }
296
297        crate::install::start_integrated_install_all(app, &items, app.dry_run);
298        app.toast_message = Some(crate::i18n::t(app, "app.toasts.installing_skipped"));
299        app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
300        return;
301    }
302
303    if use_cache {
304        // Reset cancellation flag when opening new preflight
305        app.preflight_cancelled
306            .store(false, std::sync::atomic::Ordering::Relaxed);
307
308        let crate::logic::preflight::PreflightSummaryOutcome {
309            summary,
310            header,
311            reverse_deps_report: _,
312        } = crate::logic::preflight::compute_preflight_summary(
313            &items,
314            crate::state::PreflightAction::Install,
315        );
316        app.pending_service_plan.clear();
317
318        let item_names: std::collections::HashSet<String> =
319            items.iter().map(|i| i.name.clone()).collect();
320        let cached_deps = filter_cached_dependencies(app, &item_names);
321        let cached_files = filter_cached_files(app, &item_names);
322
323        let dependency_info = if cached_deps.is_empty() {
324            tracing::debug!(
325                "[Preflight] Cache empty, will trigger background dependency resolution for {} packages",
326                items.len()
327            );
328            Vec::new()
329        } else {
330            cached_deps
331        };
332
333        trigger_background_resolution(app, &items, &dependency_info, &cached_files);
334        create_preflight_modal_with_cache(
335            app,
336            items,
337            summary,
338            header,
339            dependency_info,
340            cached_files,
341        );
342    } else {
343        create_preflight_modal_insert_mode(app, items);
344    }
345    app.toast_message = Some(if use_cache {
346        crate::i18n::t(app, "app.toasts.preflight_opened")
347    } else {
348        "Preflight opened".to_string()
349    });
350    app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(2));
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    /// What: Provide a baseline `AppState` for preflight helper tests.
358    ///
359    /// Inputs: None
360    ///
361    /// Output: Fresh `AppState` with default values.
362    fn new_app() -> AppState {
363        AppState::default()
364    }
365
366    /// What: Create a test package item for testing.
367    ///
368    /// Inputs:
369    /// - `name`: Package name
370    ///
371    /// Output: A test `PackageItem` with official source.
372    fn test_package(name: &str) -> PackageItem {
373        PackageItem {
374            name: name.to_string(),
375            version: "1.0.0".to_string(),
376            description: "Test package".to_string(),
377            source: crate::state::Source::Official {
378                repo: "extra".to_string(),
379                arch: "x86_64".to_string(),
380            },
381            popularity: None,
382            out_of_date: None,
383            orphaned: false,
384        }
385    }
386
387    #[test]
388    /// What: Verify that `trigger_background_resolution` sets resolution flags correctly.
389    ///
390    /// Inputs:
391    /// - App state
392    /// - Empty `dependency_info` and `cached_files`
393    ///
394    /// Output:
395    /// - Resolution flags and items are set
396    ///
397    /// Details:
398    /// - Tests that background resolution is properly triggered when caches are empty.
399    fn trigger_background_resolution_sets_flags_when_cache_empty() {
400        let mut app = new_app();
401        let items = vec![test_package("test-pkg")];
402
403        trigger_background_resolution(&mut app, &items, &[], &[]);
404
405        // Flags should be set
406        assert!(app.preflight_deps_resolving);
407        assert!(app.preflight_files_resolving);
408        assert!(app.preflight_services_resolving);
409        // Items should be queued
410        assert!(app.preflight_deps_items.is_some());
411        assert!(app.preflight_files_items.is_some());
412        assert!(app.preflight_services_items.is_some());
413    }
414
415    #[test]
416    /// What: Verify that `trigger_background_resolution` does not set deps flag when cache has deps.
417    ///
418    /// Inputs:
419    /// - App state
420    /// - Non-empty `dependency_info`
421    ///
422    /// Output:
423    /// - Deps resolution flag and items are not set
424    ///
425    /// Details:
426    /// - Tests that existing cached deps prevent re-resolution.
427    fn trigger_background_resolution_skips_deps_when_cached() {
428        let mut app = new_app();
429        let items = vec![test_package("test-pkg")];
430        let cached_deps = vec![crate::state::modal::DependencyInfo {
431            name: "cached-dep".to_string(),
432            version: "1.0".to_string(),
433            status: crate::state::modal::DependencyStatus::ToInstall,
434            source: crate::state::modal::DependencySource::Official {
435                repo: "extra".to_string(),
436            },
437            required_by: vec!["test-pkg".to_string()],
438            depends_on: Vec::new(),
439            is_core: false,
440            is_system: false,
441        }];
442
443        trigger_background_resolution(&mut app, &items, &cached_deps, &[]);
444
445        // Deps should not be triggered (cache has data)
446        assert!(!app.preflight_deps_resolving);
447        assert!(app.preflight_deps_items.is_none());
448        // Files should still be triggered (cache empty)
449        assert!(app.preflight_files_resolving);
450        assert!(app.preflight_files_items.is_some());
451    }
452
453    #[test]
454    /// What: Verify `create_preflight_modal_insert_mode` resets `preflight_cancelled` flag.
455    ///
456    /// Inputs:
457    /// - App state with `preflight_cancelled` set to true
458    ///
459    /// Output:
460    /// - `preflight_cancelled` is reset to false
461    ///
462    /// Details:
463    /// - Tests the insert mode path resets the cancellation flag.
464    fn create_preflight_modal_insert_mode_resets_cancelled() {
465        let mut app = new_app();
466        app.preflight_cancelled
467            .store(true, std::sync::atomic::Ordering::Relaxed);
468        let items = vec![test_package("test-pkg")];
469
470        create_preflight_modal_insert_mode(&mut app, items);
471
472        // Cancelled flag should be reset
473        assert!(
474            !app.preflight_cancelled
475                .load(std::sync::atomic::Ordering::Relaxed)
476        );
477    }
478
479    #[test]
480    /// What: Verify `filter_cached_dependencies` returns only matching deps.
481    ///
482    /// Inputs:
483    /// - App state with cached dependencies
484    /// - Set of item names to filter by
485    ///
486    /// Output:
487    /// - Only dependencies matching the item names are returned
488    ///
489    /// Details:
490    /// - Tests that dependency filtering works correctly.
491    fn filter_cached_dependencies_returns_matching() {
492        let mut app = new_app();
493        app.install_list_deps = vec![
494            crate::state::modal::DependencyInfo {
495                name: "dep-a".to_string(),
496                version: "1.0".to_string(),
497                status: crate::state::modal::DependencyStatus::ToInstall,
498                source: crate::state::modal::DependencySource::Official {
499                    repo: "extra".to_string(),
500                },
501                required_by: vec!["pkg-a".to_string()],
502                depends_on: Vec::new(),
503                is_core: false,
504                is_system: false,
505            },
506            crate::state::modal::DependencyInfo {
507                name: "dep-b".to_string(),
508                version: "1.0".to_string(),
509                status: crate::state::modal::DependencyStatus::ToInstall,
510                source: crate::state::modal::DependencySource::Official {
511                    repo: "extra".to_string(),
512                },
513                required_by: vec!["pkg-b".to_string()],
514                depends_on: Vec::new(),
515                is_core: false,
516                is_system: false,
517            },
518        ];
519
520        let mut item_names = std::collections::HashSet::new();
521        item_names.insert("pkg-a".to_string());
522
523        let result = filter_cached_dependencies(&app, &item_names);
524
525        assert_eq!(result.len(), 1);
526        assert_eq!(result[0].name, "dep-a");
527    }
528
529    #[test]
530    /// What: Verify `filter_cached_files` returns only matching files.
531    ///
532    /// Inputs:
533    /// - App state with cached file info
534    /// - Set of item names to filter by
535    ///
536    /// Output:
537    /// - Only file info matching the item names are returned
538    ///
539    /// Details:
540    /// - Tests that file filtering works correctly.
541    fn filter_cached_files_returns_matching() {
542        let mut app = new_app();
543        app.install_list_files = vec![
544            crate::state::modal::PackageFileInfo {
545                name: "pkg-a".to_string(),
546                files: vec![crate::state::modal::FileChange {
547                    path: "/usr/bin/a".to_string(),
548                    change_type: crate::state::modal::FileChangeType::New,
549                    package: "pkg-a".to_string(),
550                    is_config: false,
551                    predicted_pacnew: false,
552                    predicted_pacsave: false,
553                }],
554                total_count: 1,
555                new_count: 1,
556                changed_count: 0,
557                removed_count: 0,
558                config_count: 0,
559                pacnew_candidates: 0,
560                pacsave_candidates: 0,
561            },
562            crate::state::modal::PackageFileInfo {
563                name: "pkg-b".to_string(),
564                files: vec![crate::state::modal::FileChange {
565                    path: "/usr/bin/b".to_string(),
566                    change_type: crate::state::modal::FileChangeType::New,
567                    package: "pkg-b".to_string(),
568                    is_config: false,
569                    predicted_pacnew: false,
570                    predicted_pacsave: false,
571                }],
572                total_count: 1,
573                new_count: 1,
574                changed_count: 0,
575                removed_count: 0,
576                config_count: 0,
577                pacnew_candidates: 0,
578                pacsave_candidates: 0,
579            },
580        ];
581
582        let mut item_names = std::collections::HashSet::new();
583        item_names.insert("pkg-a".to_string());
584
585        let result = filter_cached_files(&app, &item_names);
586
587        assert_eq!(result.len(), 1);
588        assert_eq!(result[0].name, "pkg-a");
589    }
590}