pacsea 0.8.2

A fast, friendly TUI for browsing and installing Arch and AUR packages with built-in news and security scanning
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
use std::fs;
use std::path::Path;

use crate::theme::config::skeletons::SETTINGS_SKELETON_CONTENT;
use crate::theme::paths::resolve_settings_config_path;

/// What: Persist the user-selected sort mode into `settings.conf` (or legacy `pacsea.conf`).
///
/// Inputs:
/// - `sm`: Sort mode chosen in the UI, expressed as `crate::state::SortMode`.
///
/// Output:
/// - None.
///
/// Details:
/// - Ensures the target file exists by seeding from the skeleton when missing.
/// - Replaces existing `sort_mode`/`results_sort` entries while preserving comments.
pub fn save_sort_mode(sm: crate::state::SortMode) {
    let path = resolve_settings_config_path().or_else(|| {
        std::env::var("XDG_CONFIG_HOME")
            .ok()
            .map(std::path::PathBuf::from)
            .or_else(|| {
                std::env::var("HOME")
                    .ok()
                    .map(|h| Path::new(&h).join(".config"))
            })
            .map(|base| base.join("pacsea").join("settings.conf"))
    });
    let Some(p) = path else {
        return;
    };

    // Ensure directory exists
    if let Some(dir) = p.parent() {
        let _ = fs::create_dir_all(dir);
    }

    // If file doesn't exist or is empty, initialize with skeleton
    let meta = std::fs::metadata(&p).ok();
    let file_exists = meta.is_some();
    let file_empty = meta.is_none_or(|m| m.len() == 0);

    let mut lines: Vec<String> = if file_exists && !file_empty {
        // File exists and has content - read it
        fs::read_to_string(&p)
            .map(|content| content.lines().map(ToString::to_string).collect())
            .unwrap_or_default()
    } else {
        // File doesn't exist or is empty - start with skeleton
        SETTINGS_SKELETON_CONTENT
            .lines()
            .map(ToString::to_string)
            .collect()
    };
    let mut replaced = false;
    for line in &mut lines {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
            continue;
        }
        if let Some(eq) = trimmed.find('=') {
            let (kraw, _) = trimmed.split_at(eq);
            let key = kraw.trim().to_lowercase().replace(['.', '-', ' '], "_");
            if key == "sort_mode" || key == "results_sort" {
                *line = format!("sort_mode = {}", sm.as_config_key());
                replaced = true;
            }
        }
    }
    if !replaced {
        if let Some(dir) = p.parent() {
            let _ = fs::create_dir_all(dir);
        }
        lines.push(format!("sort_mode = {}", sm.as_config_key()));
    }
    let new_content = if lines.is_empty() {
        format!("sort_mode = {}\n", sm.as_config_key())
    } else {
        lines.join("\n")
    };
    let _ = fs::write(p, new_content);
}

/// What: Persist a single boolean toggle within `settings.conf` while preserving unrelated content.
///
/// Inputs:
/// - `primary_key`: Primary key name to update (lowercase, underscore-separated recommended).
/// - `aliases`: Optional aliases that should map to the same setting (legacy compatibility).
/// - `value`: Boolean flag to serialize as `true` or `false`.
///
/// Output:
/// - None.
///
/// Details:
/// - Creates the configuration file from the skeleton when it is missing or empty.
/// - Rewrites existing entries (including aliases) in place; otherwise appends the primary key.
/// - When an alias is encountered, it is replaced with the primary key to migrate configs forward.
fn save_boolean_key_with_aliases(primary_key: &str, aliases: &[&str], value: bool) {
    let path = resolve_settings_config_path().or_else(|| {
        std::env::var("XDG_CONFIG_HOME")
            .ok()
            .map(std::path::PathBuf::from)
            .or_else(|| {
                std::env::var("HOME")
                    .ok()
                    .map(|h| Path::new(&h).join(".config"))
            })
            .map(|base| base.join("pacsea").join("settings.conf"))
    });
    let Some(p) = path else {
        return;
    };

    // Ensure directory exists
    if let Some(dir) = p.parent() {
        let _ = fs::create_dir_all(dir);
    }

    // If file doesn't exist or is empty, initialize with skeleton
    let meta = std::fs::metadata(&p).ok();
    let file_exists = meta.is_some();
    let file_empty = meta.is_none_or(|m| m.len() == 0);

    let mut lines: Vec<String> = if file_exists && !file_empty {
        // File exists and has content - read it
        fs::read_to_string(&p)
            .map(|content| content.lines().map(ToString::to_string).collect())
            .unwrap_or_default()
    } else {
        // File doesn't exist or is empty - start with skeleton
        SETTINGS_SKELETON_CONTENT
            .lines()
            .map(ToString::to_string)
            .collect()
    };
    let bool_text = if value { "true" } else { "false" };
    let primary_norm = primary_key
        .trim()
        .to_lowercase()
        .replace(['.', '-', ' '], "_");
    let alias_norms: Vec<String> = aliases
        .iter()
        .map(|k| k.trim().to_lowercase().replace(['.', '-', ' '], "_"))
        .collect();
    let mut replaced = false;
    for line in &mut lines {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
            continue;
        }
        if let Some(eq) = trimmed.find('=') {
            let (kraw, _) = trimmed.split_at(eq);
            let key = kraw.trim().to_lowercase().replace(['.', '-', ' '], "_");
            if key == primary_norm || alias_norms.iter().any(|alias| alias == &key) {
                *line = format!("{primary_key} = {bool_text}");
                replaced = true;
            }
        }
    }
    if !replaced {
        if let Some(dir) = p.parent() {
            let _ = fs::create_dir_all(dir);
        }
        lines.push(format!("{primary_key} = {bool_text}"));
    }
    let new_content = if lines.is_empty() {
        format!("{primary_key} = {bool_text}\n")
    } else {
        lines.join("\n")
    };
    let _ = fs::write(p, new_content);
}

