Skip to main content

pacsea/app/runtime/
init.rs

1use std::{collections::HashMap, fs, path::Path, time::Instant};
2
3use crate::index as pkgindex;
4use crate::state::{AppState, PackageDetails, PackageItem};
5
6use super::super::deps_cache;
7use super::super::files_cache;
8use super::super::sandbox_cache;
9use super::super::services_cache;
10
11/// What: Initialize the locale system: resolve locale, load translations, set up fallbacks.
12///
13/// Inputs:
14/// - `app`: Application state to populate with locale and translations
15/// - `locale_pref`: Locale preference from `settings.conf` (empty = auto-detect)
16/// - `_prefs`: Settings struct (unused but kept for future use)
17///
18/// Output:
19/// - Populates `app.locale`, `app.translations`, and `app.translations_fallback`
20///
21/// Details:
22/// - Resolves locale using fallback chain (settings -> system -> default)
23/// - Loads English fallback translations first (required)
24/// - Loads primary locale translations if different from English
25/// - Handles errors gracefully: falls back to English if locale file missing/invalid
26/// - Logs warnings for missing files but continues execution
27pub fn initialize_locale_system(
28    app: &mut AppState,
29    locale_pref: &str,
30    _prefs: &crate::theme::Settings,
31) {
32    // Get paths - try both development and installed locations
33    let locales_dir = crate::i18n::find_locales_dir().unwrap_or_else(|| {
34        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
35            .join("config")
36            .join("locales")
37    });
38    let Some(i18n_config_path) = crate::i18n::find_config_file("i18n.yml") else {
39        tracing::error!(
40            "i18n config file not found in development or installed locations. Using default locale 'en-US'."
41        );
42        app.locale = "en-US".to_string();
43        app.translations = std::collections::HashMap::new();
44        app.translations_fallback = std::collections::HashMap::new();
45        return;
46    };
47
48    // Resolve locale
49    let resolver = crate::i18n::LocaleResolver::new(&i18n_config_path);
50    let resolved_locale = resolver.resolve(locale_pref);
51
52    tracing::info!(
53        "Resolved locale: '{}' (from settings: '{}')",
54        &resolved_locale,
55        if locale_pref.trim().is_empty() {
56            "<auto-detect>"
57        } else {
58            locale_pref
59        }
60    );
61    app.locale.clone_from(&resolved_locale);
62
63    // Load translations
64    let mut loader = crate::i18n::LocaleLoader::new(locales_dir);
65
66    // Load fallback (English) translations first - this is required
67    match loader.load("en-US") {
68        Ok(fallback) => {
69            let key_count = fallback.len();
70            app.translations_fallback = fallback;
71            tracing::debug!("Loaded English fallback translations ({} keys)", key_count);
72        }
73        Err(e) => {
74            tracing::error!(
75                "Failed to load English fallback translations: {}. Application may show untranslated keys.",
76                e
77            );
78            app.translations_fallback = std::collections::HashMap::new();
79        }
80    }
81
82    // Load primary locale translations
83    if resolved_locale == "en-US" {
84        // Already loaded English as fallback, use it as primary too
85        app.translations = app.translations_fallback.clone();
86        tracing::debug!("Using English as primary locale");
87    } else {
88        match loader.load(&resolved_locale) {
89            Ok(translations) => {
90                let key_count = translations.len();
91                app.translations = translations;
92                tracing::info!(
93                    "Loaded translations for locale '{}' ({} keys)",
94                    resolved_locale,
95                    key_count
96                );
97                // Debug: Check if specific keys exist
98                let test_keys = [
99                    "app.details.footer.search_hint",
100                    "app.details.footer.confirm_installation",
101                ];
102                for key in &test_keys {
103                    if app.translations.contains_key(*key) {
104                        tracing::debug!("  ✓ Key '{}' found in translations", key);
105                    } else {
106                        tracing::debug!("  ✗ Key '{}' NOT found in translations", key);
107                    }
108                }
109            }
110            Err(e) => {
111                tracing::warn!(
112                    "Failed to load translations for locale '{}': {}. Using English fallback.",
113                    resolved_locale,
114                    e
115                );
116                // Use empty map - translate_with_fallback will use English fallback
117                app.translations = std::collections::HashMap::new();
118            }
119        }
120    }
121}
122
123/// What: Run startup config preflight exactly once and return resolved settings.
124///
125/// Inputs:
126/// - None.
127///
128/// Output:
129/// - Returns the current `Settings` after legacy migration and key ensure operations.
130///
131/// Details:
132/// - Migrates legacy config layout into split config files when needed.
133/// - Ensures `theme.conf` and `settings.conf` include all known keys.
134/// - Reads and returns settings from disk after preflight so downstream initialization
135///   can reuse the same resolved snapshot.
136pub fn run_startup_config_preflight() -> crate::theme::Settings {
137    crate::theme::maybe_migrate_legacy_confs();
138    crate::theme::ensure_theme_keys_present();
139    let prefs = crate::theme::settings();
140    crate::theme::ensure_settings_keys_present(&prefs);
141    prefs
142}
143
144/// What: Initialize application state: load settings, caches, and persisted data.
145///
146/// Inputs:
147/// - `app`: Application state to initialize
148/// - `dry_run_flag`: When `true`, install/remove/downgrade actions are displayed but not executed
149/// - `headless`: When `true`, skip terminal-dependent operations
150///
151/// Output:
152/// - Returns flags indicating which caches need background resolution
153///
154/// Details:
155/// - Migrates legacy configs and loads settings
156/// - Loads persisted caches (details, recent, install list, dependencies, files, services, sandbox)
157/// - Initializes locale system
158/// - Checks for GNOME terminal if on GNOME desktop
159#[allow(clippy::struct_excessive_bools)]
160pub struct InitFlags {
161    /// Whether dependency resolution is needed (cache missing or invalid).
162    pub needs_deps_resolution: bool,
163    /// Whether file analysis is needed (cache missing or invalid).
164    pub needs_files_resolution: bool,
165    /// Whether service analysis is needed (cache missing or invalid).
166    pub needs_services_resolution: bool,
167    /// Whether sandbox analysis is needed (cache missing or invalid).
168    pub needs_sandbox_resolution: bool,
169}
170
171/// What: Load a cache with signature validation, returning whether resolution is needed.
172///
173/// Inputs:
174/// - `install_list`: Current install list to compute signature from
175/// - `cache_path`: Path to the cache file
176/// - `compute_signature`: Function to compute signature from install list
177/// - `load_cache`: Function to load cache from path and signature
178/// - `cache_name`: Name of the cache for logging
179///
180/// Output:
181/// - `(Option<T>, bool)` where first is the loaded cache (if valid) and second indicates if resolution is needed
182///
183/// Details:
184/// - Returns `(None, true)` if install list is empty or cache is missing/invalid
185/// - Returns `(Some(cache), false)` if cache is valid
186fn load_cache_with_signature<T>(
187    install_list: &[crate::state::PackageItem],
188    cache_path: &std::path::PathBuf,
189    compute_signature: impl Fn(&[crate::state::PackageItem]) -> Vec<String>,
190    load_cache: impl Fn(&std::path::PathBuf, &[String]) -> Option<T>,
191    cache_name: &str,
192) -> (Option<T>, bool) {
193    if install_list.is_empty() {
194        return (None, false);
195    }
196
197    let signature = compute_signature(install_list);
198    load_cache(cache_path, &signature).map_or_else(
199        || {
200            tracing::info!(
201                "{} cache missing or invalid, will trigger background resolution",
202                cache_name
203            );
204            (None, true)
205        },
206        |cached| (Some(cached), false),
207    )
208}
209
210/// What: Ensure cache directories exist before writing placeholder files.
211///
212/// Inputs:
213/// - `path`: Target cache file path whose parent directory should exist.
214///
215/// Output:
216/// - Parent directory is created if missing; logs a warning on failure.
217///
218/// Details:
219/// - No-op when the path has no parent.
220fn ensure_cache_parent_dir(path: &Path) {
221    if let Some(parent) = path.parent()
222        && let Err(error) = fs::create_dir_all(parent)
223    {
224        tracing::warn!(
225            path = %parent.display(),
226            %error,
227            "[Init] Failed to create cache directory"
228        );
229    }
230}
231
232/// What: Create empty cache files at startup so they always exist on disk.
233///
234/// Inputs:
235/// - `app`: Application state providing cache paths.
236///
237/// Output:
238/// - Writes empty dependency, file, service, and sandbox caches if the files are missing.
239///
240/// Details:
241/// - Uses empty signatures and payloads; leaves existing files untouched.
242/// - Ensures parent directories exist before writing.
243fn initialize_cache_files(app: &AppState) {
244    let empty_signature: Vec<String> = Vec::new();
245
246    if !app.deps_cache_path.exists() {
247        ensure_cache_parent_dir(&app.deps_cache_path);
248        deps_cache::save_cache(&app.deps_cache_path, &empty_signature, &[]);
249        tracing::debug!(
250            path = %app.deps_cache_path.display(),
251            "[Init] Created empty dependency cache"
252        );
253    }
254
255    if !app.files_cache_path.exists() {
256        ensure_cache_parent_dir(&app.files_cache_path);
257        files_cache::save_cache(&app.files_cache_path, &empty_signature, &[]);
258        tracing::debug!(
259            path = %app.files_cache_path.display(),
260            "[Init] Created empty file cache"
261        );
262    }
263
264    if !app.services_cache_path.exists() {
265        ensure_cache_parent_dir(&app.services_cache_path);
266        services_cache::save_cache(&app.services_cache_path, &empty_signature, &[]);
267        tracing::debug!(
268            path = %app.services_cache_path.display(),
269            "[Init] Created empty service cache"
270        );
271    }
272
273    if !app.sandbox_cache_path.exists() {
274        ensure_cache_parent_dir(&app.sandbox_cache_path);
275        sandbox_cache::save_cache(&app.sandbox_cache_path, &empty_signature, &[]);
276        tracing::debug!(
277            path = %app.sandbox_cache_path.display(),
278            "[Init] Created empty sandbox cache"
279        );
280    }
281}
282
283/// What: Apply settings from configuration to application state.
284///
285/// Inputs:
286/// - `app`: Application state to update
287/// - `prefs`: Settings to apply
288///
289/// Output: None (modifies app state in place)
290///
291/// Details:
292/// - Applies layout percentages, keymap, sort mode, package marker, and pane visibility
293pub fn apply_settings_to_app_state(app: &mut AppState, prefs: &crate::theme::Settings) {
294    app.layout_left_pct = prefs.layout_left_pct;
295    app.layout_center_pct = prefs.layout_center_pct;
296    app.layout_right_pct = prefs.layout_right_pct;
297    app.main_pane_order = prefs.main_pane_order;
298    app.vertical_layout_limits = crate::state::VerticalLayoutLimits::from_u16s(
299        prefs.vertical_min_results,
300        prefs.vertical_max_results,
301        prefs.vertical_min_middle,
302        prefs.vertical_max_middle,
303        prefs.vertical_min_package_info,
304    );
305    app.keymap = prefs.keymap.clone();
306    app.sort_mode = prefs.sort_mode;
307    app.package_marker = prefs.package_marker;
308    app.show_recent_pane = prefs.show_recent_pane;
309    app.show_install_pane = prefs.show_install_pane;
310    app.show_keybinds_footer = prefs.show_keybinds_footer;
311    app.search_normal_mode = prefs.search_startup_mode;
312    app.fuzzy_search_enabled = prefs.fuzzy_search;
313    app.installed_packages_mode = prefs.installed_packages_mode;
314    app.app_mode = if prefs.start_in_news {
315        crate::state::types::AppMode::News
316    } else {
317        crate::state::types::AppMode::Package
318    };
319    app.news_filter_show_arch_news = prefs.news_filter_show_arch_news;
320    app.news_filter_show_advisories = prefs.news_filter_show_advisories;
321    app.news_filter_show_pkg_updates = prefs.news_filter_show_pkg_updates;
322    app.news_filter_show_aur_updates = prefs.news_filter_show_aur_updates;
323    app.news_filter_show_aur_comments = prefs.news_filter_show_aur_comments;
324    app.news_filter_installed_only = prefs.news_filter_installed_only;
325    app.news_max_age_days = prefs.news_max_age_days;
326    // Recompute news results with loaded filters/age
327    app.refresh_news_results();
328    crate::logic::repos::refresh_dynamic_filters_in_app(app, prefs);
329}
330
331/// What: Check if GNOME terminal is needed and set modal if required.
332///
333/// Inputs:
334/// - `app`: Application state to update
335/// - `headless`: When `true`, skip the check
336///
337/// Output: None (modifies app state in place)
338///
339/// Details:
340/// - Checks if running on GNOME desktop without `gnome-terminal` or `gnome-console`/`kgx`
341/// - Sets modal to `GnomeTerminalPrompt` if terminal is missing
342fn check_gnome_terminal(app: &mut AppState, headless: bool) {
343    if headless {
344        return;
345    }
346
347    let is_gnome = std::env::var("XDG_CURRENT_DESKTOP")
348        .ok()
349        .is_some_and(|v| v.to_uppercase().contains("GNOME"));
350
351    if !is_gnome {
352        return;
353    }
354
355    let has_gterm = crate::install::command_on_path("gnome-terminal");
356    let has_gconsole =
357        crate::install::command_on_path("gnome-console") || crate::install::command_on_path("kgx");
358
359    if !(has_gterm || has_gconsole) {
360        app.modal = crate::state::Modal::GnomeTerminalPrompt;
361    }
362}
363
364/// What: Load details cache from disk.
365///
366/// Inputs:
367/// - `app`: Application state to update
368///
369/// Output: None (modifies app state in place)
370///
371/// Details:
372/// - Attempts to deserialize details cache from JSON file
373fn load_details_cache(app: &mut AppState) {
374    if let Ok(s) = std::fs::read_to_string(&app.cache_path)
375        && let Ok(map) = serde_json::from_str::<HashMap<String, PackageDetails>>(&s)
376    {
377        app.details_cache = map;
378        tracing::info!(path = %app.cache_path.display(), "loaded details cache");
379    }
380}
381
382/// What: Load recent searches from disk.
383///
384/// Inputs:
385/// - `app`: Application state to update
386///
387/// Output: None (modifies app state in place)
388///
389/// Details:
390/// - Attempts to deserialize recent searches list from JSON file
391/// - Selects first item if list is not empty
392fn load_recent_searches(app: &mut AppState) {
393    if let Ok(s) = std::fs::read_to_string(&app.recent_path)
394        && let Ok(list) = serde_json::from_str::<Vec<String>>(&s)
395    {
396        let count = list.len();
397        app.load_recent_items(&list);
398        if count > 0 {
399            app.history_state.select(Some(0));
400        }
401        tracing::info!(
402            path = %app.recent_path.display(),
403            count = count,
404            "loaded recent searches"
405        );
406    }
407}
408
409/// What: Load install list from disk.
410///
411/// Inputs:
412/// - `app`: Application state to update
413///
414/// Output: None (modifies app state in place)
415///
416/// Details:
417/// - Attempts to deserialize install list from JSON file
418/// - Selects first item if list is not empty
419fn load_install_list(app: &mut AppState) {
420    if let Ok(s) = std::fs::read_to_string(&app.install_path)
421        && let Ok(list) = serde_json::from_str::<Vec<PackageItem>>(&s)
422    {
423        app.install_list = list;
424        if !app.install_list.is_empty() {
425            app.install_state.select(Some(0));
426        }
427        tracing::info!(
428            path = %app.install_path.display(),
429            count = app.install_list.len(),
430            "loaded install list"
431        );
432    }
433}
434
435/// What: Load news read URLs from disk.
436///
437/// Inputs:
438/// - `app`: Application state to update
439///
440/// Output: None (modifies app state in place)
441///
442/// Details:
443/// - Attempts to deserialize news read URLs set from JSON file
444fn load_news_read_urls(app: &mut AppState) {
445    if let Ok(s) = std::fs::read_to_string(&app.news_read_path)
446        && let Ok(set) = serde_json::from_str::<std::collections::HashSet<String>>(&s)
447    {
448        app.news_read_urls = set;
449        tracing::info!(
450            path = %app.news_read_path.display(),
451            count = app.news_read_urls.len(),
452            "loaded read news urls"
453        );
454    }
455}
456
457/// What: Load news read IDs from disk (feed-level tracking).
458///
459/// Inputs:
460/// - `app`: Application state to update
461///
462/// Output: None (modifies app state in place)
463///
464/// Details:
465/// - Attempts to deserialize news read IDs set from JSON file.
466/// - If no IDs file is found, falls back to populated `news_read_urls` for migration.
467fn load_news_read_ids(app: &mut AppState) {
468    if let Ok(s) = std::fs::read_to_string(&app.news_read_ids_path)
469        && let Ok(set) = serde_json::from_str::<std::collections::HashSet<String>>(&s)
470    {
471        app.news_read_ids = set;
472        tracing::info!(
473            path = %app.news_read_ids_path.display(),
474            count = app.news_read_ids.len(),
475            "loaded read news ids"
476        );
477        return;
478    }
479
480    if app.news_read_ids.is_empty() && !app.news_read_urls.is_empty() {
481        app.news_read_ids.extend(app.news_read_urls.iter().cloned());
482        tracing::info!(
483            copied = app.news_read_ids.len(),
484            "seeded news read ids from legacy URL set"
485        );
486        app.news_read_ids_dirty = true;
487    }
488}
489
490/// What: Load announcement read IDs from disk.
491///
492/// Inputs:
493/// - `app`: Application state to update
494///
495/// Output: None (modifies app state in place)
496///
497/// Details:
498/// - Attempts to deserialize announcement read IDs set from JSON file
499/// - Handles both old format (single hash) and new format (set of IDs) for migration
500fn load_announcement_state(app: &mut AppState) {
501    // Try old format for migration ({ "hash": "..." })
502    /// What: Legacy announcement read state structure.
503    ///
504    /// Inputs: Deserialized from old announcement read file.
505    ///
506    /// Output: Old state structure for migration.
507    ///
508    /// Details: Used for migrating from old announcement read state format.
509    #[derive(serde::Deserialize)]
510    struct OldAnnouncementReadState {
511        /// Announcement hash if read.
512        hash: Option<String>,
513    }
514    if let Ok(s) = std::fs::read_to_string(&app.announcement_read_path) {
515        // Try new format first (HashSet<String>)
516        if let Ok(ids) = serde_json::from_str::<std::collections::HashSet<String>>(&s) {
517            app.announcements_read_ids = ids;
518            tracing::info!(
519                path = %app.announcement_read_path.display(),
520                count = app.announcements_read_ids.len(),
521                "loaded announcement read IDs"
522            );
523            return;
524        }
525        if let Ok(old_state) = serde_json::from_str::<OldAnnouncementReadState>(&s)
526            && let Some(hash) = old_state.hash
527        {
528            app.announcements_read_ids.insert(format!("hash:{hash}"));
529            app.announcement_dirty = true; // Mark dirty to migrate to new format
530            tracing::info!(
531                path = %app.announcement_read_path.display(),
532                "migrated old announcement read state"
533            );
534        }
535    }
536}
537
538/// What: Check for version-embedded announcement and show modal if not read.
539///
540/// Inputs:
541/// - `app`: Application state to update
542///
543/// Output: None (modifies app state in place)
544///
545/// Details:
546/// - Checks embedded announcements for current app version
547/// - If version announcement exists and hasn't been marked as read, shows modal
548fn check_version_announcement(app: &mut AppState) {
549    const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
550
551    // Extract base version (X.X.X) from current version, ignoring suffixes
552    let current_base_version = crate::announcements::extract_base_version(CURRENT_VERSION);
553
554    // Find announcement matching the base version (compares only X.X.X part)
555    if let Some(announcement) = crate::announcements::VERSION_ANNOUNCEMENTS
556        .iter()
557        .find(|a| {
558            let announcement_base_version = crate::announcements::extract_base_version(a.version);
559            announcement_base_version == current_base_version
560        })
561    {
562        // Use full current version (including suffix) for the ID
563        // This ensures announcements show again when suffix changes (e.g., 0.6.0-pr#85 -> 0.6.0-pr#86)
564        let version_id = format!("v{CURRENT_VERSION}");
565
566        // Check if this version announcement has been marked as read
567        if app.announcements_read_ids.contains(&version_id) {
568            tracing::info!(
569                current_version = CURRENT_VERSION,
570                base_version = %current_base_version,
571                "version announcement already marked as read"
572            );
573            return;
574        }
575
576        if matches!(app.modal, crate::state::Modal::None) {
577            // Show version announcement modal immediately.
578            app.modal = crate::state::Modal::Announcement {
579                title: announcement.title.to_string(),
580                content: announcement.content.to_string(),
581                id: version_id,
582                scroll: 0,
583            };
584            tracing::info!(
585                current_version = CURRENT_VERSION,
586                base_version = %current_base_version,
587                announcement_version = announcement.version,
588                "showing version announcement modal"
589            );
590        } else {
591            // Keep first-run/setup modal flow intact and defer version announcement.
592            app.pending_announcements
593                .push(crate::announcements::RemoteAnnouncement {
594                    id: version_id,
595                    title: announcement.title.to_string(),
596                    content: announcement.content.to_string(),
597                    min_version: None,
598                    max_version: None,
599                    expires: None,
600                });
601            tracing::info!(
602                current_version = CURRENT_VERSION,
603                base_version = %current_base_version,
604                announcement_version = announcement.version,
605                queue_size = app.pending_announcements.len(),
606                "queued version announcement modal because another modal is open"
607            );
608        }
609    }
610    // Note: Remote announcements will be queued if they arrive while embedded is showing
611    // and will be shown when embedded is dismissed via show_next_pending_announcement()
612}
613
614/// What: Initialize application state by loading settings, caches, and persisted data.
615///
616/// Inputs:
617/// - `app`: Mutable application state to initialize
618/// - `dry_run_flag`: Whether to enable dry-run mode for this session
619/// - `headless`: Whether running in headless/test mode
620/// - `prefs`: Preflighted settings snapshot to apply during initialization
621///
622/// Output:
623/// - Returns `InitFlags` indicating which caches need background resolution
624///
625/// Details:
626/// - Applies startup settings that were preflighted before runtime initialization
627/// - Initializes locale system and translations
628/// - Loads persisted data: recent searches, install list, details cache, dependency/file/service/sandbox caches
629/// - Loads news read URLs and announcement state
630/// - Loads official package index from disk
631/// - Checks for version-embedded announcements
632pub fn initialize_app_state(
633    app: &mut AppState,
634    dry_run_flag: bool,
635    headless: bool,
636    prefs: &crate::theme::Settings,
637) -> InitFlags {
638    app.dry_run = if dry_run_flag {
639        true
640    } else {
641        prefs.app_dry_run_default
642    };
643    app.last_input_change = Instant::now();
644
645    // Log resolved configuration/state file locations at startup
646    tracing::info!(
647        recent = %app.recent_path.display(),
648        install = %app.install_path.display(),
649        details_cache = %app.cache_path.display(),
650        index = %app.official_index_path.display(),
651        news_read = %app.news_read_path.display(),
652        news_read_ids = %app.news_read_ids_path.display(),
653        announcement_read = %app.announcement_read_path.display(),
654        "resolved state file paths"
655    );
656
657    crate::logic::repos::load_repos_config_into_app(app, crate::theme::resolve_repos_config_path());
658    apply_settings_to_app_state(app, prefs);
659
660    // Initialize locale system
661    initialize_locale_system(app, &prefs.locale, prefs);
662
663    check_gnome_terminal(app, headless);
664
665    // Show startup setup selector modal on first launch if startup news is not configured.
666    if !headless && !prefs.startup_news_configured {
667        // Only show if no other modal is already set (e.g., GnomeTerminalPrompt)
668        if matches!(app.modal, crate::state::Modal::None) {
669            let ssh_command = crate::theme::settings().aur_vote_ssh_command;
670            app.pending_aur_ssh_help_check_result = Some(
671                crate::logic::ssh_setup::spawn_aur_ssh_help_check(ssh_command),
672            );
673            app.aur_ssh_help_ready = None;
674            app.modal = crate::state::Modal::StartupSetupSelector {
675                cursor: 0,
676                selected: std::collections::HashSet::new(),
677                active_privilege_tool: crate::logic::privilege::active_tool().ok(),
678            };
679        }
680    } else if !headless && prefs.startup_news_configured {
681        // Always fetch fresh news in background (using last startup timestamp for incremental updates)
682        // Show loading toast while fetching, but cached items will be displayed immediately
683        app.news_loading = true;
684        app.toast_message = Some(crate::i18n::t(app, "app.news_button.loading"));
685        app.toast_expires_at = None; // No expiration - toast stays until news loading completes
686    }
687
688    // Check faillock status at startup
689    if !headless {
690        let username = std::env::var("USER").unwrap_or_else(|_| "user".to_string());
691        let (is_locked, lockout_until, remaining_minutes) =
692            crate::logic::faillock::get_lockout_info(&username);
693        app.faillock_locked = is_locked;
694        app.faillock_lockout_until = lockout_until;
695        app.faillock_remaining_minutes = remaining_minutes;
696    }
697
698    load_details_cache(app);
699    load_recent_searches(app);
700    load_install_list(app);
701    initialize_cache_files(app);
702
703    // Load dependency cache after install list is loaded (but before channels are created)
704    let (deps_cache, needs_deps_resolution) = load_cache_with_signature(
705        &app.install_list,
706        &app.deps_cache_path,
707        deps_cache::compute_signature,
708        deps_cache::load_cache,
709        "dependency",
710    );
711    if let Some(cached_deps) = deps_cache {
712        app.install_list_deps = cached_deps;
713        tracing::info!(
714            path = %app.deps_cache_path.display(),
715            count = app.install_list_deps.len(),
716            "loaded dependency cache"
717        );
718    }
719
720    // Load file cache after install list is loaded (but before channels are created)
721    let (files_cache, needs_files_resolution) = load_cache_with_signature(
722        &app.install_list,
723        &app.files_cache_path,
724        files_cache::compute_signature,
725        files_cache::load_cache,
726        "file",
727    );
728    if let Some(cached_files) = files_cache {
729        app.install_list_files = cached_files;
730        tracing::info!(
731            path = %app.files_cache_path.display(),
732            count = app.install_list_files.len(),
733            "loaded file cache"
734        );
735    }
736
737    // Load service cache after install list is loaded (but before channels are created)
738    let (services_cache, needs_services_resolution) = load_cache_with_signature(
739        &app.install_list,
740        &app.services_cache_path,
741        services_cache::compute_signature,
742        services_cache::load_cache,
743        "service",
744    );
745    if let Some(cached_services) = services_cache {
746        app.install_list_services = cached_services;
747        tracing::info!(
748            path = %app.services_cache_path.display(),
749            count = app.install_list_services.len(),
750            "loaded service cache"
751        );
752    }
753
754    // Load sandbox cache after install list is loaded (but before channels are created)
755    let (sandbox_cache, needs_sandbox_resolution) = load_cache_with_signature(
756        &app.install_list,
757        &app.sandbox_cache_path,
758        sandbox_cache::compute_signature,
759        sandbox_cache::load_cache,
760        "sandbox",
761    );
762    if let Some(cached_sandbox) = sandbox_cache {
763        app.install_list_sandbox = cached_sandbox;
764        tracing::info!(
765            path = %app.sandbox_cache_path.display(),
766            count = app.install_list_sandbox.len(),
767            "loaded sandbox cache"
768        );
769    }
770
771    load_news_read_urls(app);
772    load_news_read_ids(app);
773    load_announcement_state(app);
774
775    pkgindex::load_from_disk(&app.official_index_path);
776
777    // Check for version-embedded announcement after loading state
778    check_version_announcement(app);
779    tracing::info!(
780        path = %app.official_index_path.display(),
781        "attempted to load official index from disk"
782    );
783
784    InitFlags {
785        needs_deps_resolution,
786        needs_files_resolution,
787        needs_services_resolution,
788        needs_sandbox_resolution,
789    }
790}
791
792/// What: Trigger initial background resolution for caches that were missing or invalid.
793///
794/// Inputs:
795/// - `app`: Application state
796/// - `flags`: Initialization flags indicating which caches need resolution
797/// - `deps_req_tx`: Channel sender for dependency resolution requests
798/// - `files_req_tx`: Channel sender for file resolution requests (with action)
799/// - `services_req_tx`: Channel sender for service resolution requests
800/// - `sandbox_req_tx`: Channel sender for sandbox resolution requests
801///
802/// Output:
803/// - Sets resolution flags and sends requests to background workers
804///
805/// Details:
806/// - Only triggers resolution if cache was missing/invalid and install list is not empty
807pub fn trigger_initial_resolutions(
808    app: &mut AppState,
809    flags: &InitFlags,
810    deps_req_tx: &tokio::sync::mpsc::UnboundedSender<(
811        Vec<PackageItem>,
812        crate::state::modal::PreflightAction,
813    )>,
814    files_req_tx: &tokio::sync::mpsc::UnboundedSender<(
815        Vec<PackageItem>,
816        crate::state::modal::PreflightAction,
817    )>,
818    services_req_tx: &tokio::sync::mpsc::UnboundedSender<(
819        Vec<PackageItem>,
820        crate::state::modal::PreflightAction,
821    )>,
822    sandbox_req_tx: &tokio::sync::mpsc::UnboundedSender<Vec<PackageItem>>,
823) {
824    if flags.needs_deps_resolution && !app.install_list.is_empty() {
825        app.deps_resolving = true;
826        // Initial resolution is always for Install action (install_list)
827        let _ = deps_req_tx.send((
828            app.install_list.clone(),
829            crate::state::modal::PreflightAction::Install,
830        ));
831    }
832
833    if flags.needs_files_resolution && !app.install_list.is_empty() {
834        app.files_resolving = true;
835        // Initial resolution is always for Install action (install_list)
836        let _ = files_req_tx.send((
837            app.install_list.clone(),
838            crate::state::modal::PreflightAction::Install,
839        ));
840    }
841
842    if flags.needs_services_resolution && !app.install_list.is_empty() {
843        app.services_resolving = true;
844        let _ = services_req_tx.send((
845            app.install_list.clone(),
846            crate::state::modal::PreflightAction::Install,
847        ));
848    }
849
850    if flags.needs_sandbox_resolution && !app.install_list.is_empty() {
851        app.sandbox_resolving = true;
852        let _ = sandbox_req_tx.send(app.install_list.clone());
853    }
854}
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859    use crate::app::runtime::background::Channels;
860
861    /// What: Provide a baseline `AppState` for initialization tests.
862    ///
863    /// Inputs: None
864    /// Output: Fresh `AppState` with default values
865    fn new_app() -> AppState {
866        AppState::default()
867    }
868
869    #[test]
870    /// What: Verify that `initialize_locale_system` sets default locale when config file is missing.
871    ///
872    /// Inputs:
873    /// - App state with default locale
874    /// - Empty locale preference
875    ///
876    /// Output:
877    /// - Locale is set to "en-US" when config file is missing
878    /// - Translations maps are initialized (may be empty)
879    ///
880    /// Details:
881    /// - Tests graceful fallback when i18n config is not found
882    fn initialize_locale_system_fallback_when_config_missing() {
883        let mut app = new_app();
884        let prefs = crate::theme::Settings::default();
885
886        // This will fall back to en-US if config file is missing
887        initialize_locale_system(&mut app, "", &prefs);
888
889        // Locale should be set (either resolved or default)
890        assert!(!app.locale.is_empty());
891        // Translations maps should be initialized
892        assert!(app.translations.is_empty() || !app.translations.is_empty());
893        assert!(app.translations_fallback.is_empty() || !app.translations_fallback.is_empty());
894    }
895
896    #[test]
897    /// What: Verify that `initialize_app_state` sets `dry_run` flag correctly.
898    ///
899    /// Inputs:
900    /// - `AppState`
901    /// - `dry_run_flag` = true
902    /// - headless = false
903    ///
904    /// Output:
905    /// - `app.dry_run` is set to true
906    /// - `InitFlags` are returned
907    ///
908    /// Details:
909    /// - Tests that `dry_run` flag is properly initialized
910    fn initialize_app_state_sets_dry_run_flag() {
911        let mut app = new_app();
912        let prefs = crate::theme::settings();
913        let flags = initialize_app_state(&mut app, true, false, &prefs);
914
915        assert!(app.dry_run);
916        // Flags should be returned (InitFlags struct is created)
917        // The actual values depend on cache state, so we just verify flags exist
918        let _ = flags;
919    }
920
921    #[test]
922    /// What: Verify that `initialize_app_state` loads settings correctly.
923    ///
924    /// Inputs:
925    /// - `AppState`
926    /// - `dry_run_flag` = false
927    /// - headless = false
928    ///
929    /// Output:
930    /// - `AppState` has layout percentages set
931    /// - Keymap is set
932    /// - Sort mode is set
933    ///
934    /// Details:
935    /// - Tests that settings are properly applied to app state
936    fn initialize_app_state_loads_settings() {
937        let mut app = new_app();
938        let prefs = crate::theme::settings();
939        let _flags = initialize_app_state(&mut app, false, false, &prefs);
940
941        // Settings should be loaded (values depend on config, but should be set)
942        assert!(app.layout_left_pct > 0);
943        assert!(app.layout_center_pct > 0);
944        assert!(app.layout_right_pct > 0);
945        // Keymap should be initialized (it's a struct, not a string)
946        // Just verify it's not the default empty state by checking a field
947        // (KeyMap has many fields, we just verify it's been set)
948    }
949
950    #[test]
951    /// What: Verify first startup shows startup setup selector modal.
952    fn initialize_app_state_shows_startup_selector_when_news_unconfigured() {
953        let mut app = new_app();
954        let mut prefs = crate::theme::settings();
955        prefs.startup_news_configured = false;
956        let _flags = initialize_app_state(&mut app, false, false, &prefs);
957        assert!(matches!(
958            app.modal,
959            crate::state::Modal::StartupSetupSelector { .. }
960        ));
961    }
962
963    #[test]
964    /// What: Verify version announcement is queued when another startup modal is already open.
965    ///
966    /// Inputs:
967    /// - `AppState` with `StartupSetupSelector` already open.
968    ///
969    /// Output:
970    /// - Existing modal remains `StartupSetupSelector`.
971    /// - Version announcement is pushed into `pending_announcements`.
972    ///
973    /// Details:
974    /// - Prevents first-run setup flow from being overwritten by version announcement display.
975    fn check_version_announcement_queues_when_modal_already_open() {
976        let mut app = new_app();
977        app.modal = crate::state::Modal::StartupSetupSelector {
978            cursor: 0,
979            selected: std::collections::HashSet::new(),
980            active_privilege_tool: None,
981        };
982        let pending_before = app.pending_announcements.len();
983
984        check_version_announcement(&mut app);
985
986        assert!(matches!(
987            app.modal,
988            crate::state::Modal::StartupSetupSelector { .. }
989        ));
990        assert_eq!(
991            app.pending_announcements.len(),
992            pending_before.saturating_add(1)
993        );
994    }
995
996    #[test]
997    /// What: Verify that `initialize_cache_files` creates placeholder cache files when missing.
998    ///
999    /// Inputs:
1000    /// - `AppState` with cache paths pointed to temporary locations that do not yet exist.
1001    ///
1002    /// Output:
1003    /// - Empty dependency, file, service, and sandbox cache files are created.
1004    ///
1005    /// Details:
1006    /// - Validates that startup eagerly materializes cache files instead of delaying until first use.
1007    fn initialize_cache_files_creates_empty_placeholders() {
1008        let mut app = new_app();
1009        let mut deps_path = std::env::temp_dir();
1010        deps_path.push(format!(
1011            "pacsea_init_deps_cache_{}_{}.json",
1012            std::process::id(),
1013            std::time::SystemTime::now()
1014                .duration_since(std::time::UNIX_EPOCH)
1015                .expect("System time is before UNIX epoch")
1016                .as_nanos()
1017        ));
1018        let mut files_path = deps_path.clone();
1019        files_path.set_file_name("pacsea_init_files_cache.json");
1020        let mut services_path = deps_path.clone();
1021        services_path.set_file_name("pacsea_init_services_cache.json");
1022        let mut sandbox_path = deps_path.clone();
1023        sandbox_path.set_file_name("pacsea_init_sandbox_cache.json");
1024
1025        app.deps_cache_path = deps_path.clone();
1026        app.files_cache_path = files_path.clone();
1027        app.services_cache_path = services_path.clone();
1028        app.sandbox_cache_path = sandbox_path.clone();
1029
1030        // Ensure paths are clean
1031        let _ = std::fs::remove_file(&app.deps_cache_path);
1032        let _ = std::fs::remove_file(&app.files_cache_path);
1033        let _ = std::fs::remove_file(&app.services_cache_path);
1034        let _ = std::fs::remove_file(&app.sandbox_cache_path);
1035
1036        initialize_cache_files(&app);
1037
1038        let deps_body = std::fs::read_to_string(&app.deps_cache_path)
1039            .expect("Dependency cache file should exist");
1040        let deps_cache: crate::app::deps_cache::DependencyCache =
1041            serde_json::from_str(&deps_body).expect("Dependency cache should parse");
1042        assert!(deps_cache.install_list_signature.is_empty());
1043        assert!(deps_cache.dependencies.is_empty());
1044
1045        let files_body =
1046            std::fs::read_to_string(&app.files_cache_path).expect("File cache file should exist");
1047        let files_cache: crate::app::files_cache::FileCache =
1048            serde_json::from_str(&files_body).expect("File cache should parse");
1049        assert!(files_cache.install_list_signature.is_empty());
1050        assert!(files_cache.files.is_empty());
1051
1052        let services_body = std::fs::read_to_string(&app.services_cache_path)
1053            .expect("Service cache file should exist");
1054        let services_cache: crate::app::services_cache::ServiceCache =
1055            serde_json::from_str(&services_body).expect("Service cache should parse");
1056        assert!(services_cache.install_list_signature.is_empty());
1057        assert!(services_cache.services.is_empty());
1058
1059        let sandbox_body = std::fs::read_to_string(&app.sandbox_cache_path)
1060            .expect("Sandbox cache file should exist");
1061        let sandbox_cache: crate::app::sandbox_cache::SandboxCache =
1062            serde_json::from_str(&sandbox_body).expect("Sandbox cache should parse");
1063        assert!(sandbox_cache.install_list_signature.is_empty());
1064        assert!(sandbox_cache.sandbox_info.is_empty());
1065
1066        let _ = std::fs::remove_file(&app.deps_cache_path);
1067        let _ = std::fs::remove_file(&app.files_cache_path);
1068        let _ = std::fs::remove_file(&app.services_cache_path);
1069        let _ = std::fs::remove_file(&app.sandbox_cache_path);
1070    }
1071
1072    #[tokio::test]
1073    /// What: Verify that `trigger_initial_resolutions` skips when install list is empty.
1074    ///
1075    /// Inputs:
1076    /// - `AppState` with empty install list
1077    /// - `InitFlags` with `needs_deps_resolution` = true
1078    /// - Channel senders
1079    ///
1080    /// Output:
1081    /// - No requests sent when install list is empty
1082    ///
1083    /// Details:
1084    /// - Tests that resolution is only triggered when install list is not empty
1085    async fn trigger_initial_resolutions_skips_when_install_list_empty() {
1086        let mut app = new_app();
1087        app.install_list.clear();
1088
1089        let flags = InitFlags {
1090            needs_deps_resolution: true,
1091            needs_files_resolution: true,
1092            needs_services_resolution: true,
1093            needs_sandbox_resolution: true,
1094        };
1095
1096        // Create channels (we only need the senders)
1097        let channels = Channels::new(std::path::PathBuf::from("/tmp"));
1098
1099        // Should not panic even with empty install list
1100        trigger_initial_resolutions(
1101            &mut app,
1102            &flags,
1103            &channels.deps_req_tx,
1104            &channels.files_req_tx,
1105            &channels.services_req_tx,
1106            &channels.sandbox_req_tx,
1107        );
1108
1109        // Flags should not be set when install list is empty
1110        assert!(!app.deps_resolving);
1111        assert!(!app.files_resolving);
1112        assert!(!app.services_resolving);
1113        assert!(!app.sandbox_resolving);
1114    }
1115
1116    #[tokio::test]
1117    /// What: Verify that `trigger_initial_resolutions` sets flags and sends requests when needed.
1118    ///
1119    /// Inputs:
1120    /// - `AppState` with non-empty install list
1121    /// - `InitFlags` with `needs_deps_resolution` = true
1122    /// - Channel senders
1123    ///
1124    /// Output:
1125    /// - `deps_resolving` flag is set
1126    /// - Request is sent to `deps_req_tx`
1127    ///
1128    /// Details:
1129    /// - Tests that resolution is properly triggered when conditions are met
1130    async fn trigger_initial_resolutions_triggers_when_needed() {
1131        let mut app = new_app();
1132        app.install_list.push(crate::state::PackageItem {
1133            name: "test-package".to_string(),
1134            version: "1.0.0".to_string(),
1135            description: "Test".to_string(),
1136            source: crate::state::Source::Aur,
1137            popularity: None,
1138            out_of_date: None,
1139            orphaned: false,
1140        });
1141
1142        let flags = InitFlags {
1143            needs_deps_resolution: true,
1144            needs_files_resolution: false,
1145            needs_services_resolution: false,
1146            needs_sandbox_resolution: false,
1147        };
1148
1149        let channels = Channels::new(std::path::PathBuf::from("/tmp"));
1150
1151        trigger_initial_resolutions(
1152            &mut app,
1153            &flags,
1154            &channels.deps_req_tx,
1155            &channels.files_req_tx,
1156            &channels.services_req_tx,
1157            &channels.sandbox_req_tx,
1158        );
1159
1160        // Flag should be set
1161        assert!(app.deps_resolving);
1162        // Other flags should not be set
1163        assert!(!app.files_resolving);
1164        assert!(!app.services_resolving);
1165        assert!(!app.sandbox_resolving);
1166    }
1167}