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 "aur_helper" => Some(prefs.aur_helper.clone()),
134 "virustotal_api_key" => Some(prefs.virustotal_api_key.clone()),
135 _ => None,
136 }
137}
138
139fn 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
180fn 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
197fn 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 Some("true".to_string())
211 }
212 _ => None,
213 }
214}
215
216fn 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
237fn 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
261fn 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 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 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
319fn 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
342pub fn ensure_settings_keys_present(prefs: &Settings) {
357 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 return;
373 };
374
375 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 fs::read_to_string(&p)
388 .map(|content| content.lines().map(ToString::to_string).collect())
389 .unwrap_or_default()
390 } else {
391 Vec::new()
393 };
394
395 if created_new || lines.is_empty() {
397 lines = SETTINGS_SKELETON_CONTENT
398 .lines()
399 .map(ToString::to_string)
400 .collect();
401 }
402 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 let skeleton_lines: Vec<&str> = SETTINGS_SKELETON_CONTENT.lines().collect();
421 let missing_settings = parse_missing_settings(&skeleton_lines, &have, prefs);
422
423 if created_new || !missing_settings.is_empty() {
425 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 let kb = resolve_keybinds_config_path().unwrap_or_else(|| config_dir().join("keybinds.conf"));
456 if kb.exists() {
457 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
469fn ensure_keybinds_present(keybinds_path: &Path) {
482 let Ok(existing_content) = fs::read_to_string(keybinds_path) else {
484 return; };
486
487 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 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 current_comment = None;
514 continue;
515 }
516 if trimmed.starts_with('#') {
517 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 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 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 current_comment = None;
558 current_section_header = None;
559 }
560 }
561
562 if missing_keybinds.is_empty() {
564 return;
565 }
566
567 let mut new_lines: Vec<String> = existing_content.lines().map(ToString::to_string).collect();
569
570 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
596pub 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
678pub fn maybe_migrate_legacy_confs() {
691 let base = config_dir();
692 let legacy = base.join("pacsea.conf");
693 if !legacy.is_file() {
694 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 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 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 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 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 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 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}