pacsea/theme/config/
settings_save.rs

1use std::fs;
2use std::path::Path;
3
4use crate::theme::config::skeletons::SETTINGS_SKELETON_CONTENT;
5use crate::theme::paths::resolve_settings_config_path;
6
7/// What: Persist the user-selected sort mode into `settings.conf` (or legacy `pacsea.conf`).
8///
9/// Inputs:
10/// - `sm`: Sort mode chosen in the UI, expressed as `crate::state::SortMode`.
11///
12/// Output:
13/// - None.
14///
15/// Details:
16/// - Ensures the target file exists by seeding from the skeleton when missing.
17/// - Replaces existing `sort_mode`/`results_sort` entries while preserving comments.
18pub fn save_sort_mode(sm: crate::state::SortMode) {
19    let path = resolve_settings_config_path().or_else(|| {
20        std::env::var("XDG_CONFIG_HOME")
21            .ok()
22            .map(std::path::PathBuf::from)
23            .or_else(|| {
24                std::env::var("HOME")
25                    .ok()
26                    .map(|h| Path::new(&h).join(".config"))
27            })
28            .map(|base| base.join("pacsea").join("settings.conf"))
29    });
30    let Some(p) = path else {
31        return;
32    };
33
34    // Ensure directory exists
35    if let Some(dir) = p.parent() {
36        let _ = fs::create_dir_all(dir);
37    }
38
39    // If file doesn't exist or is empty, initialize with skeleton
40    let meta = std::fs::metadata(&p).ok();
41    let file_exists = meta.is_some();
42    let file_empty = meta.is_none_or(|m| m.len() == 0);
43
44    let mut lines: Vec<String> = if file_exists && !file_empty {
45        // File exists and has content - read it
46        fs::read_to_string(&p)
47            .map(|content| content.lines().map(ToString::to_string).collect())
48            .unwrap_or_default()
49    } else {
50        // File doesn't exist or is empty - start with skeleton
51        SETTINGS_SKELETON_CONTENT
52            .lines()
53            .map(ToString::to_string)
54            .collect()
55    };
56    let mut replaced = false;
57    for line in &mut lines {
58        let trimmed = line.trim();
59        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
60            continue;
61        }
62        if let Some(eq) = trimmed.find('=') {
63            let (kraw, _) = trimmed.split_at(eq);
64            let key = kraw.trim().to_lowercase().replace(['.', '-', ' '], "_");
65            if key == "sort_mode" || key == "results_sort" {
66                *line = format!("sort_mode = {}", sm.as_config_key());
67                replaced = true;
68            }
69        }
70    }
71    if !replaced {
72        if let Some(dir) = p.parent() {
73            let _ = fs::create_dir_all(dir);
74        }
75        lines.push(format!("sort_mode = {}", sm.as_config_key()));
76    }
77    let new_content = if lines.is_empty() {
78        format!("sort_mode = {}\n", sm.as_config_key())
79    } else {
80        lines.join("\n")
81    };
82    let _ = fs::write(p, new_content);
83}
84
85/// What: Persist a single boolean toggle within `settings.conf` while preserving unrelated content.
86///
87/// Inputs:
88/// - `primary_key`: Primary key name to update (lowercase, underscore-separated recommended).
89/// - `aliases`: Optional aliases that should map to the same setting (legacy compatibility).
90/// - `value`: Boolean flag to serialize as `true` or `false`.
91///
92/// Output:
93/// - None.
94///
95/// Details:
96/// - Creates the configuration file from the skeleton when it is missing or empty.
97/// - Rewrites existing entries (including aliases) in place; otherwise appends the primary key.
98/// - When an alias is encountered, it is replaced with the primary key to migrate configs forward.
99fn save_boolean_key_with_aliases(primary_key: &str, aliases: &[&str], value: bool) {
100    let path = resolve_settings_config_path().or_else(|| {
101        std::env::var("XDG_CONFIG_HOME")
102            .ok()
103            .map(std::path::PathBuf::from)
104            .or_else(|| {
105                std::env::var("HOME")
106                    .ok()
107                    .map(|h| Path::new(&h).join(".config"))
108            })
109            .map(|base| base.join("pacsea").join("settings.conf"))
110    });
111    let Some(p) = path else {
112        return;
113    };
114
115    // Ensure directory exists
116    if let Some(dir) = p.parent() {
117        let _ = fs::create_dir_all(dir);
118    }
119
120    // If file doesn't exist or is empty, initialize with skeleton
121    let meta = std::fs::metadata(&p).ok();
122    let file_exists = meta.is_some();
123    let file_empty = meta.is_none_or(|m| m.len() == 0);
124
125    let mut lines: Vec<String> = if file_exists && !file_empty {
126        // File exists and has content - read it
127        fs::read_to_string(&p)
128            .map(|content| content.lines().map(ToString::to_string).collect())
129            .unwrap_or_default()
130    } else {
131        // File doesn't exist or is empty - start with skeleton
132        SETTINGS_SKELETON_CONTENT
133            .lines()
134            .map(ToString::to_string)
135            .collect()
136    };
137    let bool_text = if value { "true" } else { "false" };
138    let primary_norm = primary_key
139        .trim()
140        .to_lowercase()
141        .replace(['.', '-', ' '], "_");
142    let alias_norms: Vec<String> = aliases
143        .iter()
144        .map(|k| k.trim().to_lowercase().replace(['.', '-', ' '], "_"))
145        .collect();
146    let mut replaced = false;
147    for line in &mut lines {
148        let trimmed = line.trim();
149        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
150            continue;
151        }
152        if let Some(eq) = trimmed.find('=') {
153            let (kraw, _) = trimmed.split_at(eq);
154            let key = kraw.trim().to_lowercase().replace(['.', '-', ' '], "_");
155            if key == primary_norm || alias_norms.iter().any(|alias| alias == &key) {
156                *line = format!("{primary_key} = {bool_text}");
157                replaced = true;
158            }
159        }
160    }
161    if !replaced {
162        if let Some(dir) = p.parent() {
163            let _ = fs::create_dir_all(dir);
164        }
165        lines.push(format!("{primary_key} = {bool_text}"));
166    }
167    let new_content = if lines.is_empty() {
168        format!("{primary_key} = {bool_text}\n")
169    } else {
170        lines.join("\n")
171    };
172    let _ = fs::write(p, new_content);
173}
174
175/// What: Persist a single boolean toggle within `settings.conf` while preserving unrelated content.
176///
177/// Inputs:
178/// - `key_norm`: Normalized (lowercase, underscore-separated) key name to update.
179/// - `value`: Boolean flag to serialize as `true` or `false`.
180///
181/// Output:
182/// - None.
183///
184/// Details:
185/// - Convenience wrapper that delegates to `save_boolean_key_with_aliases` without aliases.
186fn save_boolean_key(key_norm: &str, value: bool) {
187    save_boolean_key_with_aliases(key_norm, &[], value);
188}
189
190/// What: Persist a string-valued setting inside `settings.conf` without disturbing other keys.
191///
192/// Inputs:
193/// - `key_norm`: Normalized key to update.
194/// - `value`: String payload that should be written verbatim after trimming handled by the caller.
195///
196/// Output:
197/// - None.
198///
199/// Details:
200/// - Bootstraps the configuration file from the skeleton if necessary.
201/// - Updates the existing key in place or appends a new line when absent.
202fn save_string_key(key_norm: &str, value: &str) {
203    let path = resolve_settings_config_path().or_else(|| {
204        std::env::var("XDG_CONFIG_HOME")
205            .ok()
206            .map(std::path::PathBuf::from)
207            .or_else(|| {
208                std::env::var("HOME")
209                    .ok()
210                    .map(|h| Path::new(&h).join(".config"))
211            })
212            .map(|base| base.join("pacsea").join("settings.conf"))
213    });
214    let Some(p) = path else {
215        return;
216    };
217
218    // Ensure directory exists
219    if let Some(dir) = p.parent() {
220        let _ = fs::create_dir_all(dir);
221    }
222
223    // If file doesn't exist or is empty, initialize with skeleton
224    let meta = std::fs::metadata(&p).ok();
225    let file_exists = meta.is_some();
226    let file_empty = meta.is_none_or(|m| m.len() == 0);
227
228    let mut lines: Vec<String> = if file_exists && !file_empty {
229        // File exists and has content - read it
230        fs::read_to_string(&p)
231            .map(|content| content.lines().map(ToString::to_string).collect())
232            .unwrap_or_default()
233    } else {
234        // File doesn't exist or is empty - start with skeleton
235        SETTINGS_SKELETON_CONTENT
236            .lines()
237            .map(ToString::to_string)
238            .collect()
239    };
240    let mut replaced = false;
241    for line in &mut lines {
242        let trimmed = line.trim();
243        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
244            continue;
245        }
246        if let Some(eq) = trimmed.find('=') {
247            let (kraw, _) = trimmed.split_at(eq);
248            let key = kraw.trim().to_lowercase().replace(['.', '-', ' '], "_");
249            if key == key_norm {
250                *line = format!("{key_norm} = {value}");
251                replaced = true;
252            }
253        }
254    }
255    if !replaced {
256        if let Some(dir) = p.parent() {
257            let _ = fs::create_dir_all(dir);
258        }
259        lines.push(format!("{key_norm} = {value}"));
260    }
261    let new_content = if lines.is_empty() {
262        format!("{key_norm} = {value}\n")
263    } else {
264        lines.join("\n")
265    };
266    let _ = fs::write(p, new_content);
267}
268
269/// What: Persist the visibility flag for the Search history pane.
270///
271/// Inputs:
272/// - `value`: Whether the Search history pane should be shown on startup.
273///
274/// Output:
275/// - None.
276///
277/// Details:
278/// - Writes to the canonical `show_search_history_pane` key while migrating legacy
279///   `show_recent_pane` entries.
280pub fn save_show_recent_pane(value: bool) {
281    save_boolean_key_with_aliases("show_search_history_pane", &["show_recent_pane"], value);
282}
283/// What: Persist the visibility flag for the Install pane.
284///
285/// Inputs:
286/// - `value`: Whether the Install pane should be shown on startup.
287///
288/// Output:
289/// - None.
290///
291/// Details:
292/// - Delegates to `save_boolean_key("show_install_pane", value)`.
293pub fn save_show_install_pane(value: bool) {
294    save_boolean_key("show_install_pane", value);
295}
296/// What: Persist the visibility flag for the keybinds footer.
297///
298/// Inputs:
299/// - `value`: Whether the footer should be rendered.
300///
301/// Output:
302/// - None.
303///
304/// Details:
305/// - Delegates to `save_boolean_key("show_keybinds_footer", value)`.
306pub fn save_show_keybinds_footer(value: bool) {
307    save_boolean_key("show_keybinds_footer", value);
308}
309
310/// What: Persist the comma-separated list of preferred mirror countries.
311///
312/// Inputs:
313/// - `value`: Country list string (already normalized by caller).
314///
315/// Output:
316/// - None.
317///
318/// Details:
319/// - Delegates to `save_string_key("selected_countries", ...)`.
320pub fn save_selected_countries(value: &str) {
321    save_string_key("selected_countries", value);
322}
323/// What: Persist the numeric limit on ranked mirrors.
324///
325/// Inputs:
326/// - `value`: Mirror count to record.
327///
328/// Output:
329/// - None.
330///
331/// Details:
332/// - Delegates to `save_string_key("mirror_count", value)` after converting to text.
333pub fn save_mirror_count(value: u16) {
334    save_string_key("mirror_count", &value.to_string());
335}
336
337/// Persist start mode (package/news).
338pub fn save_app_start_mode(start_in_news: bool) {
339    let v = if start_in_news { "news" } else { "package" };
340    save_string_key("app_start_mode", v);
341}
342
343/// Persist whether to show Arch news items.
344pub fn save_news_filter_show_arch_news(value: bool) {
345    save_boolean_key("news_filter_show_arch_news", value);
346}
347
348/// Persist whether to show security advisories.
349pub fn save_news_filter_show_advisories(value: bool) {
350    save_boolean_key("news_filter_show_advisories", value);
351}
352
353/// Persist whether to show installed package updates in the News view.
354pub fn save_news_filter_show_pkg_updates(value: bool) {
355    save_boolean_key("news_filter_show_pkg_updates", value);
356}
357
358/// Persist whether to show AUR package updates in the News view.
359pub fn save_news_filter_show_aur_updates(value: bool) {
360    save_boolean_key("news_filter_show_aur_updates", value);
361}
362
363/// Persist whether to show AUR comments in the News view.
364pub fn save_news_filter_show_aur_comments(value: bool) {
365    save_boolean_key("news_filter_show_aur_comments", value);
366}
367
368/// Persist whether to restrict advisories to installed packages.
369pub fn save_news_filter_installed_only(value: bool) {
370    save_boolean_key("news_filter_installed_only", value);
371}
372
373/// Persist whether news filters are collapsed behind the Filters button.
374pub fn save_news_filters_collapsed(value: bool) {
375    save_boolean_key("news_filters_collapsed", value);
376}
377
378/// Persist the maximum age of news items (None = all).
379pub fn save_news_max_age_days(value: Option<u32>) {
380    let v = value.map_or_else(|| "all".to_string(), |d| d.to_string());
381    save_string_key("news_max_age_days", &v);
382}
383
384/// Persist whether startup news popup setup has been completed.
385pub fn save_startup_news_configured(value: bool) {
386    save_boolean_key("startup_news_configured", value);
387}
388
389/// Persist whether to show Arch news in startup news popup.
390pub fn save_startup_news_show_arch_news(value: bool) {
391    save_boolean_key("startup_news_show_arch_news", value);
392}
393
394/// Persist whether to show security advisories in startup news popup.
395pub fn save_startup_news_show_advisories(value: bool) {
396    save_boolean_key("startup_news_show_advisories", value);
397}
398
399/// Persist whether to show AUR updates in startup news popup.
400pub fn save_startup_news_show_aur_updates(value: bool) {
401    save_boolean_key("startup_news_show_aur_updates", value);
402}
403
404/// Persist whether to show AUR comments in startup news popup.
405pub fn save_startup_news_show_aur_comments(value: bool) {
406    save_boolean_key("startup_news_show_aur_comments", value);
407}
408
409/// Persist whether to show official package updates in startup news popup.
410pub fn save_startup_news_show_pkg_updates(value: bool) {
411    save_boolean_key("startup_news_show_pkg_updates", value);
412}
413
414/// Persist the maximum age of news items for startup news popup (None = all).
415pub fn save_startup_news_max_age_days(value: Option<u32>) {
416    let v = value.map_or_else(|| "all".to_string(), |d| d.to_string());
417    save_string_key("startup_news_max_age_days", &v);
418}
419
420/// What: Persist the `VirusTotal` API key used for scanning packages.
421///
422/// Inputs:
423/// - `value`: API key string supplied by the user.
424///
425/// Output:
426/// - None.
427///
428/// Details:
429/// - Delegates to `save_string_key("virustotal_api_key", ...)`.
430pub fn save_virustotal_api_key(value: &str) {
431    save_string_key("virustotal_api_key", value);
432}
433
434/// What: Persist the `ClamAV` scan toggle.
435///
436/// Inputs:
437/// - `value`: Whether `ClamAV` scans should run by default.
438///
439/// Output:
440/// - None.
441///
442/// Details:
443/// - Delegates to `save_boolean_key("scan_do_clamav", value)`.
444pub fn save_scan_do_clamav(value: bool) {
445    save_boolean_key("scan_do_clamav", value);
446}
447/// What: Persist the Trivy scan toggle.
448///
449/// Inputs:
450/// - `value`: Whether Trivy scans should run by default.
451///
452/// Output:
453/// - None.
454///
455/// Details:
456/// - Delegates to `save_boolean_key("scan_do_trivy", value)`.
457pub fn save_scan_do_trivy(value: bool) {
458    save_boolean_key("scan_do_trivy", value);
459}
460/// What: Persist the Semgrep scan toggle.
461///
462/// Inputs:
463/// - `value`: Whether Semgrep scans should run by default.
464///
465/// Output:
466/// - None.
467///
468/// Details:
469/// - Delegates to `save_boolean_key("scan_do_semgrep", value)`.
470pub fn save_scan_do_semgrep(value: bool) {
471    save_boolean_key("scan_do_semgrep", value);
472}
473/// What: Persist the `ShellCheck` scan toggle.
474///
475/// Inputs:
476/// - `value`: Whether `ShellCheck` scans should run by default.
477///
478/// Output:
479/// - None.
480///
481/// Details:
482/// - Delegates to `save_boolean_key("scan_do_shellcheck", value)`.
483pub fn save_scan_do_shellcheck(value: bool) {
484    save_boolean_key("scan_do_shellcheck", value);
485}
486/// What: Persist the `VirusTotal` scan toggle.
487///
488/// Inputs:
489/// - `value`: Whether `VirusTotal` scans should run by default.
490///
491/// Output:
492/// - None.
493///
494/// Details:
495/// - Delegates to `save_boolean_key("scan_do_virustotal", value)`.
496pub fn save_scan_do_virustotal(value: bool) {
497    save_boolean_key("scan_do_virustotal", value);
498}
499/// What: Persist the custom scan toggle.
500///
501/// Inputs:
502/// - `value`: Whether user-defined custom scans should run by default.
503///
504/// Output:
505/// - None.
506///
507/// Details:
508/// - Delegates to `save_boolean_key("scan_do_custom", value)`.
509pub fn save_scan_do_custom(value: bool) {
510    save_boolean_key("scan_do_custom", value);
511}
512
513/// What: Persist the Sleuth scan toggle.
514///
515/// Inputs:
516/// - `value`: Whether Sleuth scans should run by default.
517///
518/// Output:
519/// - None.
520///
521/// Details:
522/// - Delegates to `save_boolean_key("scan_do_sleuth", value)`.
523pub fn save_scan_do_sleuth(value: bool) {
524    save_boolean_key("scan_do_sleuth", value);
525}
526
527/// What: Persist the fuzzy search toggle.
528///
529/// Inputs:
530/// - `value`: Whether fuzzy search should be enabled.
531///
532/// Output:
533/// - None.
534///
535/// Details:
536/// - Delegates to `save_boolean_key("fuzzy_search", value)`.
537pub fn save_fuzzy_search(value: bool) {
538    save_boolean_key("fuzzy_search", value);
539}