/// What: Persist a single boolean toggle within `settings.conf` while preserving unrelated content.
///
/// Inputs:
/// - `key_norm`: Normalized (lowercase, underscore-separated) key name to update.
/// - `value`: Boolean flag to serialize as `true` or `false`.
///
/// Output:
/// - None.
///
/// Details:
/// - Convenience wrapper that delegates to `save_boolean_key_with_aliases` without aliases.
fn save_boolean_key(key_norm: &str, value: bool) {
    save_boolean_key_with_aliases(key_norm, &[], value);
}

/// What: Persist a string-valued setting inside `settings.conf` without disturbing other keys.
///
/// Inputs:
/// - `key_norm`: Normalized key to update.
/// - `value`: String payload that should be written verbatim after trimming handled by the caller.
///
/// Output:
/// - None.
///
/// Details:
/// - Bootstraps the configuration file from the skeleton if necessary.
/// - Updates the existing key in place or appends a new line when absent.
fn save_string_key(key_norm: &str, value: &str) {
    let path = resolve_settings_config_path().or_else(|| {
        std::env::var("XDG_CONFIG_HOME")
            .ok()
            .map(std::path::PathBuf::from)
            .or_else(|| {
                std::env::var("HOME")
                    .ok()
                    .map(|h| Path::new(&h).join(".config"))
            })
            .map(|base| base.join("pacsea").join("settings.conf"))
    });
    let Some(p) = path else {
        return;
    };

    // Ensure directory exists
    if let Some(dir) = p.parent() {
        let _ = fs::create_dir_all(dir);
    }

    // If file doesn't exist or is empty, initialize with skeleton
    let meta = std::fs::metadata(&p).ok();
    let file_exists = meta.is_some();
    let file_empty = meta.is_none_or(|m| m.len() == 0);

    let mut lines: Vec<String> = if file_exists && !file_empty {
        // File exists and has content - read it
        fs::read_to_string(&p)
            .map(|content| content.lines().map(ToString::to_string).collect())
            .unwrap_or_default()
    } else {
        // File doesn't exist or is empty - start with skeleton
        SETTINGS_SKELETON_CONTENT
            .lines()
            .map(ToString::to_string)
            .collect()
    };
    let mut replaced = false;
    for line in &mut lines {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
            continue;
        }
        if let Some(eq) = trimmed.find('=') {
            let (kraw, _) = trimmed.split_at(eq);
            let key = kraw.trim().to_lowercase().replace(['.', '-', ' '], "_");
            if key == key_norm {
                *line = format!("{key_norm} = {value}");
                replaced = true;
            }
        }
    }
    if !replaced {
        if let Some(dir) = p.parent() {
            let _ = fs::create_dir_all(dir);
        }
        lines.push(format!("{key_norm} = {value}"));
    }
    let new_content = if lines.is_empty() {
        format!("{key_norm} = {value}\n")
    } else {
        lines.join("\n")
    };
    let _ = fs::write(p, new_content);
}

