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