Skip to main content

pacsea/theme/config/
settings_ensure.rs

1use std::collections::HashSet;
2use std::fs;
3use std::path::Path;
4
5use crate::theme::config::skeletons::{
6    KEYBINDS_SKELETON_CONTENT, REPOS_SKELETON_CONTENT, SETTINGS_SKELETON_CONTENT,
7    THEME_SKELETON_CONTENT,
8};
9use crate::theme::config::theme_loader::{THEME_REQUIRED_CANONICAL, resolved_theme_canonical_keys};
10use crate::theme::parsing::canonical_for_key;
11use crate::theme::paths::{
12    config_dir, resolve_keybinds_config_path, resolve_repos_config_path,
13    resolve_settings_config_path, resolve_theme_config_path,
14};
15use crate::theme::types::Settings;
16
17/// What: Convert a boolean value to a config string.
18///
19/// Inputs:
20/// - `value`: Boolean value to convert
21///
22/// Output:
23/// - "true" or "false" string
24fn bool_to_string(value: bool) -> String {
25    if value {
26        "true".to_string()
27    } else {
28        "false".to_string()
29    }
30}
31
32/// What: Convert an optional integer to a config string.
33///
34/// Inputs:
35/// - `value`: Optional integer value
36///
37/// Output:
38/// - String representation of the value, or "all" if None
39fn optional_int_to_string(value: Option<u32>) -> String {
40    value.map_or_else(|| "all".to_string(), |v| v.to_string())
41}
42
43/// What: Get layout-related setting values.
44///
45/// Inputs:
46/// - `key`: Normalized key name
47/// - `prefs`: Current in-memory settings
48///
49/// Output:
50/// - Some(String) if key was handled, None otherwise
51fn get_layout_value(key: &str, prefs: &Settings) -> Option<String> {
52    match key {
53        "layout_left_pct" => Some(prefs.layout_left_pct.to_string()),
54        "layout_center_pct" => Some(prefs.layout_center_pct.to_string()),
55        "layout_right_pct" => Some(prefs.layout_right_pct.to_string()),
56        "main_pane_order" => Some(crate::state::format_main_pane_order(&prefs.main_pane_order)),
57        "vertical_min_results" => Some(prefs.vertical_min_results.to_string()),
58        "vertical_max_results" => Some(prefs.vertical_max_results.to_string()),
59        "vertical_min_middle" => Some(prefs.vertical_min_middle.to_string()),
60        "vertical_max_middle" => Some(prefs.vertical_max_middle.to_string()),
61        "vertical_min_package_info" => Some(prefs.vertical_min_package_info.to_string()),
62        _ => None,
63    }
64}
65
66/// What: Get app/UI-related setting values.
67///
68/// Inputs:
69/// - `key`: Normalized key name
70/// - `prefs`: Current in-memory settings
71///
72/// Output:
73/// - Some(String) if key was handled, None otherwise
74fn get_app_value(key: &str, prefs: &Settings) -> Option<String> {
75    match key {
76        "app_dry_run_default" => Some(bool_to_string(prefs.app_dry_run_default)),
77        "sort_mode" => Some(prefs.sort_mode.as_config_key().to_string()),
78        "clipboard_suffix" => Some(prefs.clipboard_suffix.clone()),
79        "show_recent_pane" | "show_search_history_pane" => {
80            Some(bool_to_string(prefs.show_recent_pane))
81        }
82        "show_install_pane" => Some(bool_to_string(prefs.show_install_pane)),
83        "show_keybinds_footer" => Some(bool_to_string(prefs.show_keybinds_footer)),
84        "package_marker" => {
85            let marker_str = match prefs.package_marker {
86                crate::theme::types::PackageMarker::FullLine => "full_line",
87                crate::theme::types::PackageMarker::Front => "front",
88                crate::theme::types::PackageMarker::End => "end",
89            };
90            Some(marker_str.to_string())
91        }
92        "app_start_mode" => {
93            let mode = if prefs.start_in_news {
94                "news"
95            } else {
96                "package"
97            };
98            Some(mode.to_string())
99        }
100        "skip_preflight" => Some(bool_to_string(prefs.skip_preflight)),
101        "search_startup_mode" => {
102            let mode = if prefs.search_startup_mode {
103                "normal_mode"
104            } else {
105                "insert_mode"
106            };
107            Some(mode.to_string())
108        }
109        "locale" => Some(prefs.locale.clone()),
110        "preferred_terminal" => Some(prefs.preferred_terminal.clone()),
111        "privilege_tool" => Some(prefs.privilege_mode.as_config_key().to_string()),
112        "auth_mode" => Some(prefs.auth_mode.as_config_key().to_string()),
113        "use_terminal_theme" => Some(bool_to_string(prefs.use_terminal_theme)),
114        "aur_vote_enabled" => Some(bool_to_string(prefs.aur_vote_enabled)),
115        "aur_vote_ssh_timeout_seconds" => Some(prefs.aur_vote_ssh_timeout_seconds.to_string()),
116        "aur_vote_ssh_command" => Some(prefs.aur_vote_ssh_command.clone()),
117        _ => None,
118    }
119}
120
121/// What: Get mirror-related setting values.
122///
123/// Inputs:
124/// - `key`: Normalized key name
125/// - `prefs`: Current in-memory settings
126///
127/// Output:
128/// - Some(String) if key was handled, None otherwise
129fn get_mirror_value(key: &str, prefs: &Settings) -> Option<String> {
130    match key {
131        "selected_countries" => Some(prefs.selected_countries.clone()),
132        "mirror_count" => Some(prefs.mirror_count.to_string()),
133        "virustotal_api_key" => Some(prefs.virustotal_api_key.clone()),
134        _ => None,
135    }
136}
137
138/// What: Get news-related setting values.
139///
140/// Inputs:
141/// - `key`: Normalized key name
142/// - `prefs`: Current in-memory settings
143///
144/// Output:
145/// - Some(String) if key was handled, None otherwise
146fn get_news_value(key: &str, prefs: &Settings) -> Option<String> {
147    match key {
148        "news_read_symbol" => Some(prefs.news_read_symbol.clone()),
149        "news_unread_symbol" => Some(prefs.news_unread_symbol.clone()),
150        "news_filter_show_arch_news" => Some(bool_to_string(prefs.news_filter_show_arch_news)),
151        "news_filter_show_advisories" => Some(bool_to_string(prefs.news_filter_show_advisories)),
152        "news_filter_show_pkg_updates" => Some(bool_to_string(prefs.news_filter_show_pkg_updates)),
153        "news_filter_show_aur_updates" => Some(bool_to_string(prefs.news_filter_show_aur_updates)),
154        "news_filter_show_aur_comments" => {
155            Some(bool_to_string(prefs.news_filter_show_aur_comments))
156        }
157        "news_filter_installed_only" => Some(bool_to_string(prefs.news_filter_installed_only)),
158        "news_max_age_days" => Some(optional_int_to_string(prefs.news_max_age_days)),
159        "startup_news_configured" => Some(bool_to_string(prefs.startup_news_configured)),
160        "startup_news_show_arch_news" => Some(bool_to_string(prefs.startup_news_show_arch_news)),
161        "startup_news_show_advisories" => Some(bool_to_string(prefs.startup_news_show_advisories)),
162        "startup_news_show_aur_updates" => {
163            Some(bool_to_string(prefs.startup_news_show_aur_updates))
164        }
165        "startup_news_show_aur_comments" => {
166            Some(bool_to_string(prefs.startup_news_show_aur_comments))
167        }
168        "startup_news_show_pkg_updates" => {
169            Some(bool_to_string(prefs.startup_news_show_pkg_updates))
170        }
171        "startup_news_max_age_days" => {
172            Some(optional_int_to_string(prefs.startup_news_max_age_days))
173        }
174        "news_cache_ttl_days" => Some(prefs.news_cache_ttl_days.to_string()),
175        _ => None,
176    }
177}
178
179/// What: Get updates/refresh-related setting values.
180///
181/// Inputs:
182/// - `key`: Normalized key name
183/// - `prefs`: Current in-memory settings
184///
185/// Output:
186/// - Some(String) if key was handled, None otherwise
187fn get_updates_value(key: &str, prefs: &Settings) -> Option<String> {
188    match key {
189        "updates_refresh_interval" | "updates_interval" | "refresh_interval" => {
190            Some(prefs.updates_refresh_interval.to_string())
191        }
192        _ => None,
193    }
194}
195
196/// What: Get scan-related setting values.
197///
198/// Inputs:
199/// - `key`: Normalized key name
200/// - `prefs`: Current in-memory settings
201///
202/// Output:
203/// - Some(String) if key was handled, None otherwise
204fn get_scan_value(key: &str, _prefs: &Settings) -> Option<String> {
205    match key {
206        "scan_do_clamav" | "scan_do_trivy" | "scan_do_semgrep" | "scan_do_shellcheck"
207        | "scan_do_virustotal" | "scan_do_custom" | "scan_do_sleuth" => {
208            // Scan keys default to true
209            Some("true".to_string())
210        }
211        _ => None,
212    }
213}
214
215/// What: Get PKGBUILD static-check related setting values for `ensure_settings_keys_present`.
216///
217/// Inputs:
218/// - `key`: Normalized key name
219/// - `prefs`: Current in-memory settings
220///
221/// Output:
222/// - Some(value) when the key is handled, else None
223///
224/// Details:
225/// - Used when appending missing keys so user prefs override skeleton defaults.
226fn get_pkgbuild_static_check_value(key: &str, prefs: &Settings) -> Option<String> {
227    match key {
228        "pkgbuild_shellcheck_exclude" => Some(prefs.pkgbuild_shellcheck_exclude.clone()),
229        "pkgbuild_checks_show_raw_output" => {
230            Some(bool_to_string(prefs.pkgbuild_checks_show_raw_output))
231        }
232        _ => None,
233    }
234}
235
236/// What: Get the value for a setting key, preferring prefs over skeleton default.
237///
238/// Inputs:
239/// - `key`: Normalized key name
240/// - `skeleton_value`: Default value from skeleton
241/// - `prefs`: Current in-memory settings
242///
243/// Output:
244/// - String value to use for the setting
245///
246/// Details:
247/// - Delegates to category-specific functions to reduce complexity.
248/// - Mirrors the parsing architecture for consistency.
249fn get_setting_value(key: &str, skeleton_value: String, prefs: &Settings) -> String {
250    get_layout_value(key, prefs)
251        .or_else(|| get_app_value(key, prefs))
252        .or_else(|| get_mirror_value(key, prefs))
253        .or_else(|| get_news_value(key, prefs))
254        .or_else(|| get_updates_value(key, prefs))
255        .or_else(|| get_scan_value(key, prefs))
256        .or_else(|| get_pkgbuild_static_check_value(key, prefs))
257        .unwrap_or(skeleton_value)
258}
259
260/// What: Parse skeleton and extract missing settings with comments.
261///
262/// Inputs:
263/// - `skeleton_lines`: Lines from the settings skeleton
264/// - `have`: Set of existing keys
265/// - `prefs`: Current settings to get values from
266///
267/// Output:
268/// - Vector of (`setting_line`, `optional_comment`) tuples
269fn parse_missing_settings(
270    skeleton_lines: &[&str],
271    have: &HashSet<String>,
272    prefs: &Settings,
273) -> Vec<(String, Option<String>)> {
274    let mut missing_settings: Vec<(String, Option<String>)> = Vec::new();
275    let mut current_comment: Option<String> = None;
276
277    for line in skeleton_lines {
278        let trimmed = line.trim();
279        if trimmed.is_empty() {
280            current_comment = None;
281            continue;
282        }
283        if trimmed.starts_with('#') {
284            // Check if this is a comment for a setting (not a section header or empty comment)
285            if !trimmed.contains("—")
286                && !trimmed.starts_with("# Pacsea")
287                && trimmed.len() > 1
288                && !trimmed.starts_with("# Available countries")
289            {
290                current_comment = Some(trimmed.to_string());
291            } else {
292                current_comment = None;
293            }
294            continue;
295        }
296        if trimmed.starts_with("//") {
297            current_comment = None;
298            continue;
299        }
300        if trimmed.contains('=') {
301            let mut parts = trimmed.splitn(2, '=');
302            let raw_key = parts.next().unwrap_or("");
303            let skeleton_value = parts.next().unwrap_or("").trim().to_string();
304            let key = raw_key.trim().to_lowercase().replace(['.', '-', ' '], "_");
305            if have.contains(&key) {
306                current_comment = None;
307            } else {
308                // Use value from prefs if available, otherwise use skeleton value
309                let value = get_setting_value(&key, skeleton_value, prefs);
310                let setting_line = format!("{} = {}", raw_key.trim(), value);
311                missing_settings.push((setting_line, current_comment.take()));
312            }
313        }
314    }
315    missing_settings
316}
317
318/// What: Create `repos.conf` from the built-in skeleton when the file does not exist yet.
319///
320/// Inputs:
321/// - None.
322///
323/// Output:
324/// - None.
325///
326/// Details:
327/// - Best-effort: ignores write failures (same pattern as keybinds seeding).
328/// - Target path matches [`resolve_repos_config_path`] when a candidate file already exists; if none
329///   exist yet, writes to `config_dir()/repos.conf` (same default as the Config menu and Repositories modal).
330fn ensure_repos_conf_skeleton() {
331    let repos_path = resolve_repos_config_path().unwrap_or_else(|| config_dir().join("repos.conf"));
332    if repos_path.exists() {
333        return;
334    }
335    if let Some(dir) = repos_path.parent() {
336        let _ = fs::create_dir_all(dir);
337    }
338    let _ = fs::write(&repos_path, REPOS_SKELETON_CONTENT);
339}
340
341/// What: Ensure all expected settings keys exist in `settings.conf`, appending defaults as needed.
342///
343/// Inputs:
344/// - `prefs`: Current in-memory settings whose values seed the file when keys are missing.
345///
346/// Output:
347/// - None.
348///
349/// # Panics
350/// - Panics if `lines.last()` is called on an empty vector after checking `!lines.is_empty()` (should not happen due to the check)
351///
352/// Details:
353/// - Preserves existing lines and comments while adding only absent keys.
354/// - Creates the settings file from the skeleton when it is missing or empty.
355pub fn ensure_settings_keys_present(prefs: &Settings) {
356    // Always resolve to HOME/XDG path similar to save_sort_mode
357    // This ensures we always have a path, even if the file doesn't exist yet
358    let p = resolve_settings_config_path().or_else(|| {
359        std::env::var("XDG_CONFIG_HOME")
360            .ok()
361            .map(std::path::PathBuf::from)
362            .or_else(|| {
363                std::env::var("HOME")
364                    .ok()
365                    .map(|h| Path::new(&h).join(".config"))
366            })
367            .map(|base| base.join("pacsea").join("settings.conf"))
368    });
369    let Some(p) = p else {
370        // This should never happen (HOME should always be set), but if it does, we can't proceed
371        return;
372    };
373
374    // Ensure directory exists
375    if let Some(dir) = p.parent() {
376        let _ = fs::create_dir_all(dir);
377    }
378
379    let meta = std::fs::metadata(&p).ok();
380    let file_exists = meta.is_some();
381    let file_empty = meta.is_none_or(|m| m.len() == 0);
382    let created_new = !file_exists || file_empty;
383
384    let mut lines: Vec<String> = if file_exists && !file_empty {
385        // File exists and has content - read it
386        fs::read_to_string(&p)
387            .map(|content| content.lines().map(ToString::to_string).collect())
388            .unwrap_or_default()
389    } else {
390        // File doesn't exist or is empty - start with skeleton
391        Vec::new()
392    };
393
394    // If file is missing or empty, seed with the built-in skeleton content first
395    if created_new || lines.is_empty() {
396        lines = SETTINGS_SKELETON_CONTENT
397            .lines()
398            .map(ToString::to_string)
399            .collect();
400    }
401    // Parse existing settings keys (normalize keys like the parser does)
402    let mut have: HashSet<String> = HashSet::new();
403    for line in &lines {
404        let trimmed = line.trim();
405        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
406            continue;
407        }
408        if let Some(eq) = trimmed.find('=') {
409            let (kraw, _) = trimmed.split_at(eq);
410            let key = kraw.trim().to_lowercase().replace(['.', '-', ' '], "_");
411            if key == "show_recent_pane" {
412                have.insert("show_search_history_pane".to_string());
413            }
414            have.insert(key);
415        }
416    }
417
418    // Parse skeleton to extract settings entries with their comments
419    let skeleton_lines: Vec<&str> = SETTINGS_SKELETON_CONTENT.lines().collect();
420    let missing_settings = parse_missing_settings(&skeleton_lines, &have, prefs);
421
422    // Update settings file if needed
423    if created_new || !missing_settings.is_empty() {
424        // Append missing settings to the file
425        // Add separator and header comment for auto-added settings
426        if !created_new
427            && !lines.is_empty()
428            && !lines
429                .last()
430                .expect("lines should not be empty after is_empty() check")
431                .trim()
432                .is_empty()
433        {
434            lines.push(String::new());
435        }
436        if !missing_settings.is_empty() {
437            lines.push("# Missing settings added automatically".to_string());
438            lines.push(String::new());
439        }
440
441        for (setting_line, comment) in &missing_settings {
442            if let Some(comment) = comment {
443                lines.push(comment.clone());
444            }
445            lines.push(setting_line.clone());
446        }
447
448        let new_content = lines.join("\n");
449        let _ = fs::write(p, new_content);
450    }
451
452    // Ensure keybinds file exists with skeleton if missing (best-effort)
453    // Try to use the same path resolution as reading, but fall back to config_dir if file doesn't exist yet
454    let kb = resolve_keybinds_config_path().unwrap_or_else(|| config_dir().join("keybinds.conf"));
455    if kb.exists() {
456        // Append missing keybinds to existing file
457        ensure_keybinds_present(&kb);
458    } else {
459        if let Some(dir) = kb.parent() {
460            let _ = fs::create_dir_all(dir);
461        }
462        let _ = fs::write(&kb, KEYBINDS_SKELETON_CONTENT);
463    }
464
465    ensure_repos_conf_skeleton();
466}
467
468/// What: Ensure all expected keybind entries exist in `keybinds.conf`, appending defaults as needed.
469///
470/// Inputs:
471/// - `keybinds_path`: Path to the keybinds.conf file.
472///
473/// Output:
474/// - None.
475///
476/// Details:
477/// - Preserves existing lines and comments while adding only absent keybinds.
478/// - Parses the skeleton to extract all keybind entries with their associated comments.
479/// - Appends missing keybinds with their comments in the correct sections.
480fn ensure_keybinds_present(keybinds_path: &Path) {
481    // Read existing file
482    let Ok(existing_content) = fs::read_to_string(keybinds_path) else {
483        return; // Can't read, skip
484    };
485
486    // Parse existing keybinds (normalize keys like the parser does)
487    let mut have: HashSet<String> = HashSet::new();
488    for line in existing_content.lines() {
489        let trimmed = line.trim();
490        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
491            continue;
492        }
493        if !trimmed.contains('=') {
494            continue;
495        }
496        let mut parts = trimmed.splitn(2, '=');
497        let raw_key = parts.next().unwrap_or("");
498        let key = raw_key.trim().to_lowercase().replace(['.', '-', ' '], "_");
499        have.insert(key);
500    }
501
502    // Parse skeleton to extract keybind entries with their comments
503    let skeleton_lines: Vec<&str> = KEYBINDS_SKELETON_CONTENT.lines().collect();
504    let mut missing_keybinds: Vec<(String, Option<String>)> = Vec::new();
505    let mut current_comment: Option<String> = None;
506    let mut current_section_header: Option<String> = None;
507
508    for line in skeleton_lines {
509        let trimmed = line.trim();
510        if trimmed.is_empty() {
511            // Clear descriptive comment on empty lines (section header persists until next keybind)
512            current_comment = None;
513            continue;
514        }
515        if trimmed.starts_with('#') {
516            // Check if this is a section header (contains "—" or is a special header)
517            if trimmed.contains("—")
518                || trimmed.starts_with("# Pacsea")
519                || trimmed.starts_with("# Modifiers")
520            {
521                current_section_header = Some(trimmed.to_string());
522                current_comment = None;
523            } else {
524                // Descriptive comment for a keybind
525                current_comment = Some(trimmed.to_string());
526            }
527            continue;
528        }
529        if trimmed.starts_with("//") {
530            current_comment = None;
531            continue;
532        }
533        if trimmed.contains('=') {
534            let mut parts = trimmed.splitn(2, '=');
535            let raw_key = parts.next().unwrap_or("");
536            let key = raw_key.trim().to_lowercase().replace(['.', '-', ' '], "_");
537            if !have.contains(&key) {
538                // Key is missing, build comment string with section header and descriptive comment
539                let mut comment_parts = Vec::new();
540                if let Some(ref section) = current_section_header {
541                    comment_parts.push(section.clone());
542                }
543                if let Some(ref desc) = current_comment {
544                    comment_parts.push(desc.clone());
545                }
546                let combined_comment = if comment_parts.is_empty() {
547                    None
548                } else {
549                    Some(comment_parts.join("\n"))
550                };
551                missing_keybinds.push((trimmed.to_string(), combined_comment));
552            }
553            // Clear both descriptive comment and section header after processing keybind
554            // (whether the keybind exists or not). Only the first missing keybind in a section
555            // will include the section header in its comment.
556            current_comment = None;
557            current_section_header = None;
558        }
559    }
560
561    // If no missing keybinds, nothing to do
562    if missing_keybinds.is_empty() {
563        return;
564    }
565
566    // Append missing keybinds to the file
567    let mut new_lines: Vec<String> = existing_content.lines().map(ToString::to_string).collect();
568
569    // Add separator and header comment for auto-added keybinds
570    if !new_lines.is_empty()
571        && !new_lines
572            .last()
573            .expect("new_lines should not be empty after is_empty() check")
574            .trim()
575            .is_empty()
576    {
577        new_lines.push(String::new());
578    }
579    if !missing_keybinds.is_empty() {
580        new_lines.push("# Missing keybinds added automatically".to_string());
581        new_lines.push(String::new());
582    }
583
584    for (keybind_line, comment) in &missing_keybinds {
585        if let Some(comment) = comment {
586            new_lines.push(comment.clone());
587        }
588        new_lines.push(keybind_line.clone());
589    }
590
591    let new_content = new_lines.join("\n");
592    let _ = fs::write(keybinds_path, new_content);
593}
594
595/// What: Ensure `theme.conf` (or legacy theme file) defines every required theme key, appending skeleton defaults for any gaps.
596///
597/// Inputs:
598/// - None.
599///
600/// Output:
601/// - None.
602///
603/// Details:
604/// - Resolves the same path as theme loading (`resolve_theme_config_path` or `config_dir()/theme.conf`).
605/// - Writes the full theme skeleton when the file is missing or empty.
606/// - Otherwise appends `key = value` lines from `THEME_SKELETON_CONTENT` for each missing canonical color.
607/// - Must run before the first `theme()` load so incomplete files are repaired on disk first.
608pub fn ensure_theme_keys_present() {
609    let p = resolve_theme_config_path().unwrap_or_else(|| config_dir().join("theme.conf"));
610    if let Some(dir) = p.parent() {
611        let _ = fs::create_dir_all(dir);
612    }
613
614    let meta = fs::metadata(&p).ok();
615    let file_missing = meta.is_none();
616    let file_empty = meta.is_none_or(|m| m.len() == 0);
617
618    if file_missing || file_empty {
619        let _ = fs::write(&p, THEME_SKELETON_CONTENT);
620        return;
621    }
622
623    let Ok(content) = fs::read_to_string(&p) else {
624        return;
625    };
626
627    let have = resolved_theme_canonical_keys(&content);
628    let missing: Vec<&str> = THEME_REQUIRED_CANONICAL
629        .iter()
630        .copied()
631        .filter(|k| !have.contains(*k))
632        .collect();
633
634    if missing.is_empty() {
635        return;
636    }
637
638    let mut defaults: std::collections::HashMap<String, String> = std::collections::HashMap::new();
639    for line in THEME_SKELETON_CONTENT.lines() {
640        let trimmed = line.trim();
641        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
642            continue;
643        }
644        if !trimmed.contains('=') {
645            continue;
646        }
647        let mut parts = trimmed.splitn(2, '=');
648        let raw_key = parts.next().unwrap_or("").trim();
649        let norm = raw_key.to_lowercase().replace(['.', '-', ' '], "_");
650        let Some(canon) = canonical_for_key(&norm) else {
651            continue;
652        };
653        defaults
654            .entry(canon.to_string())
655            .or_insert_with(|| trimmed.to_string());
656    }
657
658    let mut lines: Vec<String> = content.lines().map(ToString::to_string).collect();
659    if !lines.is_empty() && !lines.last().is_some_and(|l| l.trim().is_empty()) {
660        lines.push(String::new());
661    }
662    lines.push("# Missing theme keys added automatically".to_string());
663    lines.push(String::new());
664    for canon in missing {
665        if let Some(line) = defaults.get(canon) {
666            lines.push(line.clone());
667        } else {
668            tracing::warn!(
669                canon = canon,
670                "theme skeleton had no default line for missing canonical key"
671            );
672        }
673    }
674    let _ = fs::write(&p, lines.join("\n"));
675}
676
677/// What: Migrate legacy `pacsea.conf` into the split `theme.conf` and `settings.conf` files.
678///
679/// Inputs:
680/// - None.
681///
682/// Output:
683/// - None.
684///
685/// Details:
686/// - Copies non-preference keys to `theme.conf` and preference keys (excluding keybinds) to `settings.conf`.
687/// - Seeds missing files with skeleton content when the legacy file is absent or empty.
688/// - Leaves existing, non-empty split configs untouched to avoid overwriting user changes.
689pub fn maybe_migrate_legacy_confs() {
690    let base = config_dir();
691    let legacy = base.join("pacsea.conf");
692    if !legacy.is_file() {
693        // No legacy file: ensure split configs exist with skeletons
694        let theme_path = base.join("theme.conf");
695        let settings_path = base.join("settings.conf");
696        let keybinds_path = base.join("keybinds.conf");
697
698        // theme.conf
699        let theme_missing_or_empty = std::fs::metadata(&theme_path)
700            .ok()
701            .is_none_or(|m| m.len() == 0);
702        if theme_missing_or_empty {
703            if let Some(dir) = theme_path.parent() {
704                let _ = fs::create_dir_all(dir);
705            }
706            let _ = fs::write(&theme_path, THEME_SKELETON_CONTENT);
707        }
708
709        // settings.conf
710        let settings_missing_or_empty = std::fs::metadata(&settings_path)
711            .ok()
712            .is_none_or(|m| m.len() == 0);
713        if settings_missing_or_empty {
714            if let Some(dir) = settings_path.parent() {
715                let _ = fs::create_dir_all(dir);
716            }
717            let _ = fs::write(&settings_path, SETTINGS_SKELETON_CONTENT);
718        }
719
720        // keybinds.conf
721        let keybinds_missing_or_empty = std::fs::metadata(&keybinds_path)
722            .ok()
723            .is_none_or(|m| m.len() == 0);
724        if keybinds_missing_or_empty {
725            if let Some(dir) = keybinds_path.parent() {
726                let _ = fs::create_dir_all(dir);
727            }
728            let _ = fs::write(&keybinds_path, KEYBINDS_SKELETON_CONTENT);
729        }
730        return;
731    }
732    let theme_path = base.join("theme.conf");
733    let settings_path = base.join("settings.conf");
734
735    let theme_missing_or_empty = std::fs::metadata(&theme_path)
736        .ok()
737        .is_none_or(|m| m.len() == 0);
738    let settings_missing_or_empty = std::fs::metadata(&settings_path)
739        .ok()
740        .is_none_or(|m| m.len() == 0);
741    if !theme_missing_or_empty && !settings_missing_or_empty {
742        // Nothing to do
743        return;
744    }
745    let Ok(content) = fs::read_to_string(&legacy) else {
746        return;
747    };
748
749    let mut theme_lines: Vec<String> = Vec::new();
750    let mut settings_lines: Vec<String> = Vec::new();
751
752    for line in content.lines() {
753        let trimmed = line.trim();
754        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
755            continue;
756        }
757        if !trimmed.contains('=') {
758            continue;
759        }
760        let mut parts = trimmed.splitn(2, '=');
761        let raw_key = parts.next().unwrap_or("");
762        let key = raw_key.trim();
763        let norm = key.to_lowercase().replace(['.', '-', ' '], "_");
764        // Same classification as theme parsing: treat these as non-theme preference keys
765        let is_pref_key = norm.starts_with("pref_")
766            || norm.starts_with("settings_")
767            || norm.starts_with("layout_")
768            || norm.starts_with("keybind_")
769            || norm.starts_with("app_")
770            || norm.starts_with("sort_")
771            || norm.starts_with("clipboard_")
772            || norm.starts_with("show_")
773            || norm == "results_sort";
774        if is_pref_key {
775            // Exclude keybinds from settings.conf; those live in keybinds.conf
776            if !norm.starts_with("keybind_") {
777                settings_lines.push(trimmed.to_string());
778            }
779        } else {
780            theme_lines.push(trimmed.to_string());
781        }
782    }
783
784    if theme_missing_or_empty {
785        if let Some(dir) = theme_path.parent() {
786            let _ = fs::create_dir_all(dir);
787        }
788        if theme_lines.is_empty() {
789            let _ = fs::write(&theme_path, THEME_SKELETON_CONTENT);
790        } else {
791            let mut out = String::new();
792            out.push_str("# Pacsea theme configuration (migrated from pacsea.conf)\n");
793            out.push_str(&theme_lines.join("\n"));
794            out.push('\n');
795            let _ = fs::write(&theme_path, out);
796        }
797    }
798
799    if settings_missing_or_empty {
800        if let Some(dir) = settings_path.parent() {
801            let _ = fs::create_dir_all(dir);
802        }
803        if settings_lines.is_empty() {
804            let _ = fs::write(&settings_path, SETTINGS_SKELETON_CONTENT);
805        } else {
806            let mut out = String::new();
807            out.push_str("# Pacsea settings configuration (migrated from pacsea.conf)\n");
808            out.push_str(&settings_lines.join("\n"));
809            out.push('\n');
810            let _ = fs::write(&settings_path, out);
811        }
812    }
813}