/// What: Persist the visibility flag for the Search history pane.
///
/// Inputs:
/// - `value`: Whether the Search history pane should be shown on startup.
///
/// Output:
/// - None.
///
/// Details:
/// - Writes to the canonical `show_search_history_pane` key while migrating legacy
///   `show_recent_pane` entries.
pub fn save_show_recent_pane(value: bool) {
    save_boolean_key_with_aliases("show_search_history_pane", &["show_recent_pane"], value);
}
/// What: Persist the visibility flag for the Install pane.
///
/// Inputs:
/// - `value`: Whether the Install pane should be shown on startup.
///
/// Output:
/// - None.
///
/// Details:
/// - Delegates to `save_boolean_key("show_install_pane", value)`.
pub fn save_show_install_pane(value: bool) {
    save_boolean_key("show_install_pane", value);
}
/// What: Persist the visibility flag for the keybinds footer.
///
/// Inputs:
/// - `value`: Whether the footer should be rendered.
///
/// Output:
/// - None.
///
/// Details:
/// - Delegates to `save_boolean_key("show_keybinds_footer", value)`.
pub fn save_show_keybinds_footer(value: bool) {
    save_boolean_key("show_keybinds_footer", value);
}

/// What: Persist the comma-separated list of preferred mirror countries.
///
/// Inputs:
/// - `value`: Country list string (already normalized by caller).
///
/// Output:
/// - None.
///
/// Details:
/// - Delegates to `save_string_key("selected_countries", ...)`.
pub fn save_selected_countries(value: &str) {
    save_string_key("selected_countries", value);
}
/// What: Persist the numeric limit on ranked mirrors.
///
/// Inputs:
/// - `value`: Mirror count to record.
///
/// Output:
/// - None.
///
/// Details:
/// - Delegates to `save_string_key("mirror_count", value)` after converting to text.
pub fn save_mirror_count(value: u16) {
    save_string_key("mirror_count", &value.to_string());
}

/// Persist start mode (package/news).
pub fn save_app_start_mode(start_in_news: bool) {
    let v = if start_in_news { "news" } else { "package" };
    save_string_key("app_start_mode", v);
}

/// Persist whether to show Arch news items.
pub fn save_news_filter_show_arch_news(value: bool) {
    save_boolean_key("news_filter_show_arch_news", value);
}

/// Persist whether to show security advisories.
pub fn save_news_filter_show_advisories(value: bool) {
    save_boolean_key("news_filter_show_advisories", value);
}

/// Persist whether to show installed package updates in the News view.
pub fn save_news_filter_show_pkg_updates(value: bool) {
    save_boolean_key("news_filter_show_pkg_updates", value);
}

/// Persist whether to show AUR package updates in the News view.
pub fn save_news_filter_show_aur_updates(value: bool) {
    save_boolean_key("news_filter_show_aur_updates", value);
}

/// Persist whether to show AUR comments in the News view.
pub fn save_news_filter_show_aur_comments(value: bool) {
    save_boolean_key("news_filter_show_aur_comments", value);
}

/// Persist whether to restrict advisories to installed packages.
pub fn save_news_filter_installed_only(value: bool) {
    save_boolean_key("news_filter_installed_only", value);
}

/// Persist whether news filters are collapsed behind the Filters button.
pub fn save_news_filters_collapsed(value: bool) {
    save_boolean_key("news_filters_collapsed", value);
}

/// Persist the maximum age of news items (None = all).
pub fn save_news_max_age_days(value: Option<u32>) {
    let v = value.map_or_else(|| "all".to_string(), |d| d.to_string());
    save_string_key("news_max_age_days", &v);
}

/// Persist whether startup news popup setup has been completed.
pub fn save_startup_news_configured(value: bool) {
    save_boolean_key("startup_news_configured", value);
}

/// Persist whether to show Arch news in startup news popup.
pub fn save_startup_news_show_arch_news(value: bool) {
    save_boolean_key("startup_news_show_arch_news", value);
}

/// Persist whether to show security advisories in startup news popup.
pub fn save_startup_news_show_advisories(value: bool) {
    save_boolean_key("startup_news_show_advisories", value);
}

/// Persist whether to show AUR updates in startup news popup.
pub fn save_startup_news_show_aur_updates(value: bool) {
    save_boolean_key("startup_news_show_aur_updates", value);
}

/// Persist whether to show AUR comments in startup news popup.
pub fn save_startup_news_show_aur_comments(value: bool) {
    save_boolean_key("startup_news_show_aur_comments", value);
}

/// Persist whether to show official package updates in startup news popup.
pub fn save_startup_news_show_pkg_updates(value: bool) {
    save_boolean_key("startup_news_show_pkg_updates", value);
}

