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.keymap = prefs.keymap.clone();
298    app.sort_mode = prefs.sort_mode;
299    app.package_marker = prefs.package_marker;
300    app.show_recent_pane = prefs.show_recent_pane;
301    app.show_install_pane = prefs.show_install_pane;
302    app.show_keybinds_footer = prefs.show_keybinds_footer;
303    app.search_normal_mode = prefs.search_startup_mode;
304    app.fuzzy_search_enabled = prefs.fuzzy_search;
305    app.installed_packages_mode = prefs.installed_packages_mode;
306    app.app_mode = if prefs.start_in_news {
307        crate::state::types::AppMode::News
308    } else {
309        crate::state::types::AppMode::Package
310    };
311    app.news_filter_show_arch_news = prefs.news_filter_show_arch_news;
312    app.news_filter_show_advisories = prefs.news_filter_show_advisories;
313    app.news_filter_show_pkg_updates = prefs.news_filter_show_pkg_updates;
314    app.news_filter_show_aur_updates = prefs.news_filter_show_aur_updates;
315    app.news_filter_show_aur_comments = prefs.news_filter_show_aur_comments;
316    app.news_filter_installed_only = prefs.news_filter_installed_only;
317    app.news_max_age_days = prefs.news_max_age_days;
318    // Recompute news results with loaded filters/age
319    app.refresh_news_results();
320}
321
322/// What: Check if GNOME terminal is needed and set modal if required.
323///
324/// Inputs:
325/// - `app`: Application state to update
326/// - `headless`: When `true`, skip the check
327///
328/// Output: None (modifies app state in place)
329///
330/// Details:
331/// - Checks if running on GNOME desktop without `gnome-terminal` or `gnome-console`/`kgx`
332/// - Sets modal to `GnomeTerminalPrompt` if terminal is missing
333fn check_gnome_terminal(app: &mut AppState, headless: bool) {
334    if headless {
335        return;
336    }
337
338    let is_gnome = std::env::var("XDG_CURRENT_DESKTOP")
339        .ok()
340        .is_some_and(|v| v.to_uppercase().contains("GNOME"));
341
342    if !is_gnome {
343        return;
344    }
345
346    let has_gterm = crate::install::command_on_path("gnome-terminal");
347    let has_gconsole =
348        crate::install::command_on_path("gnome-console") || crate::install::command_on_path("kgx");
349
350    if !(has_gterm || has_gconsole) {
351        app.modal = crate::state::Modal::GnomeTerminalPrompt;
352    }
353}
354
355/// What: Load details cache from disk.
356///
357/// Inputs:
358/// - `app`: Application state to update
359///
360/// Output: None (modifies app state in place)
361///
362/// Details:
363/// - Attempts to deserialize details cache from JSON file
364fn load_details_cache(app: &mut AppState) {
365    if let Ok(s) = std::fs::read_to_string(&app.cache_path)
366        && let Ok(map) = serde_json::from_str::<HashMap<String, PackageDetails>>(&s)
367    {
368        app.details_cache = map;
369        tracing::info!(path = %app.cache_path.display(), "loaded details cache");
370    }
371}
372
373/// What: Load recent searches from disk.
374///
375/// Inputs:
376/// - `app`: Application state to update
377///
378/// Output: None (modifies app state in place)
379///
380/// Details:
381/// - Attempts to deserialize recent searches list from JSON file
382/// - Selects first item if list is not empty
383fn load_recent_searches(app: &mut AppState) {
384    if let Ok(s) = std::fs::read_to_string(&app.recent_path)
385        && let Ok(list) = serde_json::from_str::<Vec<String>>(&s)
386    {
387        let count = list.len();
388        app.load_recent_items(&list);
389        if count > 0 {
390            app.history_state.select(Some(0));
391        }
392        tracing::info!(
393            path = %app.recent_path.display(),
394            count = count,
395            "loaded recent searches"
396        );
397    }
398}
399
400/// What: Load install list from disk.
401///
402/// Inputs:
403/// - `app`: Application state to update
404///
405/// Output: None (modifies app state in place)
406///
407/// Details:
408/// - Attempts to deserialize install list from JSON file
409/// - Selects first item if list is not empty
410fn load_install_list(app: &mut AppState) {
411    if let Ok(s) = std::fs::read_to_string(&app.install_path)
412        && let Ok(list) = serde_json::from_str::<Vec<PackageItem>>(&s)
413    {
414        app.install_list = list;
415        if !app.install_list.is_empty() {
416            app.install_state.select(Some(0));
417        }
418        tracing::info!(
419            path = %app.install_path.display(),
420            count = app.install_list.len(),
421            "loaded install list"
422        );
423    }
424}
425
426/// What: Load news read URLs from disk.
427///
428/// Inputs:
429/// - `app`: Application state to update
430///
431/// Output: None (modifies app state in place)
432///
433/// Details:
434/// - Attempts to deserialize news read URLs set from JSON file
435fn load_news_read_urls(app: &mut AppState) {
436    if let Ok(s) = std::fs::read_to_string(&app.news_read_path)
437        && let Ok(set) = serde_json::from_str::<std::collections::HashSet<String>>(&s)
438    {
439        app.news_read_urls = set;
440        tracing::info!(
441            path = %app.news_read_path.display(),
442            count = app.news_read_urls.len(),
443            "loaded read news urls"
444        );
445    }
446}
447
448/// What: Load news read IDs from disk (feed-level tracking).
449///
450/// Inputs:
451/// - `app`: Application state to update
452///
453/// Output: None (modifies app state in place)
454///
455/// Details:
456/// - Attempts to deserialize news read IDs set from JSON file.
457/// - If no IDs file is found, falls back to populated `news_read_urls` for migration.
458fn load_news_read_ids(app: &mut AppState) {
459    if let Ok(s) = std::fs::read_to_string(&app.news_read_ids_path)
460        && let Ok(set) = serde_json::from_str::<std::collections::HashSet<String>>(&s)
461    {
462        app.news_read_ids = set;
463        tracing::info!(
464            path = %app.news_read_ids_path.display(),
465            count = app.news_read_ids.len(),
466            "loaded read news ids"
467        );
468        return;
469    }
470
471    if app.news_read_ids.is_empty() && !app.news_read_urls.is_empty() {
472        app.news_read_ids.extend(app.news_read_urls.iter().cloned());
473        tracing::info!(
474            copied = app.news_read_ids.len(),
475            "seeded news read ids from legacy URL set"
476        );
477        app.news_read_ids_dirty = true;
478    }
479}
480
481/// What: Load announcement read IDs from disk.
482///
483/// Inputs:
484/// - `app`: Application state to update
485///
486/// Output: None (modifies app state in place)
487///
488/// Details:
489/// - Attempts to deserialize announcement read IDs set from JSON file
490/// - Handles both old format (single hash) and new format (set of IDs) for migration
491fn load_announcement_state(app: &mut AppState) {
492    // Try old format for migration ({ "hash": "..." })
493    /// What: Legacy announcement read state structure.
494    ///
495    /// Inputs: Deserialized from old announcement read file.
496    ///
497    /// Output: Old state structure for migration.
498    ///
499    /// Details: Used for migrating from old announcement read state format.
500    #[derive(serde::Deserialize)]
501    struct OldAnnouncementReadState {
502        /// Announcement hash if read.
503        hash: Option<String>,
504    }
505    if let Ok(s) = std::fs::read_to_string(&app.announcement_read_path) {
506        // Try new format first (HashSet<String>)
507        if let Ok(ids) = serde_json::from_str::<std::collections::HashSet<String>>(&s) {
508            app.announcements_read_ids = ids;
509            tracing::info!(
510                path = %app.announcement_read_path.display(),
511                count = app.announcements_read_ids.len(),
512                "loaded announcement read IDs"
513            );
514            return;
515        }
516        if let Ok(old_state) = serde_json::from_str::<OldAnnouncementReadState>(&s)
517            && let Some(hash) = old_state.hash
518        {
519            app.announcements_read_ids.insert(format!("hash:{hash}"));
520            app.announcement_dirty = true; // Mark dirty to migrate to new format
521            tracing::info!(
522                path = %app.announcement_read_path.display(),
523                "migrated old announcement read state"
524            );
525        }
526    }
527}
528
529/// What: Check for version-embedded announcement and show modal if not read.
530///
531/// Inputs:
532/// - `app`: Application state to update
533///
534/// Output: None (modifies app state in place)
535///
536/// Details:
537/// - Checks embedded announcements for current app version
538/// - If version announcement exists and hasn't been marked as read, shows modal
539fn check_version_announcement(app: &mut AppState) {
540    const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
541
542    // Extract base version (X.X.X) from current version, ignoring suffixes
543    let current_base_version = crate::announcements::extract_base_version(CURRENT_VERSION);
544
545    // Find announcement matching the base version (compares only X.X.X part)
546    if let Some(announcement) = crate::announcements::VERSION_ANNOUNCEMENTS
547        .iter()
548        .find(|a| {
549            let announcement_base_version = crate::announcements::extract_base_version(a.version);
550            announcement_base_version == current_base_version
551        })
552    {
553        // Use full current version (including suffix) for the ID
554        // This ensures announcements show again when suffix changes (e.g., 0.6.0-pr#85 -> 0.6.0-pr#86)
555        let version_id = format!("v{CURRENT_VERSION}");
556
557        // Check if this version announcement has been marked as read
558        if app.announcements_read_ids.contains(&version_id) {
559            tracing::info!(
560                current_version = CURRENT_VERSION,
561                base_version = %current_base_version,
562                "version announcement already marked as read"
563            );
564            return;
565        }
566
567        // Show version announcement modal
568        app.modal = crate::state::Modal::Announcement {
569            title: announcement.title.to_string(),
570            content: announcement.content.to_string(),
571            id: version_id,
572            scroll: 0,
573        };
574        tracing::info!(
575            current_version = CURRENT_VERSION,
576            base_version = %current_base_version,
577            announcement_version = announcement.version,
578            "showing version announcement modal"
579        );
580    }
581    // Note: Remote announcements will be queued if they arrive while embedded is showing
582    // and will be shown when embedded is dismissed via show_next_pending_announcement()
583}
584
585/// What: Initialize application state by loading settings, caches, and persisted data.
586///
587/// Inputs:
588/// - `app`: Mutable application state to initialize
589/// - `dry_run_flag`: Whether to enable dry-run mode for this session
590/// - `headless`: Whether running in headless/test mode
591/// - `prefs`: Preflighted settings snapshot to apply during initialization
592///
593/// Output:
594/// - Returns `InitFlags` indicating which caches need background resolution
595///
596/// Details:
597/// - Applies startup settings that were preflighted before runtime initialization
598/// - Initializes locale system and translations
599/// - Loads persisted data: recent searches, install list, details cache, dependency/file/service/sandbox caches
600/// - Loads news read URLs and announcement state
601/// - Loads official package index from disk
602/// - Checks for version-embedded announcements
603pub fn initialize_app_state(
604    app: &mut AppState,
605    dry_run_flag: bool,
606    headless: bool,
607    prefs: &crate::theme::Settings,
608) -> InitFlags {
609    app.dry_run = if dry_run_flag {
610        true
611    } else {
612        prefs.app_dry_run_default
613    };
614    app.last_input_change = Instant::now();
615
616    // Log resolved configuration/state file locations at startup
617    tracing::info!(
618        recent = %app.recent_path.display(),
619        install = %app.install_path.display(),
620        details_cache = %app.cache_path.display(),
621        index = %app.official_index_path.display(),
622        news_read = %app.news_read_path.display(),
623        news_read_ids = %app.news_read_ids_path.display(),
624        announcement_read = %app.announcement_read_path.display(),
625        "resolved state file paths"
626    );
627
628    apply_settings_to_app_state(app, prefs);
629
630    // Initialize locale system
631    initialize_locale_system(app, &prefs.locale, prefs);
632
633    check_gnome_terminal(app, headless);
634
635    // Show NewsSetup modal on first launch if not configured
636    if !headless && !prefs.startup_news_configured {
637        // Only show if no other modal is already set (e.g., GnomeTerminalPrompt)
638        if matches!(app.modal, crate::state::Modal::None) {
639            app.modal = crate::state::Modal::NewsSetup {
640                show_arch_news: prefs.startup_news_show_arch_news,
641                show_advisories: prefs.startup_news_show_advisories,
642                show_aur_updates: prefs.startup_news_show_aur_updates,
643                show_aur_comments: prefs.startup_news_show_aur_comments,
644                show_pkg_updates: prefs.startup_news_show_pkg_updates,
645                max_age_days: prefs.startup_news_max_age_days,
646                cursor: 0,
647            };
648        }
649    } else if !headless && prefs.startup_news_configured {
650        // Always fetch fresh news in background (using last startup timestamp for incremental updates)
651        // Show loading toast while fetching, but cached items will be displayed immediately
652        app.news_loading = true;
653        app.toast_message = Some(crate::i18n::t(app, "app.news_button.loading"));
654        app.toast_expires_at = None; // No expiration - toast stays until news loading completes
655    }
656
657    // Check faillock status at startup
658    if !headless {
659        let username = std::env::var("USER").unwrap_or_else(|_| "user".to_string());
660        let (is_locked, lockout_until, remaining_minutes) =
661            crate::logic::faillock::get_lockout_info(&username);
662        app.faillock_locked = is_locked;
663        app.faillock_lockout_until = lockout_until;
664        app.faillock_remaining_minutes = remaining_minutes;
665    }
666
667    load_details_cache(app);
668    load_recent_searches(app);
669    load_install_list(app);
670    initialize_cache_files(app);
671
672    // Load dependency cache after install list is loaded (but before channels are created)
673    let (deps_cache, needs_deps_resolution) = load_cache_with_signature(
674        &app.install_list,
675        &app.deps_cache_path,
676        deps_cache::compute_signature,
677        deps_cache::load_cache,
678        "dependency",
679    );
680    if let Some(cached_deps) = deps_cache {
681        app.install_list_deps = cached_deps;
682        tracing::info!(
683            path = %app.deps_cache_path.display(),
684            count = app.install_list_deps.len(),
685            "loaded dependency cache"
686        );
687    }
688
689    // Load file cache after install list is loaded (but before channels are created)
690    let (files_cache, needs_files_resolution) = load_cache_with_signature(
691        &app.install_list,
692        &app.files_cache_path,
693        files_cache::compute_signature,
694        files_cache::load_cache,
695        "file",
696    );
697    if let Some(cached_files) = files_cache {
698        app.install_list_files = cached_files;
699        tracing::info!(
700            path = %app.files_cache_path.display(),
701            count = app.install_list_files.len(),
702            "loaded file cache"
703        );
704    }
705
706    // Load service cache after install list is loaded (but before channels are created)
707    let (services_cache, needs_services_resolution) = load_cache_with_signature(
708        &app.install_list,
709        &app.services_cache_path,
710        services_cache::compute_signature,
711        services_cache::load_cache,
712        "service",
713    );
714    if let Some(cached_services) = services_cache {
715        app.install_list_services = cached_services;
716        tracing::info!(
717            path = %app.services_cache_path.display(),
718            count = app.install_list_services.len(),
719            "loaded service cache"
720        );
721    }
722
723    // Load sandbox cache after install list is loaded (but before channels are created)
724    let (sandbox_cache, needs_sandbox_resolution) = load_cache_with_signature(
725        &app.install_list,
726        &app.sandbox_cache_path,
727        sandbox_cache::compute_signature,
728        sandbox_cache::load_cache,
729        "sandbox",
730    );
731    if let Some(cached_sandbox) = sandbox_cache {
732        app.install_list_sandbox = cached_sandbox;
733        tracing::info!(
734            path = %app.sandbox_cache_path.display(),
735            count = app.install_list_sandbox.len(),
736            "loaded sandbox cache"
737        );
738    }
739
740    load_news_read_urls(app);
741    load_news_read_ids(app);
742    load_announcement_state(app);
743
744    pkgindex::load_from_disk(&app.official_index_path);
745
746    // Check for version-embedded announcement after loading state
747    check_version_announcement(app);
748    tracing::info!(
749        path = %app.official_index_path.display(),
750        "attempted to load official index from disk"
751    );
752
753    InitFlags {
754        needs_deps_resolution,
755        needs_files_resolution,
756        needs_services_resolution,
757        needs_sandbox_resolution,
758    }
759}
760
761/// What: Trigger initial background resolution for caches that were missing or invalid.
762///
763/// Inputs:
764/// - `app`: Application state
765/// - `flags`: Initialization flags indicating which caches need resolution
766/// - `deps_req_tx`: Channel sender for dependency resolution requests
767/// - `files_req_tx`: Channel sender for file resolution requests (with action)
768/// - `services_req_tx`: Channel sender for service resolution requests
769/// - `sandbox_req_tx`: Channel sender for sandbox resolution requests
770///
771/// Output:
772/// - Sets resolution flags and sends requests to background workers
773///
774/// Details:
775/// - Only triggers resolution if cache was missing/invalid and install list is not empty
776pub fn trigger_initial_resolutions(
777    app: &mut AppState,
778    flags: &InitFlags,
779    deps_req_tx: &tokio::sync::mpsc::UnboundedSender<(
780        Vec<PackageItem>,
781        crate::state::modal::PreflightAction,
782    )>,
783    files_req_tx: &tokio::sync::mpsc::UnboundedSender<(
784        Vec<PackageItem>,
785        crate::state::modal::PreflightAction,
786    )>,
787    services_req_tx: &tokio::sync::mpsc::UnboundedSender<(
788        Vec<PackageItem>,
789        crate::state::modal::PreflightAction,
790    )>,
791    sandbox_req_tx: &tokio::sync::mpsc::UnboundedSender<Vec<PackageItem>>,
792) {
793    if flags.needs_deps_resolution && !app.install_list.is_empty() {
794        app.deps_resolving = true;
795        // Initial resolution is always for Install action (install_list)
796        let _ = deps_req_tx.send((
797            app.install_list.clone(),
798            crate::state::modal::PreflightAction::Install,
799        ));
800    }
801
802    if flags.needs_files_resolution && !app.install_list.is_empty() {
803        app.files_resolving = true;
804        // Initial resolution is always for Install action (install_list)
805        let _ = files_req_tx.send((
806            app.install_list.clone(),
807            crate::state::modal::PreflightAction::Install,
808        ));
809    }
810
811    if flags.needs_services_resolution && !app.install_list.is_empty() {
812        app.services_resolving = true;
813        let _ = services_req_tx.send((
814            app.install_list.clone(),
815            crate::state::modal::PreflightAction::Install,
816        ));
817    }
818
819    if flags.needs_sandbox_resolution && !app.install_list.is_empty() {
820        app.sandbox_resolving = true;
821        let _ = sandbox_req_tx.send(app.install_list.clone());
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828    use crate::app::runtime::background::Channels;
829
830    /// What: Provide a baseline `AppState` for initialization tests.
831    ///
832    /// Inputs: None
833    /// Output: Fresh `AppState` with default values
834    fn new_app() -> AppState {
835        AppState::default()
836    }
837
838    #[test]
839    /// What: Verify that `initialize_locale_system` sets default locale when config file is missing.
840    ///
841    /// Inputs:
842    /// - App state with default locale
843    /// - Empty locale preference
844    ///
845    /// Output:
846    /// - Locale is set to "en-US" when config file is missing
847    /// - Translations maps are initialized (may be empty)
848    ///
849    /// Details:
850    /// - Tests graceful fallback when i18n config is not found
851    fn initialize_locale_system_fallback_when_config_missing() {
852        let mut app = new_app();
853        let prefs = crate::theme::Settings::default();
854
855        // This will fall back to en-US if config file is missing
856        initialize_locale_system(&mut app, "", &prefs);
857
858        // Locale should be set (either resolved or default)
859        assert!(!app.locale.is_empty());
860        // Translations maps should be initialized
861        assert!(app.translations.is_empty() || !app.translations.is_empty());
862        assert!(app.translations_fallback.is_empty() || !app.translations_fallback.is_empty());
863    }
864
865    #[test]
866    /// What: Verify that `initialize_app_state` sets `dry_run` flag correctly.
867    ///
868    /// Inputs:
869    /// - `AppState`
870    /// - `dry_run_flag` = true
871    /// - headless = false
872    ///
873    /// Output:
874    /// - `app.dry_run` is set to true
875    /// - `InitFlags` are returned
876    ///
877    /// Details:
878    /// - Tests that `dry_run` flag is properly initialized
879    fn initialize_app_state_sets_dry_run_flag() {
880        let mut app = new_app();
881        let prefs = crate::theme::settings();
882        let flags = initialize_app_state(&mut app, true, false, &prefs);
883
884        assert!(app.dry_run);
885        // Flags should be returned (InitFlags struct is created)
886        // The actual values depend on cache state, so we just verify flags exist
887        let _ = flags;
888    }
889
890    #[test]
891    /// What: Verify that `initialize_app_state` loads settings correctly.
892    ///
893    /// Inputs:
894    /// - `AppState`
895    /// - `dry_run_flag` = false
896    /// - headless = false
897    ///
898    /// Output:
899    /// - `AppState` has layout percentages set
900    /// - Keymap is set
901    /// - Sort mode is set
902    ///
903    /// Details:
904    /// - Tests that settings are properly applied to app state
905    fn initialize_app_state_loads_settings() {
906        let mut app = new_app();
907        let prefs = crate::theme::settings();
908        let _flags = initialize_app_state(&mut app, false, false, &prefs);
909
910        // Settings should be loaded (values depend on config, but should be set)
911        assert!(app.layout_left_pct > 0);
912        assert!(app.layout_center_pct > 0);
913        assert!(app.layout_right_pct > 0);
914        // Keymap should be initialized (it's a struct, not a string)
915        // Just verify it's not the default empty state by checking a field
916        // (KeyMap has many fields, we just verify it's been set)
917    }
918
919    #[test]
920    /// What: Verify that `initialize_cache_files` creates placeholder cache files when missing.
921    ///
922    /// Inputs:
923    /// - `AppState` with cache paths pointed to temporary locations that do not yet exist.
924    ///
925    /// Output:
926    /// - Empty dependency, file, service, and sandbox cache files are created.
927    ///
928    /// Details:
929    /// - Validates that startup eagerly materializes cache files instead of delaying until first use.
930    fn initialize_cache_files_creates_empty_placeholders() {
931        let mut app = new_app();
932        let mut deps_path = std::env::temp_dir();
933        deps_path.push(format!(
934            "pacsea_init_deps_cache_{}_{}.json",
935            std::process::id(),
936            std::time::SystemTime::now()
937                .duration_since(std::time::UNIX_EPOCH)
938                .expect("System time is before UNIX epoch")
939                .as_nanos()
940        ));
941        let mut files_path = deps_path.clone();
942        files_path.set_file_name("pacsea_init_files_cache.json");
943        let mut services_path = deps_path.clone();
944        services_path.set_file_name("pacsea_init_services_cache.json");
945        let mut sandbox_path = deps_path.clone();
946        sandbox_path.set_file_name("pacsea_init_sandbox_cache.json");
947
948        app.deps_cache_path = deps_path.clone();
949        app.files_cache_path = files_path.clone();
950        app.services_cache_path = services_path.clone();
951        app.sandbox_cache_path = sandbox_path.clone();
952
953        // Ensure paths are clean
954        let _ = std::fs::remove_file(&app.deps_cache_path);
955        let _ = std::fs::remove_file(&app.files_cache_path);
956        let _ = std::fs::remove_file(&app.services_cache_path);
957        let _ = std::fs::remove_file(&app.sandbox_cache_path);
958
959        initialize_cache_files(&app);
960
961        let deps_body = std::fs::read_to_string(&app.deps_cache_path)
962            .expect("Dependency cache file should exist");
963        let deps_cache: crate::app::deps_cache::DependencyCache =
964            serde_json::from_str(&deps_body).expect("Dependency cache should parse");
965        assert!(deps_cache.install_list_signature.is_empty());
966        assert!(deps_cache.dependencies.is_empty());
967
968        let files_body =
969            std::fs::read_to_string(&app.files_cache_path).expect("File cache file should exist");
970        let files_cache: crate::app::files_cache::FileCache =
971            serde_json::from_str(&files_body).expect("File cache should parse");
972        assert!(files_cache.install_list_signature.is_empty());
973        assert!(files_cache.files.is_empty());
974
975        let services_body = std::fs::read_to_string(&app.services_cache_path)
976            .expect("Service cache file should exist");
977        let services_cache: crate::app::services_cache::ServiceCache =
978            serde_json::from_str(&services_body).expect("Service cache should parse");
979        assert!(services_cache.install_list_signature.is_empty());
980        assert!(services_cache.services.is_empty());
981
982        let sandbox_body = std::fs::read_to_string(&app.sandbox_cache_path)
983            .expect("Sandbox cache file should exist");
984        let sandbox_cache: crate::app::sandbox_cache::SandboxCache =
985            serde_json::from_str(&sandbox_body).expect("Sandbox cache should parse");
986        assert!(sandbox_cache.install_list_signature.is_empty());
987        assert!(sandbox_cache.sandbox_info.is_empty());
988
989        let _ = std::fs::remove_file(&app.deps_cache_path);
990        let _ = std::fs::remove_file(&app.files_cache_path);
991        let _ = std::fs::remove_file(&app.services_cache_path);
992        let _ = std::fs::remove_file(&app.sandbox_cache_path);
993    }
994
995    #[tokio::test]
996    /// What: Verify that `trigger_initial_resolutions` skips when install list is empty.
997    ///
998    /// Inputs:
999    /// - `AppState` with empty install list
1000    /// - `InitFlags` with `needs_deps_resolution` = true
1001    /// - Channel senders
1002    ///
1003    /// Output:
1004    /// - No requests sent when install list is empty
1005    ///
1006    /// Details:
1007    /// - Tests that resolution is only triggered when install list is not empty
1008    async fn trigger_initial_resolutions_skips_when_install_list_empty() {
1009        let mut app = new_app();
1010        app.install_list.clear();
1011
1012        let flags = InitFlags {
1013            needs_deps_resolution: true,
1014            needs_files_resolution: true,
1015            needs_services_resolution: true,
1016            needs_sandbox_resolution: true,
1017        };
1018
1019        // Create channels (we only need the senders)
1020        let channels = Channels::new(std::path::PathBuf::from("/tmp"));
1021
1022        // Should not panic even with empty install list
1023        trigger_initial_resolutions(
1024            &mut app,
1025            &flags,
1026            &channels.deps_req_tx,
1027            &channels.files_req_tx,
1028            &channels.services_req_tx,
1029            &channels.sandbox_req_tx,
1030        );
1031
1032        // Flags should not be set when install list is empty
1033        assert!(!app.deps_resolving);
1034        assert!(!app.files_resolving);
1035        assert!(!app.services_resolving);
1036        assert!(!app.sandbox_resolving);
1037    }
1038
1039    #[tokio::test]
1040    /// What: Verify that `trigger_initial_resolutions` sets flags and sends requests when needed.
1041    ///
1042    /// Inputs:
1043    /// - `AppState` with non-empty install list
1044    /// - `InitFlags` with `needs_deps_resolution` = true
1045    /// - Channel senders
1046    ///
1047    /// Output:
1048    /// - `deps_resolving` flag is set
1049    /// - Request is sent to `deps_req_tx`
1050    ///
1051    /// Details:
1052    /// - Tests that resolution is properly triggered when conditions are met
1053    async fn trigger_initial_resolutions_triggers_when_needed() {
1054        let mut app = new_app();
1055        app.install_list.push(crate::state::PackageItem {
1056            name: "test-package".to_string(),
1057            version: "1.0.0".to_string(),
1058            description: "Test".to_string(),
1059            source: crate::state::Source::Aur,
1060            popularity: None,
1061            out_of_date: None,
1062            orphaned: false,
1063        });
1064
1065        let flags = InitFlags {
1066            needs_deps_resolution: true,
1067            needs_files_resolution: false,
1068            needs_services_resolution: false,
1069            needs_sandbox_resolution: false,
1070        };
1071
1072        let channels = Channels::new(std::path::PathBuf::from("/tmp"));
1073
1074        trigger_initial_resolutions(
1075            &mut app,
1076            &flags,
1077            &channels.deps_req_tx,
1078            &channels.files_req_tx,
1079            &channels.services_req_tx,
1080            &channels.sandbox_req_tx,
1081        );
1082
1083        // Flag should be set
1084        assert!(app.deps_resolving);
1085        // Other flags should not be set
1086        assert!(!app.files_resolving);
1087        assert!(!app.services_resolving);
1088        assert!(!app.sandbox_resolving);
1089    }
1090}