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
17fn bool_to_string(value: bool) -> String {
25 if value {
26 "true".to_string()
27 } else {
28 "false".to_string()
29 }
30}
31
32fn optional_int_to_string(value: Option<u32>) -> String {
40 value.map_or_else(|| "all".to_string(), |v| v.to_string())
41}
42
43fn 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
66fn 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
121fn 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
138fn 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
179fn 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
196fn 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 Some("true".to_string())
210 }
211 _ => None,
212 }
213}
214
215fn 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
236fn 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
260fn 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 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 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
318fn 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
341pub fn ensure_settings_keys_present(prefs: &Settings) {
356 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 return;
372 };
373
374 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 fs::read_to_string(&p)
387 .map(|content| content.lines().map(ToString::to_string).collect())
388 .unwrap_or_default()
389 } else {
390 Vec::new()
392 };
393
394 if created_new || lines.is_empty() {
396 lines = SETTINGS_SKELETON_CONTENT
397 .lines()
398 .map(ToString::to_string)
399 .collect();
400 }
401 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 let skeleton_lines: Vec<&str> = SETTINGS_SKELETON_CONTENT.lines().collect();
420 let missing_settings = parse_missing_settings(&skeleton_lines, &have, prefs);
421
422 if created_new || !missing_settings.is_empty() {
424 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 let kb = resolve_keybinds_config_path().unwrap_or_else(|| config_dir().join("keybinds.conf"));
455 if kb.exists() {
456 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
468fn ensure_keybinds_present(keybinds_path: &Path) {
481 let Ok(existing_content) = fs::read_to_string(keybinds_path) else {
483 return; };
485
486 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 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 current_comment = None;
513 continue;
514 }
515 if trimmed.starts_with('#') {
516 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 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 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 current_comment = None;
557 current_section_header = None;
558 }
559 }
560
561 if missing_keybinds.is_empty() {
563 return;
564 }
565
566 let mut new_lines: Vec<String> = existing_content.lines().map(ToString::to_string).collect();
568
569 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
595pub 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
677pub fn maybe_migrate_legacy_confs() {
690 let base = config_dir();
691 let legacy = base.join("pacsea.conf");
692 if !legacy.is_file() {
693 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 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 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 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 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 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 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}