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