/// Persist the maximum age of news items for startup news popup (None = all).
pub fn save_startup_news_max_age_days(value: Option<u32>) {
    let v = value.map_or_else(|| "all".to_string(), |d| d.to_string());
    save_string_key("startup_news_max_age_days", &v);
}

/// What: Persist the `VirusTotal` API key used for scanning packages.
///
/// Inputs:
/// - `value`: API key string supplied by the user.
///
/// Output:
/// - None.
///
/// Details:
/// - Delegates to `save_string_key("virustotal_api_key", ...)`.
pub fn save_virustotal_api_key(value: &str) {
    save_string_key("virustotal_api_key", value);
}

/// What: Persist the `ClamAV` scan toggle.
///
/// Inputs:
/// - `value`: Whether `ClamAV` scans should run by default.
///
/// Output:
/// - None.
///
/// Details:
/// - Delegates to `save_boolean_key("scan_do_clamav", value)`.
pub fn save_scan_do_clamav(value: bool) {
    save_boolean_key("scan_do_clamav", value);
}
/// What: Persist the Trivy scan toggle.
///
/// Inputs:
/// - `value`: Whether Trivy scans should run by default.
///
/// Output:
/// - None.
///
/// Details:
/// - Delegates to `save_boolean_key("scan_do_trivy", value)`.
pub fn save_scan_do_trivy(value: bool) {
    save_boolean_key("scan_do_trivy", value);
}
/// What: Persist the Semgrep scan toggle.
///
/// Inputs:
/// - `value`: Whether Semgrep scans should run by default.
///
/// Output:
/// - None.
///
/// Details:
/// - Delegates to `save_boolean_key("scan_do_semgrep", value)`.
pub fn save_scan_do_semgrep(value: bool) {
    save_boolean_key("scan_do_semgrep", value);
}
/// What: Persist the `ShellCheck` scan toggle.
///
/// Inputs:
/// - `value`: Whether `ShellCheck` scans should run by default.
///
/// Output:
/// - None.
///
/// Details:
/// - Delegates to `save_boolean_key("scan_do_shellcheck", value)`.
pub fn save_scan_do_shellcheck(value: bool) {
    save_boolean_key("scan_do_shellcheck", value);
}
/// What: Persist the `VirusTotal` scan toggle.
///
/// Inputs:
/// - `value`: Whether `VirusTotal` scans should run by default.
///
/// Output:
/// - None.
///
/// Details:
/// - Delegates to `save_boolean_key("scan_do_virustotal", value)`.
pub fn save_scan_do_virustotal(value: bool) {
    save_boolean_key("scan_do_virustotal", value);
}
/// What: Persist the custom scan toggle.
///
/// Inputs:
/// - `value`: Whether user-defined custom scans should run by default.
///
/// Output:
/// - None.
///
/// Details:
/// - Delegates to `save_boolean_key("scan_do_custom", value)`.
pub fn save_scan_do_custom(value: bool) {
    save_boolean_key("scan_do_custom", value);
}

/// What: Persist the Sleuth scan toggle.
///
/// Inputs:
/// - `value`: Whether Sleuth scans should run by default.
///
/// Output:
/// - None.
///
/// Details:
/// - Delegates to `save_boolean_key("scan_do_sleuth", value)`.
pub fn save_scan_do_sleuth(value: bool) {
    save_boolean_key("scan_do_sleuth", value);
}

/// What: Persist the fuzzy search toggle.
///
/// Inputs:
/// - `value`: Whether fuzzy search should be enabled.
///
/// Output:
/// - None.
///
/// Details:
/// - Delegates to `save_boolean_key("fuzzy_search", value)`.
pub fn save_fuzzy_search(value: bool) {
    save_boolean_key("fuzzy_search", value);
}

/// What: Persist `results_filter_show_<canonical>` for a dynamic repo filter id from `repos.conf`.
///
/// Inputs:
/// - `canonical_id`: Normalized filter token (`a-z`, `0-9`, `_` only).
/// - `value`: Whether matching packages should appear in Results.
///
/// Output:
/// - None.
///
/// Details:
/// - No-op when `canonical_id` is empty or contains characters outside the safe token alphabet.
/// - Writes the full key name `results_filter_show_<canonical_id>` into `settings.conf`.
pub fn save_results_filter_show_canonical(canonical_id: &str, value: bool) {
    let trimmed = canonical_id.trim();
    if trimmed.is_empty()
        || !trimmed
            .chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
    {
        return;
    }
    let key = format!("results_filter_show_{trimmed}");
    save_boolean_key_with_aliases(&key, &[], value);
}