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        // Direct install - check for reinstalls first, then batch updates
204        // First, check if we're installing packages that are already installed (reinstall scenario)
205        // BUT exclude packages that have updates available (those should go through normal update flow)
206        let installed_set = crate::logic::deps::get_installed_packages();
207        let provided_set = crate::logic::deps::get_provided_packages(&installed_set);
208        let upgradable_set = crate::logic::deps::get_upgradable_packages();
209
210        let installed_packages: Vec<crate::state::PackageItem> = items
211            .iter()
212            .filter(|item| {
213                // Check if package is installed or provided by an installed package
214                let is_installed = crate::logic::deps::is_package_installed_or_provided(
215                    &item.name,
216                    &installed_set,
217                    &provided_set,
218                );
219
220                if !is_installed {
221                    return false;
222                }
223
224                // Check if package has an update available
225                // For official packages: check if it's in upgradable_set OR version differs from installed
226                // For AUR packages: check if target version is different from installed version
227                let has_update = if upgradable_set.contains(&item.name) {
228                    // Package is in upgradable set (pacman -Qu)
229                    true
230                } else if !item.version.is_empty() {
231                    // Normalize target version by removing revision suffix (same as installed version normalization)
232                    let normalized_target_version =
233                        item.version.split('-').next().unwrap_or(&item.version);
234                    // Compare normalized target version with normalized installed version
235                    // This works for both official and AUR packages
236                    crate::logic::deps::get_installed_version(&item.name).is_ok_and(
237                        |installed_version| normalized_target_version != installed_version,
238                    )
239                } else {
240                    // No version info available, no update
241                    false
242                };
243
244                // Only show reinstall confirmation if installed AND no update available
245                // If update is available, it should go through normal update flow
246                !has_update
247            })
248            .cloned()
249            .collect();
250
251        if !installed_packages.is_empty() {
252            // Show reinstall confirmation modal
253            // Store both installed packages (for display) and all packages (for installation)
254            app.modal = crate::state::Modal::ConfirmReinstall {
255                items: installed_packages,
256                all_items: items,
257                header_chips: crate::state::modal::PreflightHeaderChips::default(),
258            };
259            return;
260        }
261
262        // Check if this is a batch update scenario requiring confirmation
263        // Only show if there's actually an update available (package is upgradable)
264        // AND the package has installed packages in its "Required By" field (dependency risk)
265        let has_versions = items.iter().any(|item| {
266            matches!(item.source, crate::state::Source::Official { .. }) && !item.version.is_empty()
267        });
268        let has_upgrade_available = items.iter().any(|item| {
269            matches!(item.source, crate::state::Source::Official { .. })
270                && upgradable_set.contains(&item.name)
271        });
272
273        // Only show warning if package has installed packages in "Required By" (dependency risk)
274        let has_installed_required_by = items.iter().any(|item| {
275            matches!(item.source, crate::state::Source::Official { .. })
276                && crate::index::is_installed(&item.name)
277                && crate::logic::deps::has_installed_required_by(&item.name)
278        });
279
280        if has_versions && has_upgrade_available && has_installed_required_by {
281            // Show confirmation modal for batch updates (only if update is actually available
282            // AND package has installed dependents that could be affected)
283            app.modal = crate::state::Modal::ConfirmBatchUpdate {
284                items,
285                dry_run: app.dry_run,
286            };
287            return;
288        }
289
290        crate::install::start_integrated_install_all(app, &items, app.dry_run);
291        app.toast_message = Some(crate::i18n::t(app, "app.toasts.installing_skipped"));
292        app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
293        return;
294    }
295
296    if use_cache {
297        // Reset cancellation flag when opening new preflight
298        app.preflight_cancelled
299            .store(false, std::sync::atomic::Ordering::Relaxed);
300
301        let crate::logic::preflight::PreflightSummaryOutcome {
302            summary,
303            header,
304            reverse_deps_report: _,
305        } = crate::logic::preflight::compute_preflight_summary(
306            &items,
307            crate::state::PreflightAction::Install,
308        );
309        app.pending_service_plan.clear();
310
311        let item_names: std::collections::HashSet<String> =
312            items.iter().map(|i| i.name.clone()).collect();
313        let cached_deps = filter_cached_dependencies(app, &item_names);
314        let cached_files = filter_cached_files(app, &item_names);
315
316        let dependency_info = if cached_deps.is_empty() {
317            tracing::debug!(
318                "[Preflight] Cache empty, will trigger background dependency resolution for {} packages",
319                items.len()
320            );
321            Vec::new()
322        } else {
323            cached_deps
324        };
325
326        trigger_background_resolution(app, &items, &dependency_info, &cached_files);
327        create_preflight_modal_with_cache(
328            app,
329            items,
330            summary,
331            header,
332            dependency_info,
333            cached_files,
334        );
335    } else {
336        create_preflight_modal_insert_mode(app, items);
337    }
338    app.toast_message = Some(if use_cache {
339        crate::i18n::t(app, "app.toasts.preflight_opened")
340    } else {
341        "Preflight opened".to_string()
342    });
343    app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(2));
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    /// What: Provide a baseline `AppState` for preflight helper tests.
351    ///
352    /// Inputs: None
353    ///
354    /// Output: Fresh `AppState` with default values.
355    fn new_app() -> AppState {
356        AppState::default()
357    }
358
359    /// What: Create a test package item for testing.
360    ///
361    /// Inputs:
362    /// - `name`: Package name
363    ///
364    /// Output: A test `PackageItem` with official source.
365    fn test_package(name: &str) -> PackageItem {
366        PackageItem {
367            name: name.to_string(),
368            version: "1.0.0".to_string(),
369            description: "Test package".to_string(),
370            source: crate::state::Source::Official {
371                repo: "extra".to_string(),
372                arch: "x86_64".to_string(),
373            },
374            popularity: None,
375            out_of_date: None,
376            orphaned: false,
377        }
378    }
379
380    #[test]
381    /// What: Verify that `trigger_background_resolution` sets resolution flags correctly.
382    ///
383    /// Inputs:
384    /// - App state
385    /// - Empty `dependency_info` and `cached_files`
386    ///
387    /// Output:
388    /// - Resolution flags and items are set
389    ///
390    /// Details:
391    /// - Tests that background resolution is properly triggered when caches are empty.
392    fn trigger_background_resolution_sets_flags_when_cache_empty() {
393        let mut app = new_app();
394        let items = vec![test_package("test-pkg")];
395
396        trigger_background_resolution(&mut app, &items, &[], &[]);
397
398        // Flags should be set
399        assert!(app.preflight_deps_resolving);
400        assert!(app.preflight_files_resolving);
401        assert!(app.preflight_services_resolving);
402        // Items should be queued
403        assert!(app.preflight_deps_items.is_some());
404        assert!(app.preflight_files_items.is_some());
405        assert!(app.preflight_services_items.is_some());
406    }
407
408    #[test]
409    /// What: Verify that `trigger_background_resolution` does not set deps flag when cache has deps.
410    ///
411    /// Inputs:
412    /// - App state
413    /// - Non-empty `dependency_info`
414    ///
415    /// Output:
416    /// - Deps resolution flag and items are not set
417    ///
418    /// Details:
419    /// - Tests that existing cached deps prevent re-resolution.
420    fn trigger_background_resolution_skips_deps_when_cached() {
421        let mut app = new_app();
422        let items = vec![test_package("test-pkg")];
423        let cached_deps = vec![crate::state::modal::DependencyInfo {
424            name: "cached-dep".to_string(),
425            version: "1.0".to_string(),
426            status: crate::state::modal::DependencyStatus::ToInstall,
427            source: crate::state::modal::DependencySource::Official {
428                repo: "extra".to_string(),
429            },
430            required_by: vec!["test-pkg".to_string()],
431            depends_on: Vec::new(),
432            is_core: false,
433            is_system: false,
434        }];
435
436        trigger_background_resolution(&mut app, &items, &cached_deps, &[]);
437
438        // Deps should not be triggered (cache has data)
439        assert!(!app.preflight_deps_resolving);
440        assert!(app.preflight_deps_items.is_none());
441        // Files should still be triggered (cache empty)
442        assert!(app.preflight_files_resolving);
443        assert!(app.preflight_files_items.is_some());
444    }
445
446    #[test]
447    /// What: Verify `create_preflight_modal_insert_mode` resets `preflight_cancelled` flag.
448    ///
449    /// Inputs:
450    /// - App state with `preflight_cancelled` set to true
451    ///
452    /// Output:
453    /// - `preflight_cancelled` is reset to false
454    ///
455    /// Details:
456    /// - Tests the insert mode path resets the cancellation flag.
457    fn create_preflight_modal_insert_mode_resets_cancelled() {
458        let mut app = new_app();
459        app.preflight_cancelled
460            .store(true, std::sync::atomic::Ordering::Relaxed);
461        let items = vec![test_package("test-pkg")];
462
463        create_preflight_modal_insert_mode(&mut app, items);
464
465        // Cancelled flag should be reset
466        assert!(
467            !app.preflight_cancelled
468                .load(std::sync::atomic::Ordering::Relaxed)
469        );
470    }
471
472    #[test]
473    /// What: Verify `filter_cached_dependencies` returns only matching deps.
474    ///
475    /// Inputs:
476    /// - App state with cached dependencies
477    /// - Set of item names to filter by
478    ///
479    /// Output:
480    /// - Only dependencies matching the item names are returned
481    ///
482    /// Details:
483    /// - Tests that dependency filtering works correctly.
484    fn filter_cached_dependencies_returns_matching() {
485        let mut app = new_app();
486        app.install_list_deps = vec![
487            crate::state::modal::DependencyInfo {
488                name: "dep-a".to_string(),
489                version: "1.0".to_string(),
490                status: crate::state::modal::DependencyStatus::ToInstall,
491                source: crate::state::modal::DependencySource::Official {
492                    repo: "extra".to_string(),
493                },
494                required_by: vec!["pkg-a".to_string()],
495                depends_on: Vec::new(),
496                is_core: false,
497                is_system: false,
498            },
499            crate::state::modal::DependencyInfo {
500                name: "dep-b".to_string(),
501                version: "1.0".to_string(),
502                status: crate::state::modal::DependencyStatus::ToInstall,
503                source: crate::state::modal::DependencySource::Official {
504                    repo: "extra".to_string(),
505                },
506                required_by: vec!["pkg-b".to_string()],
507                depends_on: Vec::new(),
508                is_core: false,
509                is_system: false,
510            },
511        ];
512
513        let mut item_names = std::collections::HashSet::new();
514        item_names.insert("pkg-a".to_string());
515
516        let result = filter_cached_dependencies(&app, &item_names);
517
518        assert_eq!(result.len(), 1);
519        assert_eq!(result[0].name, "dep-a");
520    }
521
522    #[test]
523    /// What: Verify `filter_cached_files` returns only matching files.
524    ///
525    /// Inputs:
526    /// - App state with cached file info
527    /// - Set of item names to filter by
528    ///
529    /// Output:
530    /// - Only file info matching the item names are returned
531    ///
532    /// Details:
533    /// - Tests that file filtering works correctly.
534    fn filter_cached_files_returns_matching() {
535        let mut app = new_app();
536        app.install_list_files = vec![
537            crate::state::modal::PackageFileInfo {
538                name: "pkg-a".to_string(),
539                files: vec![crate::state::modal::FileChange {
540                    path: "/usr/bin/a".to_string(),
541                    change_type: crate::state::modal::FileChangeType::New,
542                    package: "pkg-a".to_string(),
543                    is_config: false,
544                    predicted_pacnew: false,
545                    predicted_pacsave: false,
546                }],
547                total_count: 1,
548                new_count: 1,
549                changed_count: 0,
550                removed_count: 0,
551                config_count: 0,
552                pacnew_candidates: 0,
553                pacsave_candidates: 0,
554            },
555            crate::state::modal::PackageFileInfo {
556                name: "pkg-b".to_string(),
557                files: vec![crate::state::modal::FileChange {
558                    path: "/usr/bin/b".to_string(),
559                    change_type: crate::state::modal::FileChangeType::New,
560                    package: "pkg-b".to_string(),
561                    is_config: false,
562                    predicted_pacnew: false,
563                    predicted_pacsave: false,
564                }],
565                total_count: 1,
566                new_count: 1,
567                changed_count: 0,
568                removed_count: 0,
569                config_count: 0,
570                pacnew_candidates: 0,
571                pacsave_candidates: 0,
572            },
573        ];
574
575        let mut item_names = std::collections::HashSet::new();
576        item_names.insert("pkg-a".to_string());
577
578        let result = filter_cached_files(&app, &item_names);
579
580        assert_eq!(result.len(), 1);
581        assert_eq!(result[0].name, "pkg-a");
582    }
583}