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
16fn bool_to_string(value: bool) -> String {
24 if value {
25 "true".to_string()
26 } else {
27 "false".to_string()
28 }
29}
30
31fn optional_int_to_string(value: Option<u32>) -> String {
39 value.map_or_else(|| "all".to_string(), |v| v.to_string())
40}
41
42fn 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
59fn 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
109fn 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
126fn 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
167fn 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
184fn 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 Some("true".to_string())
198 }
199 _ => None,
200 }
201}
202
203fn 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
226fn 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 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 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
284pub fn ensure_settings_keys_present(prefs: &Settings) {
299 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 return;
315 };
316
317 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 fs::read_to_string(&p)
330 .map(|content| content.lines().map(ToString::to_string).collect())
331 .unwrap_or_default()
332 } else {
333 Vec::new()
335 };
336
337 if created_new || lines.is_empty() {
339 lines = SETTINGS_SKELETON_CONTENT
340 .lines()
341 .map(ToString::to_string)
342 .collect();
343 }
344 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 let skeleton_lines: Vec<&str> = SETTINGS_SKELETON_CONTENT.lines().collect();
363 let missing_settings = parse_missing_settings(&skeleton_lines, &have, prefs);
364
365 if created_new || !missing_settings.is_empty() {
367 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 let kb = resolve_keybinds_config_path().unwrap_or_else(|| config_dir().join("keybinds.conf"));
398 if kb.exists() {
399 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
409fn ensure_keybinds_present(keybinds_path: &Path) {
422 let Ok(existing_content) = fs::read_to_string(keybinds_path) else {
424 return; };
426
427 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 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 current_comment = None;
454 continue;
455 }
456 if trimmed.starts_with('#') {
457 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 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 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 current_comment = None;
498 current_section_header = None;
499 }
500 }
501
502 if missing_keybinds.is_empty() {
504 return;
505 }
506
507 let mut new_lines: Vec<String> = existing_content.lines().map(ToString::to_string).collect();
509
510 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
536pub 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
618pub fn maybe_migrate_legacy_confs() {
631 let base = config_dir();
632 let legacy = base.join("pacsea.conf");
633 if !legacy.is_file() {
634 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 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 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 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 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 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 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}