Skip to main content

pacsea/theme/config/
schema.rs

1//! Static schema describing editable Pacsea config keys.
2//!
3//! What: Defines the value kinds, reload behavior, sensitivity, and aliases for
4//! every setting the integrated config editor is allowed to change. Phases 1+
5//! of `dev/IMPROVEMENTS/IMPLEMENTATION_PLAN_tui_integrated_config_editing.md`
6//! consume this schema to render typed rows and a harmonized edit popup.
7//!
8//! Phase 0 establishes the types and a representative subset of entries. Later
9//! phases extend [`EDITABLE_SETTINGS`] (and add `EDITABLE_KEYBINDS` /
10//! `EDITABLE_THEME` slices) without changing the consumer-facing API.
11
12use crate::theme::config::patch::ConfigFile;
13
14/// What kind of value a setting holds, used to pick the editor popup variant
15/// and to validate input before [`crate::theme::config::patch::patch_key`].
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum ValueKind {
18    /// `true` / `false`. Renders as a toggle.
19    Bool,
20    /// One of a fixed list of canonical strings (e.g. `sort_mode`,
21    /// `privilege_tool`). Editor renders a chooser.
22    Enum {
23        /// Allowed canonical values, in display order.
24        choices: &'static [&'static str],
25    },
26    /// Free-form string (typically a short identifier or label).
27    String,
28    /// Filesystem path. Editor allows browsing and validates the input is
29    /// non-empty (existence check is best-effort).
30    Path,
31    /// Secret string (e.g. API key). Editor masks the current value and
32    /// requires explicit "reveal" before display.
33    Secret,
34    /// Integer constrained to `[min, max]` inclusive.
35    IntRange {
36        /// Minimum allowed value.
37        min: i64,
38        /// Maximum allowed value.
39        max: i64,
40    },
41    /// Optional positive integer expressed as a decimal number or the literal
42    /// `"all"`. Used for keys like `news_max_age_days`.
43    OptionalUnsignedOrAll,
44    /// Hex (`#RRGGBB`) or `R,G,B` triplet. Used for theme colors.
45    Color,
46    /// Comma-separated ordered list of pane identifiers, e.g.
47    /// `package_info, search, results`. Validation lives next to the
48    /// `MainVerticalPane` parser.
49    MainPaneOrder,
50    /// Single keychord string (modifiers + key) understood by the keybind
51    /// parser. Used by `keybinds.conf` rows.
52    KeyChord,
53}
54
55/// How a saved value reaches the running app.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum ReloadBehavior {
58    /// Existing background file watchers (or per-event reloads) pick the
59    /// change up automatically; no extra action needed.
60    AutoReload,
61    /// Editor must call a specific in-process apply step after writing
62    /// (e.g. `theme::reload_theme`). Display in the editor as "applies
63    /// immediately".
64    AppliesOnSave,
65    /// Change becomes effective only after the next Pacsea launch. The
66    /// editor must show a "needs restart" hint.
67    RequiresRestart,
68}
69
70/// Whether the value should be redacted in UI/logs by default.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum Sensitivity {
73    /// Plain value, safe to render and log.
74    Normal,
75    /// Treat as a secret. The editor masks it in the bottom pane and never
76    /// echoes it into log lines.
77    Sensitive,
78}
79
80/// Schema entry for a single editable setting key.
81#[derive(Debug, Clone)]
82pub struct EditableSetting {
83    /// Canonical key name, written verbatim to the config file.
84    pub key: &'static str,
85    /// Deprecated names recognized on disk; rewritten to `key` on save.
86    pub aliases: &'static [&'static str],
87    /// Which config file owns this key.
88    pub file: ConfigFile,
89    /// Value kind / editor variant.
90    pub kind: ValueKind,
91    /// How the saved value reaches the running app.
92    pub reload: ReloadBehavior,
93    /// Whether the value should be redacted in display/logs by default.
94    pub sensitivity: Sensitivity,
95}
96
97impl EditableSetting {
98    /// What: Test whether `name` (raw or normalized) refers to this entry.
99    ///
100    /// Inputs:
101    /// - `name`: User-provided or on-disk key.
102    ///
103    /// Output:
104    /// - `true` if `name` matches `self.key` or any alias after normalization.
105    ///
106    /// Details:
107    /// - Normalization mirrors `theme::config::patch::patch_key`: lowercase and
108    ///   collapse `.`, `-`, ` ` into `_`.
109    #[must_use]
110    pub fn matches(&self, name: &str) -> bool {
111        let norm = normalize(name);
112        if normalize(self.key) == norm {
113            return true;
114        }
115        self.aliases.iter().any(|a| normalize(a) == norm)
116    }
117
118    /// What: Build the i18n key for this setting's translated label.
119    ///
120    /// Inputs: None.
121    ///
122    /// Output:
123    /// - Dot-notation i18n key under `app.modals.config_editor.settings.*`.
124    #[must_use]
125    pub fn label_i18n_key(&self) -> String {
126        format!("app.modals.config_editor.settings.{}.label", self.key)
127    }
128
129    /// What: Build the i18n key for this setting's translated summary.
130    ///
131    /// Inputs: None.
132    ///
133    /// Output:
134    /// - Dot-notation i18n key under `app.modals.config_editor.settings.*`.
135    #[must_use]
136    pub fn summary_i18n_key(&self) -> String {
137        format!("app.modals.config_editor.settings.{}.summary", self.key)
138    }
139}
140
141/// What: Normalize a key for case-/punctuation-insensitive comparison.
142///
143/// Inputs:
144/// - `s`: Raw key string.
145///
146/// Output:
147/// - Lowercased, underscore-normalized owned string.
148fn normalize(s: &str) -> String {
149    s.trim().to_lowercase().replace(['.', '-', ' '], "_")
150}
151
152/// What: Initial Phase-0 subset of editable settings.
153///
154/// Inputs:
155/// - None.
156///
157/// Output:
158/// - Static slice of [`EditableSetting`] entries.
159///
160/// Details:
161/// - Covers each [`ValueKind`] variant at least once so the editor's popup
162///   variants can be exercised end-to-end before Phase 1 fills in the rest.
163/// - Phase 1 extends this slice; consumers must treat it as append-only by
164///   `key` name.
165pub const EDITABLE_SETTINGS: &[EditableSetting] = &[
166    EditableSetting {
167        key: "sort_mode",
168        aliases: &["results_sort"],
169        file: ConfigFile::Settings,
170        kind: ValueKind::Enum {
171            choices: &[
172                "best_matches",
173                "alphabetical",
174                "official_first",
175                "aur_popularity",
176            ],
177        },
178        reload: ReloadBehavior::AppliesOnSave,
179        sensitivity: Sensitivity::Normal,
180    },
181    EditableSetting {
182        key: "show_install_pane",
183        aliases: &[],
184        file: ConfigFile::Settings,
185        kind: ValueKind::Bool,
186        reload: ReloadBehavior::AutoReload,
187        sensitivity: Sensitivity::Normal,
188    },
189    EditableSetting {
190        key: "show_search_history_pane",
191        aliases: &["show_recent_pane"],
192        file: ConfigFile::Settings,
193        kind: ValueKind::Bool,
194        reload: ReloadBehavior::AutoReload,
195        sensitivity: Sensitivity::Normal,
196    },
197    EditableSetting {
198        key: "mirror_count",
199        aliases: &[],
200        file: ConfigFile::Settings,
201        kind: ValueKind::IntRange { min: 1, max: 200 },
202        reload: ReloadBehavior::AppliesOnSave,
203        sensitivity: Sensitivity::Normal,
204    },
205    EditableSetting {
206        key: "news_max_age_days",
207        aliases: &[],
208        file: ConfigFile::Settings,
209        kind: ValueKind::OptionalUnsignedOrAll,
210        reload: ReloadBehavior::AppliesOnSave,
211        sensitivity: Sensitivity::Normal,
212    },
213    EditableSetting {
214        key: "clipboard_suffix",
215        aliases: &[],
216        file: ConfigFile::Settings,
217        kind: ValueKind::String,
218        reload: ReloadBehavior::AutoReload,
219        sensitivity: Sensitivity::Normal,
220    },
221    EditableSetting {
222        key: "preferred_terminal",
223        aliases: &[],
224        file: ConfigFile::Settings,
225        kind: ValueKind::String,
226        reload: ReloadBehavior::AppliesOnSave,
227        sensitivity: Sensitivity::Normal,
228    },
229    EditableSetting {
230        key: "privilege_mode",
231        aliases: &["privilege_tool", "priv_tool"],
232        file: ConfigFile::Settings,
233        kind: ValueKind::Enum {
234            choices: &["auto", "sudo", "doas"],
235        },
236        reload: ReloadBehavior::AppliesOnSave,
237        sensitivity: Sensitivity::Normal,
238    },
239    EditableSetting {
240        key: "main_pane_order",
241        aliases: &[],
242        file: ConfigFile::Settings,
243        kind: ValueKind::MainPaneOrder,
244        reload: ReloadBehavior::AppliesOnSave,
245        sensitivity: Sensitivity::Normal,
246    },
247    EditableSetting {
248        key: "virustotal_api_key",
249        aliases: &[],
250        file: ConfigFile::Settings,
251        kind: ValueKind::Secret,
252        reload: ReloadBehavior::AppliesOnSave,
253        sensitivity: Sensitivity::Sensitive,
254    },
255    // ── Layout ───────────────────────────────────────────────────────
256    EditableSetting {
257        key: "layout_left_pct",
258        aliases: &[],
259        file: ConfigFile::Settings,
260        kind: ValueKind::IntRange { min: 0, max: 100 },
261        reload: ReloadBehavior::AppliesOnSave,
262        sensitivity: Sensitivity::Normal,
263    },
264    EditableSetting {
265        key: "layout_center_pct",
266        aliases: &[],
267        file: ConfigFile::Settings,
268        kind: ValueKind::IntRange { min: 0, max: 100 },
269        reload: ReloadBehavior::AppliesOnSave,
270        sensitivity: Sensitivity::Normal,
271    },
272    EditableSetting {
273        key: "layout_right_pct",
274        aliases: &[],
275        file: ConfigFile::Settings,
276        kind: ValueKind::IntRange { min: 0, max: 100 },
277        reload: ReloadBehavior::AppliesOnSave,
278        sensitivity: Sensitivity::Normal,
279    },
280    // ── Vertical row limits ──────────────────────────────────────────
281    EditableSetting {
282        key: "vertical_min_results",
283        aliases: &[],
284        file: ConfigFile::Settings,
285        kind: ValueKind::IntRange { min: 1, max: 200 },
286        reload: ReloadBehavior::AppliesOnSave,
287        sensitivity: Sensitivity::Normal,
288    },
289    EditableSetting {
290        key: "vertical_max_results",
291        aliases: &[],
292        file: ConfigFile::Settings,
293        kind: ValueKind::IntRange { min: 1, max: 200 },
294        reload: ReloadBehavior::AppliesOnSave,
295        sensitivity: Sensitivity::Normal,
296    },
297    EditableSetting {
298        key: "vertical_min_middle",
299        aliases: &[],
300        file: ConfigFile::Settings,
301        kind: ValueKind::IntRange { min: 1, max: 200 },
302        reload: ReloadBehavior::AppliesOnSave,
303        sensitivity: Sensitivity::Normal,
304    },
305    EditableSetting {
306        key: "vertical_max_middle",
307        aliases: &[],
308        file: ConfigFile::Settings,
309        kind: ValueKind::IntRange { min: 1, max: 200 },
310        reload: ReloadBehavior::AppliesOnSave,
311        sensitivity: Sensitivity::Normal,
312    },
313    EditableSetting {
314        key: "vertical_min_package_info",
315        aliases: &[],
316        file: ConfigFile::Settings,
317        kind: ValueKind::IntRange { min: 1, max: 200 },
318        reload: ReloadBehavior::AppliesOnSave,
319        sensitivity: Sensitivity::Normal,
320    },
321    // ── Misc UI toggles ──────────────────────────────────────────────
322    EditableSetting {
323        key: "app_dry_run_default",
324        aliases: &[],
325        file: ConfigFile::Settings,
326        kind: ValueKind::Bool,
327        reload: ReloadBehavior::RequiresRestart,
328        sensitivity: Sensitivity::Normal,
329    },
330    EditableSetting {
331        key: "show_keybinds_footer",
332        aliases: &[],
333        file: ConfigFile::Settings,
334        kind: ValueKind::Bool,
335        reload: ReloadBehavior::AutoReload,
336        sensitivity: Sensitivity::Normal,
337    },
338    // ── Search behavior ──────────────────────────────────────────────
339    EditableSetting {
340        key: "search_startup_mode",
341        aliases: &[],
342        file: ConfigFile::Settings,
343        kind: ValueKind::Enum {
344            choices: &["insert_mode", "normal_mode"],
345        },
346        reload: ReloadBehavior::RequiresRestart,
347        sensitivity: Sensitivity::Normal,
348    },
349    EditableSetting {
350        key: "fuzzy_search",
351        aliases: &[],
352        file: ConfigFile::Settings,
353        kind: ValueKind::Bool,
354        reload: ReloadBehavior::AppliesOnSave,
355        sensitivity: Sensitivity::Normal,
356    },
357    EditableSetting {
358        key: "installed_packages_mode",
359        aliases: &[],
360        file: ConfigFile::Settings,
361        kind: ValueKind::Enum {
362            choices: &["leaf", "all"],
363        },
364        reload: ReloadBehavior::AppliesOnSave,
365        sensitivity: Sensitivity::Normal,
366    },
367    // ── Preflight / privilege ────────────────────────────────────────
368    EditableSetting {
369        key: "skip_preflight",
370        aliases: &[],
371        file: ConfigFile::Settings,
372        kind: ValueKind::Bool,
373        reload: ReloadBehavior::AppliesOnSave,
374        sensitivity: Sensitivity::Normal,
375    },
376    EditableSetting {
377        key: "use_passwordless_sudo",
378        aliases: &[],
379        file: ConfigFile::Settings,
380        kind: ValueKind::Bool,
381        reload: ReloadBehavior::AppliesOnSave,
382        sensitivity: Sensitivity::Normal,
383    },
384    EditableSetting {
385        key: "auth_mode",
386        aliases: &[],
387        file: ConfigFile::Settings,
388        kind: ValueKind::Enum {
389            choices: &["prompt", "passwordless_only", "interactive"],
390        },
391        reload: ReloadBehavior::AppliesOnSave,
392        sensitivity: Sensitivity::Normal,
393    },
394    // ── Mirrors ──────────────────────────────────────────────────────
395    EditableSetting {
396        key: "selected_countries",
397        aliases: &[],
398        file: ConfigFile::Settings,
399        kind: ValueKind::String,
400        reload: ReloadBehavior::AppliesOnSave,
401        sensitivity: Sensitivity::Normal,
402    },
403    // ── Scan defaults ────────────────────────────────────────────────
404    EditableSetting {
405        key: "scan_do_clamav",
406        aliases: &[],
407        file: ConfigFile::Settings,
408        kind: ValueKind::Bool,
409        reload: ReloadBehavior::AutoReload,
410        sensitivity: Sensitivity::Normal,
411    },
412    EditableSetting {
413        key: "scan_do_trivy",
414        aliases: &[],
415        file: ConfigFile::Settings,
416        kind: ValueKind::Bool,
417        reload: ReloadBehavior::AutoReload,
418        sensitivity: Sensitivity::Normal,
419    },
420    EditableSetting {
421        key: "scan_do_semgrep",
422        aliases: &[],
423        file: ConfigFile::Settings,
424        kind: ValueKind::Bool,
425        reload: ReloadBehavior::AutoReload,
426        sensitivity: Sensitivity::Normal,
427    },
428    EditableSetting {
429        key: "scan_do_shellcheck",
430        aliases: &[],
431        file: ConfigFile::Settings,
432        kind: ValueKind::Bool,
433        reload: ReloadBehavior::AutoReload,
434        sensitivity: Sensitivity::Normal,
435    },
436    EditableSetting {
437        key: "scan_do_virustotal",
438        aliases: &[],
439        file: ConfigFile::Settings,
440        kind: ValueKind::Bool,
441        reload: ReloadBehavior::AutoReload,
442        sensitivity: Sensitivity::Normal,
443    },
444    EditableSetting {
445        key: "scan_do_custom",
446        aliases: &[],
447        file: ConfigFile::Settings,
448        kind: ValueKind::Bool,
449        reload: ReloadBehavior::AutoReload,
450        sensitivity: Sensitivity::Normal,
451    },
452    EditableSetting {
453        key: "scan_do_sleuth",
454        aliases: &[],
455        file: ConfigFile::Settings,
456        kind: ValueKind::Bool,
457        reload: ReloadBehavior::AutoReload,
458        sensitivity: Sensitivity::Normal,
459    },
460    // ── PKGBUILD checks ──────────────────────────────────────────────
461    EditableSetting {
462        key: "pkgbuild_shellcheck_exclude",
463        aliases: &[],
464        file: ConfigFile::Settings,
465        kind: ValueKind::String,
466        reload: ReloadBehavior::AppliesOnSave,
467        sensitivity: Sensitivity::Normal,
468    },
469    EditableSetting {
470        key: "pkgbuild_checks_show_raw_output",
471        aliases: &[],
472        file: ConfigFile::Settings,
473        kind: ValueKind::Bool,
474        reload: ReloadBehavior::AutoReload,
475        sensitivity: Sensitivity::Normal,
476    },
477    // ── News symbols / filters ───────────────────────────────────────
478    EditableSetting {
479        key: "news_read_symbol",
480        aliases: &[],
481        file: ConfigFile::Settings,
482        kind: ValueKind::String,
483        reload: ReloadBehavior::AutoReload,
484        sensitivity: Sensitivity::Normal,
485    },
486    EditableSetting {
487        key: "news_unread_symbol",
488        aliases: &[],
489        file: ConfigFile::Settings,
490        kind: ValueKind::String,
491        reload: ReloadBehavior::AutoReload,
492        sensitivity: Sensitivity::Normal,
493    },
494    EditableSetting {
495        key: "news_filter_show_arch_news",
496        aliases: &[],
497        file: ConfigFile::Settings,
498        kind: ValueKind::Bool,
499        reload: ReloadBehavior::AppliesOnSave,
500        sensitivity: Sensitivity::Normal,
501    },
502    EditableSetting {
503        key: "news_filter_show_advisories",
504        aliases: &[],
505        file: ConfigFile::Settings,
506        kind: ValueKind::Bool,
507        reload: ReloadBehavior::AppliesOnSave,
508        sensitivity: Sensitivity::Normal,
509    },
510    EditableSetting {
511        key: "news_filter_show_pkg_updates",
512        aliases: &[],
513        file: ConfigFile::Settings,
514        kind: ValueKind::Bool,
515        reload: ReloadBehavior::AppliesOnSave,
516        sensitivity: Sensitivity::Normal,
517    },
518    EditableSetting {
519        key: "news_filter_show_aur_updates",
520        aliases: &[],
521        file: ConfigFile::Settings,
522        kind: ValueKind::Bool,
523        reload: ReloadBehavior::AppliesOnSave,
524        sensitivity: Sensitivity::Normal,
525    },
526    EditableSetting {
527        key: "news_filter_show_aur_comments",
528        aliases: &[],
529        file: ConfigFile::Settings,
530        kind: ValueKind::Bool,
531        reload: ReloadBehavior::AppliesOnSave,
532        sensitivity: Sensitivity::Normal,
533    },
534    EditableSetting {
535        key: "news_filter_installed_only",
536        aliases: &[],
537        file: ConfigFile::Settings,
538        kind: ValueKind::Bool,
539        reload: ReloadBehavior::AppliesOnSave,
540        sensitivity: Sensitivity::Normal,
541    },
542    // ── Startup news popup ───────────────────────────────────────────
543    EditableSetting {
544        key: "startup_news_configured",
545        aliases: &[],
546        file: ConfigFile::Settings,
547        kind: ValueKind::Bool,
548        reload: ReloadBehavior::AppliesOnSave,
549        sensitivity: Sensitivity::Normal,
550    },
551    EditableSetting {
552        key: "startup_news_show_arch_news",
553        aliases: &[],
554        file: ConfigFile::Settings,
555        kind: ValueKind::Bool,
556        reload: ReloadBehavior::AppliesOnSave,
557        sensitivity: Sensitivity::Normal,
558    },
559    EditableSetting {
560        key: "startup_news_show_advisories",
561        aliases: &[],
562        file: ConfigFile::Settings,
563        kind: ValueKind::Bool,
564        reload: ReloadBehavior::AppliesOnSave,
565        sensitivity: Sensitivity::Normal,
566    },
567    EditableSetting {
568        key: "startup_news_show_aur_updates",
569        aliases: &[],
570        file: ConfigFile::Settings,
571        kind: ValueKind::Bool,
572        reload: ReloadBehavior::AppliesOnSave,
573        sensitivity: Sensitivity::Normal,
574    },
575    EditableSetting {
576        key: "startup_news_show_aur_comments",
577        aliases: &[],
578        file: ConfigFile::Settings,
579        kind: ValueKind::Bool,
580        reload: ReloadBehavior::AppliesOnSave,
581        sensitivity: Sensitivity::Normal,
582    },
583    EditableSetting {
584        key: "startup_news_show_pkg_updates",
585        aliases: &[],
586        file: ConfigFile::Settings,
587        kind: ValueKind::Bool,
588        reload: ReloadBehavior::AppliesOnSave,
589        sensitivity: Sensitivity::Normal,
590    },
591    EditableSetting {
592        key: "startup_news_max_age_days",
593        aliases: &[],
594        file: ConfigFile::Settings,
595        kind: ValueKind::OptionalUnsignedOrAll,
596        reload: ReloadBehavior::AppliesOnSave,
597        sensitivity: Sensitivity::Normal,
598    },
599    // ── Misc ─────────────────────────────────────────────────────────
600    EditableSetting {
601        key: "package_marker",
602        aliases: &[],
603        file: ConfigFile::Settings,
604        kind: ValueKind::Enum {
605            choices: &["full_line", "front", "end"],
606        },
607        reload: ReloadBehavior::AutoReload,
608        sensitivity: Sensitivity::Normal,
609    },
610    EditableSetting {
611        key: "locale",
612        aliases: &[],
613        file: ConfigFile::Settings,
614        kind: ValueKind::String,
615        reload: ReloadBehavior::RequiresRestart,
616        sensitivity: Sensitivity::Normal,
617    },
618    EditableSetting {
619        key: "updates_refresh_interval",
620        aliases: &[],
621        file: ConfigFile::Settings,
622        kind: ValueKind::IntRange { min: 1, max: 86400 },
623        reload: ReloadBehavior::AppliesOnSave,
624        sensitivity: Sensitivity::Normal,
625    },
626    EditableSetting {
627        key: "use_terminal_theme",
628        aliases: &[],
629        file: ConfigFile::Settings,
630        kind: ValueKind::Bool,
631        reload: ReloadBehavior::RequiresRestart,
632        sensitivity: Sensitivity::Normal,
633    },
634    // ── AUR voting ───────────────────────────────────────────────────
635    EditableSetting {
636        key: "aur_vote_enabled",
637        aliases: &[],
638        file: ConfigFile::Settings,
639        kind: ValueKind::Bool,
640        reload: ReloadBehavior::AutoReload,
641        sensitivity: Sensitivity::Normal,
642    },
643    EditableSetting {
644        key: "aur_vote_ssh_timeout_seconds",
645        aliases: &[],
646        file: ConfigFile::Settings,
647        kind: ValueKind::IntRange { min: 1, max: 600 },
648        reload: ReloadBehavior::AutoReload,
649        sensitivity: Sensitivity::Normal,
650    },
651    EditableSetting {
652        key: "aur_vote_ssh_command",
653        aliases: &[],
654        file: ConfigFile::Settings,
655        kind: ValueKind::String,
656        reload: ReloadBehavior::AutoReload,
657        sensitivity: Sensitivity::Normal,
658    },
659];
660
661/// What: Phase-2 set of editable keybind rows backed by `keybinds.conf`.
662///
663/// Inputs:
664/// - None.
665///
666/// Output:
667/// - Static slice of [`EditableSetting`] entries with `file = ConfigFile::Keybinds`.
668///
669/// Details:
670/// - Canonical key names match the parser in `theme::settings::parse_keybinds`.
671/// - Aliases mirror the alias sets the parser already accepts so existing
672///   user-edited files migrate to canonical names on save.
673/// - All entries reload via `AppliesOnSave` because [`crate::theme::settings`]
674///   reparses `keybinds.conf` and `apply_settings_to_app_state` copies
675///   `prefs.keymap` into `app.keymap` after each save.
676pub const EDITABLE_KEYBINDS: &[EditableSetting] = &[
677    // ── Global ───────────────────────────────────────────────────────
678    keybind_entry("keybind_help", &["keybind_help_overlay"]),
679    keybind_entry(
680        "keybind_toggle_config",
681        &["keybind_config_menu", "keybind_config_lists"],
682    ),
683    keybind_entry("keybind_toggle_options", &["keybind_options_menu"]),
684    keybind_entry("keybind_toggle_panels", &["keybind_panels_menu"]),
685    keybind_entry(
686        "keybind_reload_config",
687        &["keybind_reload_theme", "keybind_reload"],
688    ),
689    keybind_entry("keybind_exit", &["keybind_quit"]),
690    keybind_entry(
691        "keybind_show_pkgbuild",
692        &["keybind_pkgbuild", "keybind_toggle_pkgbuild"],
693    ),
694    keybind_entry(
695        "keybind_comments_toggle",
696        &["keybind_show_comments", "keybind_toggle_comments"],
697    ),
698    keybind_entry(
699        "keybind_run_pkgbuild_checks",
700        &["keybind_pkgbuild_checks", "keybind_toggle_pkgbuild_checks"],
701    ),
702    keybind_entry(
703        "keybind_cycle_pkgbuild_sections",
704        &[
705            "keybind_pkgbuild_section_cycle",
706            "keybind_pkgbuild_next_section",
707        ],
708    ),
709    keybind_entry("keybind_change_sort", &["keybind_sort"]),
710    keybind_entry(
711        "keybind_pane_next",
712        &["keybind_next_pane", "keybind_switch_pane"],
713    ),
714    keybind_entry("keybind_pane_left", &[]),
715    keybind_entry("keybind_pane_right", &[]),
716    keybind_entry("keybind_toggle_fuzzy", &["keybind_fuzzy_toggle"]),
717    // ── Search pane ──────────────────────────────────────────────────
718    keybind_entry("keybind_search_move_up", &[]),
719    keybind_entry("keybind_search_move_down", &[]),
720    keybind_entry("keybind_search_page_up", &[]),
721    keybind_entry("keybind_search_page_down", &[]),
722    keybind_entry("keybind_search_add", &[]),
723    keybind_entry("keybind_search_install", &[]),
724    keybind_entry("keybind_search_focus_left", &[]),
725    keybind_entry("keybind_search_focus_right", &[]),
726    keybind_entry("keybind_search_backspace", &[]),
727    keybind_entry("keybind_search_insert_clear", &[]),
728    // ── Search normal mode ───────────────────────────────────────────
729    keybind_entry("keybind_search_normal_toggle", &[]),
730    keybind_entry("keybind_search_normal_insert", &[]),
731    keybind_entry("keybind_search_normal_select_left", &[]),
732    keybind_entry("keybind_search_normal_select_right", &[]),
733    keybind_entry("keybind_search_normal_delete", &[]),
734    keybind_entry("keybind_search_normal_clear", &[]),
735    keybind_entry(
736        "keybind_search_normal_open_status",
737        &["keybind_normal_open_status", "keybind_open_status"],
738    ),
739    keybind_entry("keybind_search_normal_import", &[]),
740    keybind_entry("keybind_search_normal_export", &[]),
741    keybind_entry("keybind_search_normal_updates", &[]),
742    // ── Recent pane ──────────────────────────────────────────────────
743    keybind_entry("keybind_recent_move_up", &[]),
744    keybind_entry("keybind_recent_move_down", &[]),
745    keybind_entry("keybind_recent_find", &[]),
746    keybind_entry("keybind_recent_use", &[]),
747    keybind_entry("keybind_recent_add", &[]),
748    keybind_entry("keybind_recent_to_search", &[]),
749    keybind_entry("keybind_recent_focus_right", &[]),
750    keybind_entry("keybind_recent_remove", &[]),
751    keybind_entry("keybind_recent_clear", &[]),
752    // ── Install pane ─────────────────────────────────────────────────
753    keybind_entry("keybind_install_move_up", &[]),
754    keybind_entry("keybind_install_move_down", &[]),
755    keybind_entry("keybind_install_confirm", &[]),
756    keybind_entry("keybind_install_remove", &[]),
757    keybind_entry("keybind_install_clear", &[]),
758    keybind_entry("keybind_install_find", &[]),
759    keybind_entry("keybind_install_to_search", &[]),
760    keybind_entry("keybind_install_focus_left", &[]),
761    // ── News modal ───────────────────────────────────────────────────
762    keybind_entry("keybind_news_mark_read", &[]),
763    keybind_entry("keybind_news_mark_all_read", &[]),
764    keybind_entry("keybind_news_feed_mark_read", &[]),
765    keybind_entry("keybind_news_feed_mark_unread", &[]),
766    keybind_entry("keybind_news_feed_toggle_read", &[]),
767];
768
769/// What: Phase-3 set of editable theme color rows backed by `theme.conf`.
770///
771/// Inputs:
772/// - None.
773///
774/// Output:
775/// - Static slice of [`EditableSetting`] entries with `file = ConfigFile::Theme`.
776///
777/// Details:
778/// - Keys use the preferred user-facing names written by the shipped
779///   `config/theme.conf`; aliases mirror the canonical/legacy spellings
780///   accepted by `theme::parsing::canonical_for_key` so existing files
781///   migrate to preferred names on save.
782/// - Saves are validated as a whole file via
783///   [`crate::theme::config::theme_loader::try_load_theme_from_content`]
784///   before committing, then applied live through `theme::reload_theme`.
785pub const EDITABLE_THEME: &[EditableSetting] = &[
786    theme_entry("background_base", &["base", "background"]),
787    theme_entry("background_mantle", &["mantle"]),
788    theme_entry("background_crust", &["crust"]),
789    theme_entry("surface_level1", &["surface1", "surface_1"]),
790    theme_entry("surface_level2", &["surface2", "surface_2"]),
791    theme_entry("overlay_primary", &["overlay1", "border_primary"]),
792    theme_entry("overlay_secondary", &["overlay2", "border_secondary"]),
793    theme_entry("text_primary", &["text"]),
794    theme_entry("text_secondary", &["subtext0"]),
795    theme_entry("text_tertiary", &["subtext1"]),
796    theme_entry("accent_interactive", &["sapphire", "accent_info"]),
797    theme_entry("accent_heading", &["mauve", "accent_primary"]),
798    theme_entry("semantic_success", &["green"]),
799    theme_entry("semantic_warning", &["yellow"]),
800    theme_entry("semantic_error", &["red"]),
801    theme_entry("accent_emphasis", &["lavender", "accent_border"]),
802];
803
804/// What: Construct an [`EditableSetting`] row for a theme color key.
805///
806/// Inputs:
807/// - `key`: Preferred user-facing key written to `theme.conf`.
808/// - `aliases`: Canonical/legacy spellings recognized on disk and rewritten on save.
809///
810/// Output:
811/// - `EditableSetting` with `file = ConfigFile::Theme`, `kind = ValueKind::Color`,
812///   and `reload = AppliesOnSave` (the editor re-runs `reload_theme` after a write).
813const fn theme_entry(key: &'static str, aliases: &'static [&'static str]) -> EditableSetting {
814    EditableSetting {
815        key,
816        aliases,
817        file: ConfigFile::Theme,
818        kind: ValueKind::Color,
819        reload: ReloadBehavior::AppliesOnSave,
820        sensitivity: Sensitivity::Normal,
821    }
822}
823
824/// What: Construct an [`EditableSetting`] row for a keybind action.
825///
826/// Inputs:
827/// - `key`: Canonical action key matching the parser in
828///   `theme::settings::parse_keybinds`.
829/// - `aliases`: Alternate names recognized on disk and rewritten on save.
830///
831/// Output:
832/// - `EditableSetting` with `file = ConfigFile::Keybinds`,
833///   `kind = ValueKind::KeyChord`, and `reload = AppliesOnSave`.
834const fn keybind_entry(key: &'static str, aliases: &'static [&'static str]) -> EditableSetting {
835    EditableSetting {
836        key,
837        aliases,
838        file: ConfigFile::Keybinds,
839        kind: ValueKind::KeyChord,
840        reload: ReloadBehavior::AppliesOnSave,
841        sensitivity: Sensitivity::Normal,
842    }
843}
844
845/// What: Look up an editable setting by canonical key or alias.
846///
847/// Inputs:
848/// - `name`: Canonical key or any alias (case-/punctuation-insensitive).
849///
850/// Output:
851/// - `Some(&EditableSetting)` on match, `None` otherwise.
852#[must_use]
853pub fn find_setting(name: &str) -> Option<&'static EditableSetting> {
854    EDITABLE_SETTINGS
855        .iter()
856        .chain(EDITABLE_KEYBINDS.iter())
857        .chain(EDITABLE_THEME.iter())
858        .find(|s| s.matches(name))
859}
860
861/// What: Return all editable settings registered for `file`.
862///
863/// Inputs:
864/// - `file`: Config file kind.
865///
866/// Output:
867/// - Vector of references in declaration order.
868#[must_use]
869pub fn settings_for(file: ConfigFile) -> Vec<&'static EditableSetting> {
870    EDITABLE_SETTINGS
871        .iter()
872        .chain(EDITABLE_KEYBINDS.iter())
873        .chain(EDITABLE_THEME.iter())
874        .filter(|s| s.file == file)
875        .collect()
876}
877
878/// What: Determine the conflict-detection scope for a keybind action.
879///
880/// Inputs:
881/// - `key`: Canonical keybind action key.
882///
883/// Output:
884/// - Static label identifying the scope (`global`, `search`, `search_normal`,
885///   `recent`, `install`, or `news`).
886///
887/// Details:
888/// - Two bindings collide only when they share both a chord and this scope.
889/// - Same-chord rebinds across scopes (e.g. `Left` for `pane_left` and
890///   `search_focus_left`) are considered intentional and do not conflict.
891#[must_use]
892pub fn keybind_scope(key: &str) -> &'static str {
893    if key.starts_with("keybind_search_normal_") {
894        "search_normal"
895    } else if key.starts_with("keybind_search_") {
896        "search"
897    } else if key.starts_with("keybind_recent_") {
898        "recent"
899    } else if key.starts_with("keybind_install_") {
900        "install"
901    } else if key.starts_with("keybind_news_") {
902        "news"
903    } else {
904        "global"
905    }
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911
912    #[test]
913    fn keys_are_unique_after_normalization() {
914        let mut seen = std::collections::HashSet::new();
915        for entry in EDITABLE_SETTINGS {
916            let norm = normalize(entry.key);
917            assert!(
918                seen.insert(norm.clone()),
919                "duplicate key in EDITABLE_SETTINGS: {norm}"
920            );
921        }
922    }
923
924    #[test]
925    fn aliases_resolve_to_primary() {
926        let s = find_setting("show_recent_pane").expect("alias should resolve");
927        assert_eq!(s.key, "show_search_history_pane");
928
929        let s = find_setting("Results.Sort").expect("alias is normalized");
930        assert_eq!(s.key, "sort_mode");
931    }
932
933    #[test]
934    fn enum_choices_are_non_empty() {
935        for entry in EDITABLE_SETTINGS {
936            if let ValueKind::Enum { choices } = entry.kind {
937                assert!(
938                    !choices.is_empty(),
939                    "{} declares Enum without choices",
940                    entry.key
941                );
942            }
943        }
944    }
945
946    #[test]
947    fn int_range_is_well_formed() {
948        for entry in EDITABLE_SETTINGS {
949            if let ValueKind::IntRange { min, max } = entry.kind {
950                assert!(
951                    min <= max,
952                    "{} declares inverted IntRange: {min}..{max}",
953                    entry.key
954                );
955            }
956        }
957    }
958
959    #[test]
960    fn settings_for_filters_by_file() {
961        let only_settings = settings_for(ConfigFile::Settings);
962        assert!(!only_settings.is_empty());
963        for s in only_settings {
964            assert_eq!(s.file, ConfigFile::Settings);
965        }
966        // Phase 2 ships keybind rows; Phase 3 ships theme rows; repos stays empty.
967        let only_keybinds = settings_for(ConfigFile::Keybinds);
968        assert!(!only_keybinds.is_empty());
969        for s in only_keybinds {
970            assert_eq!(s.file, ConfigFile::Keybinds);
971            assert!(matches!(s.kind, ValueKind::KeyChord));
972        }
973        let only_theme = settings_for(ConfigFile::Theme);
974        assert_eq!(only_theme.len(), 16, "one row per required canonical color");
975        for s in only_theme {
976            assert_eq!(s.file, ConfigFile::Theme);
977            assert!(matches!(s.kind, ValueKind::Color));
978        }
979        assert!(settings_for(ConfigFile::Repos).is_empty());
980    }
981
982    #[test]
983    fn theme_keys_are_unique_and_aliases_resolve() {
984        let mut seen = std::collections::HashSet::new();
985        for entry in EDITABLE_THEME {
986            let norm = normalize(entry.key);
987            assert!(
988                seen.insert(norm.clone()),
989                "duplicate key in EDITABLE_THEME: {norm}"
990            );
991        }
992        let s = find_setting("base").expect("canonical alias should resolve");
993        assert_eq!(s.key, "background_base");
994        let s = find_setting("mauve").expect("canonical alias should resolve");
995        assert_eq!(s.key, "accent_heading");
996    }
997
998    #[test]
999    fn keybind_keys_are_unique_after_normalization() {
1000        let mut seen = std::collections::HashSet::new();
1001        for entry in EDITABLE_KEYBINDS {
1002            let norm = normalize(entry.key);
1003            assert!(
1004                seen.insert(norm.clone()),
1005                "duplicate key in EDITABLE_KEYBINDS: {norm}"
1006            );
1007        }
1008    }
1009
1010    #[test]
1011    fn keybind_aliases_resolve_to_primary() {
1012        let s = find_setting("keybind_help_overlay").expect("alias should resolve");
1013        assert_eq!(s.key, "keybind_help");
1014        let s = find_setting("keybind_open_status").expect("alias should resolve");
1015        assert_eq!(s.key, "keybind_search_normal_open_status");
1016    }
1017
1018    #[test]
1019    fn keybind_scope_groups_by_prefix() {
1020        assert_eq!(keybind_scope("keybind_help"), "global");
1021        assert_eq!(keybind_scope("keybind_pane_next"), "global");
1022        assert_eq!(keybind_scope("keybind_search_move_up"), "search");
1023        assert_eq!(
1024            keybind_scope("keybind_search_normal_toggle"),
1025            "search_normal"
1026        );
1027        assert_eq!(keybind_scope("keybind_recent_remove"), "recent");
1028        assert_eq!(keybind_scope("keybind_install_remove"), "install");
1029        assert_eq!(keybind_scope("keybind_news_mark_read"), "news");
1030    }
1031}