Skip to main content

pacsea/state/
config_editor.rs

1//! State for the integrated TUI config editor (Phase 1+).
2//!
3//! What: Lives in `state` so both the renderer (`ui::modals::config_editor`)
4//! and the key handler (`events::modals::config_editor`) can read and mutate
5//! it without re-deriving fuzzy-match results or popup buffers per frame.
6//!
7//! The editor reuses Pacsea's three-pane mental model:
8//! - Top pane: file list (default) or matching-keys list (when a query is
9//!   typed, or when a file has been selected and the key list is shown).
10//! - Middle pane: the search query input.
11//! - Bottom pane: details for the selected key (current value, summary,
12//!   reload behavior).
13//!
14//! `settings.conf`, `keybinds.conf`, and `theme.conf` are editable end-to-end;
15//! `repos.conf` appears in the file list as a disabled row so users can see
16//! the roadmap without being able to open it yet.
17
18use crate::theme::{
19    ConfigFile, EditableSetting, KeyChord, KeyMap, ValueKind, find_setting, settings_for,
20};
21use crossterm::event::{KeyCode, KeyModifiers};
22use std::{fs, path::Path, path::PathBuf, time::Instant};
23
24/// Max number of stored config-editor recent search queries.
25const MAX_CONFIG_EDITOR_RECENT: usize = 50;
26
27/// Which top-pane view is active in the config editor.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ConfigEditorView {
30    /// List the four config files; `settings.conf`, `keybinds.conf`, and
31    /// `theme.conf` are selectable, `repos.conf` is a "coming soon" row.
32    FileList,
33    /// List editable keys for the currently selected file. When `query` is
34    /// non-empty, the list is filtered (substring/fuzzy on label and key).
35    KeyList,
36}
37
38/// Which control inside the editor currently consumes typed characters.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum ConfigEditorFocus {
41    /// Top-pane list (file list or key list) navigation.
42    List,
43    /// Middle-pane search input.
44    Search,
45}
46
47/// Which sub-panel inside the config-editor search pane is active.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum ConfigEditorSearchFocus {
50    /// The central text input.
51    Input,
52    /// Recent search list (left).
53    Recent,
54    /// Bookmarked keys list (right).
55    Bookmarks,
56}
57
58/// Editable popup variant.
59///
60/// Mirrors the schema's [`ValueKind`] but only for the kinds the editor
61/// supports interactively. Kinds without a dedicated control open as
62/// [`EditPopupKind::Text`] (free-form text); `keybinds.conf` rows open as
63/// [`EditPopupKind::KeyChord`] with optional chord capture.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum EditPopupKind {
66    /// `true` / `false` toggle.
67    Bool(bool),
68    /// Cycle through a fixed list of canonical strings.
69    Enum {
70        /// Allowed canonical values.
71        choices: Vec<String>,
72        /// Currently selected index into `choices`.
73        index: usize,
74    },
75    /// Bounded integer; the typed buffer is parsed on save.
76    Int {
77        /// Inclusive lower bound.
78        min: i64,
79        /// Inclusive upper bound.
80        max: i64,
81    },
82    /// Free-form text input (also covers `Path`, `MainPaneOrder`,
83    /// `Color`, and `OptionalUnsignedOrAll` in Phase 1).
84    Text,
85    /// Sensitive text input; rendered masked unless `revealed` is true.
86    Secret {
87        /// Whether the user has toggled "reveal" for this edit session.
88        revealed: bool,
89    },
90    /// Key-chord input for `keybinds.conf` rows. Supports free-form text
91    /// editing plus a dedicated capture mode (`Ctrl+K`) that records the
92    /// next pressed chord verbatim.
93    KeyChord {
94        /// When `true`, the next key event is swallowed and serialized into
95        /// the buffer instead of being interpreted as popup input.
96        capturing: bool,
97    },
98}
99
100/// State for the harmonized edit popup.
101#[derive(Debug, Clone)]
102pub struct EditPopupState {
103    /// Schema entry being edited. Carries key, file, kind, sensitivity.
104    pub setting: &'static EditableSetting,
105    /// Type-specific control state.
106    pub kind: EditPopupKind,
107    /// Text buffer used by Int/Text/Secret variants. For Bool/Enum it is
108    /// kept as the canonical string of the current selection so that
109    /// `Ctrl+S` always has a single value to write.
110    pub buffer: String,
111    /// Caret position (byte index) inside `buffer` for Int/Text/Secret.
112    pub caret: usize,
113    /// Canonical value the popup opened with; `Ctrl+Z` resets back to it.
114    pub original: String,
115    /// Target file's modification time when the popup opened. Used to warn
116    /// when the file changed on disk while the popup was editing.
117    pub opened_mtime: Option<std::time::SystemTime>,
118    /// Set after the first save attempt hit an mtime mismatch; the next
119    /// save proceeds anyway (explicit user confirmation via repeat `Ctrl+S`).
120    pub stale_overwrite_armed: bool,
121}
122
123impl EditPopupState {
124    /// What: Build a popup for `setting` initialized from `current` on-disk
125    /// (or in-memory) value.
126    ///
127    /// Inputs:
128    /// - `setting`: Schema row to edit.
129    /// - `current`: Current canonical string value, or the effective
130    ///   default if the file did not have the key.
131    ///
132    /// Output:
133    /// - Popup state ready for the renderer/handler.
134    ///
135    /// Details:
136    /// - For `Bool`, parses `true`/`yes`/`on`/`1` as `true` (everything
137    ///   else as `false`).
138    /// - For `Enum`, snaps to `choices[0]` if `current` is not in the list.
139    /// - For `IntRange`, clamps the parsed integer into the schema range.
140    /// - For `Secret`, starts unrevealed; `current` is loaded into the
141    ///   buffer so saving without typing keeps the existing value, but
142    ///   the renderer masks it.
143    #[must_use]
144    pub fn from_current(setting: &'static EditableSetting, current: &str) -> Self {
145        let trimmed = current.trim();
146        let (kind, buffer, caret) = match setting.kind {
147            ValueKind::Bool => {
148                let b = matches!(
149                    trimmed.to_ascii_lowercase().as_str(),
150                    "true" | "yes" | "on" | "1"
151                );
152                (EditPopupKind::Bool(b), bool_to_canonical(b).to_string(), 0)
153            }
154            ValueKind::Enum { choices } => {
155                let owned: Vec<String> = choices.iter().map(|s| (*s).to_string()).collect();
156                let index = owned.iter().position(|c| c == trimmed).unwrap_or(0);
157                let buffer = owned.get(index).cloned().unwrap_or_default();
158                (
159                    EditPopupKind::Enum {
160                        choices: owned,
161                        index,
162                    },
163                    buffer,
164                    0,
165                )
166            }
167            ValueKind::IntRange { min, max } => {
168                let parsed = trimmed.parse::<i64>().unwrap_or(min).clamp(min, max);
169                let buffer = parsed.to_string();
170                let caret = buffer.len();
171                (EditPopupKind::Int { min, max }, buffer, caret)
172            }
173            ValueKind::Secret => (
174                EditPopupKind::Secret { revealed: false },
175                trimmed.to_string(),
176                trimmed.len(),
177            ),
178            ValueKind::KeyChord => (
179                EditPopupKind::KeyChord { capturing: false },
180                trimmed.to_string(),
181                trimmed.len(),
182            ),
183            ValueKind::String
184            | ValueKind::Path
185            | ValueKind::OptionalUnsignedOrAll
186            | ValueKind::Color
187            | ValueKind::MainPaneOrder => (EditPopupKind::Text, trimmed.to_string(), trimmed.len()),
188        };
189        Self {
190            setting,
191            kind,
192            buffer,
193            caret,
194            original: trimmed.to_string(),
195            opened_mtime: None,
196            stale_overwrite_armed: false,
197        }
198    }
199
200    /// What: Compute the canonical string the editor should write for this
201    /// popup if the user invoked `Ctrl+S` right now.
202    ///
203    /// Inputs: None.
204    ///
205    /// Output:
206    /// - The on-disk representation; for Bool/Enum this is the selected
207    ///   choice; otherwise it is the trimmed buffer.
208    ///
209    /// Details:
210    /// - The handler is responsible for additional validation (Int range,
211    ///   `OptionalUnsignedOrAll` parsing). This method only normalizes.
212    #[must_use]
213    pub fn canonical_value(&self) -> String {
214        match &self.kind {
215            EditPopupKind::Bool(b) => bool_to_canonical(*b).to_string(),
216            EditPopupKind::Enum { choices, index } => {
217                choices.get(*index).cloned().unwrap_or_default()
218            }
219            EditPopupKind::Int { .. }
220            | EditPopupKind::Text
221            | EditPopupKind::Secret { .. }
222            | EditPopupKind::KeyChord { .. } => self.buffer.trim().to_string(),
223        }
224    }
225}
226
227/// Canonical on-disk representation for a boolean.
228#[must_use]
229pub const fn bool_to_canonical(b: bool) -> &'static str {
230    if b { "true" } else { "false" }
231}
232
233/// Top-level editor state held by [`crate::state::AppState::config_editor_state`].
234#[derive(Debug, Clone)]
235pub struct ConfigEditorState {
236    /// Currently selected config file (Phase 1: only Settings is functional).
237    pub selected_file: ConfigFile,
238    /// Active top-pane view.
239    pub view: ConfigEditorView,
240    /// Where typed characters land.
241    pub focus: ConfigEditorFocus,
242    /// Cursor in the file-list view (`0..=3`).
243    pub file_cursor: usize,
244    /// Cursor in the key-list view, indexing into the filtered list.
245    pub key_cursor: usize,
246    /// Search query text (substring/fuzzy filter applied to keys).
247    pub query: String,
248    /// Caret position inside `query`.
249    pub query_caret: usize,
250    /// Which sub-panel in the search pane currently has focus.
251    pub search_focus: ConfigEditorSearchFocus,
252    /// Most-recent-first search history for the config editor.
253    pub recent_queries: Vec<String>,
254    /// Path where config-editor recent searches are persisted.
255    pub recent_queries_path: PathBuf,
256    /// Dirty flag indicating recent queries should be flushed to disk.
257    pub recent_queries_dirty: bool,
258    /// Last time the query text changed from user input.
259    pub query_last_input_change: Instant,
260    /// Last query value saved by debounce logic to avoid duplicate writes.
261    pub last_saved_query_value: Option<String>,
262    /// Cursor in `recent_queries`.
263    pub recent_cursor: usize,
264    /// Bookmarked setting keys (canonical `EditableSetting::key`).
265    pub bookmarked_keys: Vec<String>,
266    /// Path where config-editor bookmarks are persisted.
267    pub bookmarked_keys_path: PathBuf,
268    /// Dirty flag indicating bookmarks should be flushed to disk.
269    pub bookmarked_keys_dirty: bool,
270    /// Cursor in `bookmarked_keys`.
271    pub bookmark_cursor: usize,
272    /// Active edit popup, if any.
273    pub popup: Option<EditPopupState>,
274    /// Last save outcome / hint, shown in the footer.
275    pub status: Option<String>,
276}
277
278impl Default for ConfigEditorState {
279    fn default() -> Self {
280        let lists_dir = crate::theme::lists_dir();
281        let recent_queries_path = lists_dir.join("config_editor_recent_searches.json");
282        let bookmarked_keys_path = lists_dir.join("config_editor_bookmarks.json");
283        let recent_queries =
284            load_string_list_with_limit(&recent_queries_path, MAX_CONFIG_EDITOR_RECENT);
285        let bookmarked_keys = load_string_list_with_limit(&bookmarked_keys_path, usize::MAX);
286        Self {
287            selected_file: ConfigFile::Settings,
288            view: ConfigEditorView::FileList,
289            // Match package mode ergonomics: open with search focused so the
290            // cursor is immediately visible in the middle pane.
291            focus: ConfigEditorFocus::Search,
292            file_cursor: 0,
293            key_cursor: 0,
294            query: String::new(),
295            query_caret: 0,
296            search_focus: ConfigEditorSearchFocus::Input,
297            recent_queries,
298            recent_queries_path,
299            recent_queries_dirty: false,
300            query_last_input_change: Instant::now(),
301            last_saved_query_value: None,
302            recent_cursor: 0,
303            bookmarked_keys,
304            bookmarked_keys_path,
305            bookmarked_keys_dirty: false,
306            bookmark_cursor: 0,
307            popup: None,
308            status: None,
309        }
310    }
311}
312
313/// What: Load a JSON string-list from disk and normalize it for in-app use.
314///
315/// Inputs:
316/// - `path`: JSON file path expected to contain `Vec<String>`.
317/// - `max_len`: Maximum number of entries to keep.
318///
319/// Output:
320/// - Cleaned list preserving original order, trimmed, deduplicated, and clamped.
321///
322/// Details:
323/// - Returns an empty list if file read/parse fails.
324/// - Deduplication keeps the first occurrence and drops subsequent duplicates.
325fn load_string_list_with_limit(path: &Path, max_len: usize) -> Vec<String> {
326    let Ok(raw) = fs::read_to_string(path) else {
327        return Vec::new();
328    };
329    let Ok(list) = serde_json::from_str::<Vec<String>>(&raw) else {
330        return Vec::new();
331    };
332    let mut out = Vec::new();
333    for entry in list {
334        let trimmed = entry.trim();
335        if trimmed.is_empty() {
336            continue;
337        }
338        if out.iter().any(|v: &String| v == trimmed) {
339            continue;
340        }
341        out.push(trimmed.to_string());
342        if out.len() >= max_len {
343            break;
344        }
345    }
346    out
347}
348
349impl ConfigEditorState {
350    /// What: Build the filtered key list for the currently selected file
351    /// and active query.
352    ///
353    /// Inputs: None (uses `self.selected_file` and `self.query`).
354    ///
355    /// Output:
356    /// - Vector of schema entries that match the query (or all entries if
357    ///   `query` is empty), in declaration order.
358    ///
359    /// Details:
360    /// - Phase 1 uses a case-insensitive substring match against the
361    ///   canonical key and aliases via `EditableSetting::matches`.
362    #[must_use]
363    pub fn filtered_keys(&self) -> Vec<&'static EditableSetting> {
364        let all = settings_for(self.selected_file);
365        if self.query.trim().is_empty() {
366            return all;
367        }
368        let needle = self.query.trim().to_ascii_lowercase();
369        all.into_iter()
370            .filter(|s| {
371                s.key.to_ascii_lowercase().contains(&needle)
372                    || s.aliases
373                        .iter()
374                        .any(|alias| alias.to_ascii_lowercase().contains(&needle))
375            })
376            .collect()
377    }
378
379    /// What: Look up the schema entry currently highlighted in the key list.
380    ///
381    /// Inputs: None.
382    ///
383    /// Output:
384    /// - `Some(&EditableSetting)` when the cursor lands on a row, `None`
385    ///   when the filter is empty (no matches).
386    #[must_use]
387    pub fn selected_key(&self) -> Option<&'static EditableSetting> {
388        let keys = self.filtered_keys();
389        keys.get(self.key_cursor).copied()
390    }
391
392    /// What: Clamp `key_cursor` to the current filtered list length.
393    ///
394    /// Inputs: None.
395    /// Output: Mutates `self.key_cursor`.
396    /// Details: Called after query edits or after rebuilding the key list.
397    pub fn clamp_key_cursor(&mut self) {
398        let len = self.filtered_keys().len();
399        if len == 0 {
400            self.key_cursor = 0;
401        } else if self.key_cursor >= len {
402            self.key_cursor = len - 1;
403        }
404    }
405
406    /// What: Push a query into most-recent-first history with de-duplication.
407    ///
408    /// Inputs:
409    /// - `query`: Raw search query.
410    ///
411    /// Output:
412    /// - Mutates `recent_queries` and `recent_cursor`.
413    pub fn push_recent_query(&mut self, query: &str) {
414        let trimmed = query.trim();
415        if trimmed.is_empty() {
416            return;
417        }
418        self.recent_queries.retain(|q| q != trimmed);
419        self.recent_queries.insert(0, trimmed.to_string());
420        if self.recent_queries.len() > MAX_CONFIG_EDITOR_RECENT {
421            self.recent_queries.truncate(MAX_CONFIG_EDITOR_RECENT);
422        }
423        self.recent_cursor = 0;
424        self.last_saved_query_value = Some(trimmed.to_string());
425        self.recent_queries_dirty = true;
426    }
427
428    /// Toggle bookmark for a setting key.
429    pub fn toggle_bookmark_key(&mut self, key: &str) -> bool {
430        if let Some(idx) = self.bookmarked_keys.iter().position(|k| k == key) {
431            self.bookmarked_keys.remove(idx);
432            self.clamp_bookmark_cursor();
433            self.bookmarked_keys_dirty = true;
434            return false;
435        }
436        self.bookmarked_keys.push(key.to_string());
437        self.clamp_bookmark_cursor();
438        self.bookmarked_keys_dirty = true;
439        true
440    }
441
442    /// Clamp recent cursor to bounds.
443    #[allow(clippy::missing_const_for_fn)]
444    pub fn clamp_recent_cursor(&mut self) {
445        let len = self.recent_queries.len();
446        if len == 0 {
447            self.recent_cursor = 0;
448        } else if self.recent_cursor >= len {
449            self.recent_cursor = len - 1;
450        }
451    }
452
453    /// Clamp bookmark cursor to bounds.
454    #[allow(clippy::missing_const_for_fn)]
455    pub fn clamp_bookmark_cursor(&mut self) {
456        let len = self.bookmarked_keys.len();
457        if len == 0 {
458            self.bookmark_cursor = 0;
459        } else if self.bookmark_cursor >= len {
460            self.bookmark_cursor = len - 1;
461        }
462    }
463
464    /// Resolve the currently selected recent query.
465    #[must_use]
466    pub fn selected_recent_query(&self) -> Option<&str> {
467        self.recent_queries
468            .get(self.recent_cursor)
469            .map(String::as_str)
470    }
471
472    /// Resolve the currently selected bookmarked setting key.
473    #[must_use]
474    pub fn selected_bookmarked_key(&self) -> Option<&str> {
475        self.bookmarked_keys
476            .get(self.bookmark_cursor)
477            .map(String::as_str)
478    }
479}
480
481/// Convenience wrapper used by tests and helpers.
482#[must_use]
483pub fn lookup_setting(name: &str) -> Option<&'static EditableSetting> {
484    find_setting(name)
485}
486
487/// What: Read the current value for `entry` from `Settings::settings()`,
488/// returning the canonical string the popup should pre-populate.
489///
490/// Inputs:
491/// - `entry`: Schema row to look up.
492///
493/// Output:
494/// - On-disk-equivalent string. For unsupported keys (Phase 2/3 entries),
495///   returns an empty string so the popup falls back to free-form text.
496///
497/// Details:
498/// - Reads from a fresh [`crate::theme::settings`] snapshot to avoid
499///   divergence between in-memory state and external edits.
500/// - Best-effort: Phase 1 surfaces a small set of well-known keys; other
501///   schema rows render as empty until later phases.
502#[must_use]
503#[allow(clippy::too_many_lines)]
504pub fn current_value_string(entry: &EditableSetting) -> String {
505    let s = crate::theme::settings();
506    match entry.key {
507        // Phase 1 originals
508        "sort_mode" => s.sort_mode.as_config_key().to_string(),
509        "show_install_pane" => bool_to_canonical(s.show_install_pane).to_string(),
510        "show_search_history_pane" => bool_to_canonical(s.show_recent_pane).to_string(),
511        "mirror_count" => s.mirror_count.to_string(),
512        "news_max_age_days" => s
513            .news_max_age_days
514            .map_or_else(|| "all".to_string(), |n| n.to_string()),
515        "clipboard_suffix" => s.clipboard_suffix,
516        "preferred_terminal" => s.preferred_terminal,
517        "privilege_mode" => s.privilege_mode.as_config_key().to_string(),
518        "main_pane_order" => crate::state::format_main_pane_order(&s.main_pane_order),
519        "virustotal_api_key" => s.virustotal_api_key,
520
521        // Layout
522        "layout_left_pct" => s.layout_left_pct.to_string(),
523        "layout_center_pct" => s.layout_center_pct.to_string(),
524        "layout_right_pct" => s.layout_right_pct.to_string(),
525
526        // Vertical row limits
527        "vertical_min_results" => s.vertical_min_results.to_string(),
528        "vertical_max_results" => s.vertical_max_results.to_string(),
529        "vertical_min_middle" => s.vertical_min_middle.to_string(),
530        "vertical_max_middle" => s.vertical_max_middle.to_string(),
531        "vertical_min_package_info" => s.vertical_min_package_info.to_string(),
532
533        // Misc UI toggles
534        "app_dry_run_default" => bool_to_canonical(s.app_dry_run_default).to_string(),
535        "show_keybinds_footer" => bool_to_canonical(s.show_keybinds_footer).to_string(),
536
537        // Search behavior
538        "search_startup_mode" => {
539            if s.search_startup_mode {
540                "insert_mode".to_string()
541            } else {
542                "normal_mode".to_string()
543            }
544        }
545        "fuzzy_search" => bool_to_canonical(s.fuzzy_search).to_string(),
546        "installed_packages_mode" => s.installed_packages_mode.as_config_key().to_string(),
547
548        // Preflight / privilege
549        "skip_preflight" => bool_to_canonical(s.skip_preflight).to_string(),
550        "use_passwordless_sudo" => bool_to_canonical(s.use_passwordless_sudo).to_string(),
551        "auth_mode" => s.auth_mode.as_config_key().to_string(),
552
553        // Mirrors
554        "selected_countries" => s.selected_countries,
555
556        // Scan defaults
557        "scan_do_clamav" => bool_to_canonical(s.scan_do_clamav).to_string(),
558        "scan_do_trivy" => bool_to_canonical(s.scan_do_trivy).to_string(),
559        "scan_do_semgrep" => bool_to_canonical(s.scan_do_semgrep).to_string(),
560        "scan_do_shellcheck" => bool_to_canonical(s.scan_do_shellcheck).to_string(),
561        "scan_do_virustotal" => bool_to_canonical(s.scan_do_virustotal).to_string(),
562        "scan_do_custom" => bool_to_canonical(s.scan_do_custom).to_string(),
563        "scan_do_sleuth" => bool_to_canonical(s.scan_do_sleuth).to_string(),
564
565        // PKGBUILD checks
566        "pkgbuild_shellcheck_exclude" => s.pkgbuild_shellcheck_exclude,
567        "pkgbuild_checks_show_raw_output" => {
568            bool_to_canonical(s.pkgbuild_checks_show_raw_output).to_string()
569        }
570
571        // News symbols / filters
572        "news_read_symbol" => s.news_read_symbol,
573        "news_unread_symbol" => s.news_unread_symbol,
574        "news_filter_show_arch_news" => bool_to_canonical(s.news_filter_show_arch_news).to_string(),
575        "news_filter_show_advisories" => {
576            bool_to_canonical(s.news_filter_show_advisories).to_string()
577        }
578        "news_filter_show_pkg_updates" => {
579            bool_to_canonical(s.news_filter_show_pkg_updates).to_string()
580        }
581        "news_filter_show_aur_updates" => {
582            bool_to_canonical(s.news_filter_show_aur_updates).to_string()
583        }
584        "news_filter_show_aur_comments" => {
585            bool_to_canonical(s.news_filter_show_aur_comments).to_string()
586        }
587        "news_filter_installed_only" => bool_to_canonical(s.news_filter_installed_only).to_string(),
588
589        // Startup news popup
590        "startup_news_configured" => bool_to_canonical(s.startup_news_configured).to_string(),
591        "startup_news_show_arch_news" => {
592            bool_to_canonical(s.startup_news_show_arch_news).to_string()
593        }
594        "startup_news_show_advisories" => {
595            bool_to_canonical(s.startup_news_show_advisories).to_string()
596        }
597        "startup_news_show_aur_updates" => {
598            bool_to_canonical(s.startup_news_show_aur_updates).to_string()
599        }
600        "startup_news_show_aur_comments" => {
601            bool_to_canonical(s.startup_news_show_aur_comments).to_string()
602        }
603        "startup_news_show_pkg_updates" => {
604            bool_to_canonical(s.startup_news_show_pkg_updates).to_string()
605        }
606        "startup_news_max_age_days" => s
607            .startup_news_max_age_days
608            .map_or_else(|| "all".to_string(), |n| n.to_string()),
609
610        // Misc
611        "package_marker" => match s.package_marker {
612            crate::theme::PackageMarker::FullLine => "full_line".to_string(),
613            crate::theme::PackageMarker::Front => "front".to_string(),
614            crate::theme::PackageMarker::End => "end".to_string(),
615        },
616        "locale" => s.locale,
617        "updates_refresh_interval" => s.updates_refresh_interval.to_string(),
618        "use_terminal_theme" => bool_to_canonical(s.use_terminal_theme).to_string(),
619
620        // AUR voting
621        "aur_vote_enabled" => bool_to_canonical(s.aur_vote_enabled).to_string(),
622        "aur_vote_ssh_timeout_seconds" => s.aur_vote_ssh_timeout_seconds.to_string(),
623        "aur_vote_ssh_command" => s.aur_vote_ssh_command,
624
625        key if key.starts_with("keybind_") => keybind_chords_for_key(key, &s.keymap)
626            .first()
627            .map(chord_to_canonical_string)
628            .unwrap_or_default(),
629
630        key => theme_color_value_string(key),
631    }
632}
633
634/// What: Look up the effective in-memory color for a theme schema key.
635///
636/// Inputs:
637/// - `key`: Preferred theme key from [`crate::theme::EDITABLE_THEME`]
638///   (e.g. `background_base`).
639///
640/// Output:
641/// - Config-file color literal (`#rrggbb`), or an empty string when the key
642///   is not a theme color or the color has no RGB representation.
643///
644/// Details:
645/// - Reads the live [`crate::theme::theme`] snapshot, so values reflect the
646///   effective palette (including terminal-derived themes) rather than raw
647///   file content.
648#[must_use]
649pub fn theme_color_value_string(key: &str) -> String {
650    let th = crate::theme::theme();
651    let color = match key {
652        "background_base" => th.base,
653        "background_mantle" => th.mantle,
654        "background_crust" => th.crust,
655        "surface_level1" => th.surface1,
656        "surface_level2" => th.surface2,
657        "overlay_primary" => th.overlay1,
658        "overlay_secondary" => th.overlay2,
659        "text_primary" => th.text,
660        "text_secondary" => th.subtext0,
661        "text_tertiary" => th.subtext1,
662        "accent_interactive" => th.sapphire,
663        "accent_heading" => th.mauve,
664        "semantic_success" => th.green,
665        "semantic_warning" => th.yellow,
666        "semantic_error" => th.red,
667        "accent_emphasis" => th.lavender,
668        _ => return String::new(),
669    };
670    color_to_config_string(color)
671}
672
673/// What: Serialize a ratatui color into the `#rrggbb` form accepted by
674/// `theme.conf` parsing.
675///
676/// Inputs:
677/// - `color`: Color from the active [`crate::theme::Theme`].
678///
679/// Output:
680/// - Lowercase hex literal for RGB colors; empty string for palette/named
681///   variants that have no fixed RGB value.
682#[must_use]
683pub fn color_to_config_string(color: ratatui::style::Color) -> String {
684    match color {
685        ratatui::style::Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
686        _ => String::new(),
687    }
688}
689
690/// What: Look up the in-memory chord list for a canonical keybind action key.
691///
692/// Inputs:
693/// - `key`: Canonical keybind name from [`crate::theme::EDITABLE_KEYBINDS`].
694/// - `keymap`: Current keymap snapshot.
695///
696/// Output:
697/// - Slice of chords currently bound to the action; empty when the action is
698///   not recognized (treated as unbound).
699///
700/// Details:
701/// - Mirrors the dispatch in `theme::settings::parse_keybinds::apply_keybind`
702///   so editor read/write paths stay in sync with the parser.
703#[must_use]
704#[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
705pub fn keybind_chords_for_key<'a>(key: &str, keymap: &'a KeyMap) -> &'a [KeyChord] {
706    match key {
707        "keybind_help" => &keymap.help_overlay,
708        "keybind_toggle_config" => &keymap.config_menu_toggle,
709        "keybind_toggle_options" => &keymap.options_menu_toggle,
710        "keybind_toggle_panels" => &keymap.panels_menu_toggle,
711        "keybind_reload_config" => &keymap.reload_config,
712        "keybind_exit" => &keymap.exit,
713        "keybind_show_pkgbuild" => &keymap.show_pkgbuild,
714        "keybind_comments_toggle" => &keymap.comments_toggle,
715        "keybind_run_pkgbuild_checks" => &keymap.run_pkgbuild_checks,
716        "keybind_cycle_pkgbuild_sections" => &keymap.cycle_pkgbuild_sections,
717        "keybind_change_sort" => &keymap.change_sort,
718        "keybind_pane_next" => &keymap.pane_next,
719        "keybind_pane_left" => &keymap.pane_left,
720        "keybind_pane_right" => &keymap.pane_right,
721        "keybind_toggle_fuzzy" => &keymap.toggle_fuzzy,
722        "keybind_search_move_up" => &keymap.search_move_up,
723        "keybind_search_move_down" => &keymap.search_move_down,
724        "keybind_search_page_up" => &keymap.search_page_up,
725        "keybind_search_page_down" => &keymap.search_page_down,
726        "keybind_search_add" => &keymap.search_add,
727        "keybind_search_install" => &keymap.search_install,
728        "keybind_search_focus_left" => &keymap.search_focus_left,
729        "keybind_search_focus_right" => &keymap.search_focus_right,
730        "keybind_search_backspace" => &keymap.search_backspace,
731        "keybind_search_insert_clear" => &keymap.search_insert_clear,
732        "keybind_search_normal_toggle" => &keymap.search_normal_toggle,
733        "keybind_search_normal_insert" => &keymap.search_normal_insert,
734        "keybind_search_normal_select_left" => &keymap.search_normal_select_left,
735        "keybind_search_normal_select_right" => &keymap.search_normal_select_right,
736        "keybind_search_normal_delete" => &keymap.search_normal_delete,
737        "keybind_search_normal_clear" => &keymap.search_normal_clear,
738        "keybind_search_normal_open_status" => &keymap.search_normal_open_status,
739        "keybind_search_normal_import" => &keymap.search_normal_import,
740        "keybind_search_normal_export" => &keymap.search_normal_export,
741        "keybind_search_normal_updates" => &keymap.search_normal_updates,
742        "keybind_recent_move_up" => &keymap.recent_move_up,
743        "keybind_recent_move_down" => &keymap.recent_move_down,
744        "keybind_recent_find" => &keymap.recent_find,
745        "keybind_recent_use" => &keymap.recent_use,
746        "keybind_recent_add" => &keymap.recent_add,
747        "keybind_recent_to_search" => &keymap.recent_to_search,
748        "keybind_recent_focus_right" => &keymap.recent_focus_right,
749        "keybind_recent_remove" => &keymap.recent_remove,
750        "keybind_recent_clear" => &keymap.recent_clear,
751        "keybind_install_move_up" => &keymap.install_move_up,
752        "keybind_install_move_down" => &keymap.install_move_down,
753        "keybind_install_confirm" => &keymap.install_confirm,
754        "keybind_install_remove" => &keymap.install_remove,
755        "keybind_install_clear" => &keymap.install_clear,
756        "keybind_install_find" => &keymap.install_find,
757        "keybind_install_to_search" => &keymap.install_to_search,
758        "keybind_install_focus_left" => &keymap.install_focus_left,
759        "keybind_news_mark_read" => &keymap.news_mark_read,
760        "keybind_news_mark_all_read" => &keymap.news_mark_all_read,
761        "keybind_news_feed_mark_read" => &keymap.news_mark_read_feed,
762        "keybind_news_feed_mark_unread" => &keymap.news_mark_unread_feed,
763        "keybind_news_feed_toggle_read" => &keymap.news_toggle_read_feed,
764        _ => &[],
765    }
766}
767
768/// What: Serialize a [`KeyChord`] into the canonical string format
769/// understood by `theme::parsing::parse_key_chord`.
770///
771/// Inputs:
772/// - `chord`: Chord to serialize.
773///
774/// Output:
775/// - String such as `"Ctrl+R"`, `"Shift+Tab"`, `"F5"`, `"Up"`, or `"Space"`.
776///
777/// Details:
778/// - `KeyChord::label()` formats arrow keys as Unicode glyphs which the parser
779///   does not accept; this helper outputs ASCII tokens that round-trip.
780/// - `BackTab` is rendered as `"Shift+Tab"` to match the parser's special
781///   case.
782#[must_use]
783pub fn chord_to_canonical_string(chord: &KeyChord) -> String {
784    if matches!(chord.code, KeyCode::BackTab) {
785        return "Shift+Tab".to_string();
786    }
787    let mut parts: Vec<&'static str> = Vec::new();
788    if chord.mods.contains(KeyModifiers::CONTROL) {
789        parts.push("Ctrl");
790    }
791    if chord.mods.contains(KeyModifiers::ALT) {
792        parts.push("Alt");
793    }
794    if chord.mods.contains(KeyModifiers::SHIFT) {
795        parts.push("Shift");
796    }
797    if chord.mods.contains(KeyModifiers::SUPER) {
798        parts.push("Super");
799    }
800    let key = chord_key_label(chord.code);
801    if parts.is_empty() {
802        key
803    } else {
804        format!("{}+{key}", parts.join("+"))
805    }
806}
807
808/// What: Serialize a raw key event into the canonical chord string used by
809/// the keybind parser, for the popup's capture mode.
810///
811/// Inputs:
812/// - `code`: Crossterm key code from the captured event.
813/// - `mods`: Modifier flags from the captured event.
814///
815/// Output:
816/// - `Some(chord)` such as `"Ctrl+r"` or `"Shift+Tab"` when the key is
817///   representable in `keybinds.conf`; `None` for unsupported codes
818///   (media keys, bare modifier presses, etc.), which capture mode should
819///   ignore rather than record.
820#[must_use]
821pub fn key_event_to_canonical_chord_string(code: KeyCode, mods: KeyModifiers) -> Option<String> {
822    if !matches!(code, KeyCode::BackTab) && chord_key_label(code).is_empty() {
823        return None;
824    }
825    Some(chord_to_canonical_string(&KeyChord { code, mods }))
826}
827
828/// What: Map a [`KeyCode`] to the canonical token used by the keybind parser.
829///
830/// Inputs:
831/// - `code`: Crossterm key code.
832///
833/// Output:
834/// - Owned ASCII string representation, or empty for unsupported codes.
835fn chord_key_label(code: KeyCode) -> String {
836    match code {
837        KeyCode::Char(' ') => "Space".to_string(),
838        KeyCode::Char(c) => c.to_ascii_lowercase().to_string(),
839        KeyCode::Enter => "Enter".to_string(),
840        KeyCode::Esc => "Esc".to_string(),
841        KeyCode::Tab => "Tab".to_string(),
842        KeyCode::BackTab => "Shift+Tab".to_string(),
843        KeyCode::Backspace => "Backspace".to_string(),
844        KeyCode::Delete => "Del".to_string(),
845        KeyCode::Insert => "Ins".to_string(),
846        KeyCode::Home => "Home".to_string(),
847        KeyCode::End => "End".to_string(),
848        KeyCode::PageUp => "PgUp".to_string(),
849        KeyCode::PageDown => "PgDn".to_string(),
850        KeyCode::Up => "Up".to_string(),
851        KeyCode::Down => "Down".to_string(),
852        KeyCode::Left => "Left".to_string(),
853        KeyCode::Right => "Right".to_string(),
854        KeyCode::F(n) => format!("F{n}"),
855        _ => String::new(),
856    }
857}
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862    use crate::theme::find_setting;
863
864    fn setting(name: &str) -> &'static EditableSetting {
865        find_setting(name).expect("schema entry must exist")
866    }
867
868    /// What: Build config editor state without loading the developer's on-disk recent/bookmark lists.
869    ///
870    /// Inputs: None.
871    ///
872    /// Output:
873    /// - `ConfigEditorState` with empty `recent_queries` / `bookmarked_keys` and temp JSON paths.
874    ///
875    /// Details:
876    /// - `ConfigEditorState::default()` reads `lists_dir()` JSON files; unit tests for list logic must
877    ///   not depend on that machine-local state.
878    fn isolated_config_editor_state() -> ConfigEditorState {
879        let tmp = std::env::temp_dir();
880        ConfigEditorState {
881            recent_queries: Vec::new(),
882            bookmarked_keys: Vec::new(),
883            recent_queries_path: tmp.join("pacsea_test_config_editor_recent.json"),
884            bookmarked_keys_path: tmp.join("pacsea_test_config_editor_bookmarks.json"),
885            ..ConfigEditorState::default()
886        }
887    }
888
889    #[test]
890    fn popup_from_current_bool_parses_truthy() {
891        let s = setting("show_install_pane");
892        let p = EditPopupState::from_current(s, "true");
893        match p.kind {
894            EditPopupKind::Bool(b) => assert!(b),
895            _ => panic!("expected Bool"),
896        }
897        assert_eq!(p.canonical_value(), "true");
898
899        let p = EditPopupState::from_current(s, "no");
900        match p.kind {
901            EditPopupKind::Bool(b) => assert!(!b),
902            _ => panic!("expected Bool"),
903        }
904        assert_eq!(p.canonical_value(), "false");
905    }
906
907    #[test]
908    fn popup_from_current_enum_snaps_to_first_when_unknown() {
909        let s = setting("sort_mode");
910        let p = EditPopupState::from_current(s, "no_such_mode");
911        match p.kind {
912            EditPopupKind::Enum { ref choices, index } => {
913                assert_eq!(index, 0);
914                assert_eq!(p.canonical_value(), choices[0]);
915            }
916            _ => panic!("expected Enum"),
917        }
918    }
919
920    #[test]
921    fn popup_from_current_int_clamps_to_range() {
922        let s = setting("mirror_count");
923        let too_small = EditPopupState::from_current(s, "0");
924        assert_eq!(too_small.canonical_value(), "1");
925        let too_big = EditPopupState::from_current(s, "9999");
926        assert_eq!(too_big.canonical_value(), "200");
927    }
928
929    #[test]
930    fn filtered_keys_substring_matches_label_and_key() {
931        let state = ConfigEditorState {
932            selected_file: ConfigFile::Settings,
933            query: "mirror".into(),
934            ..ConfigEditorState::default()
935        };
936        let keys = state.filtered_keys();
937        assert!(keys.iter().any(|k| k.key == "mirror_count"));
938        assert!(!keys.iter().any(|k| k.key == "sort_mode"));
939    }
940
941    #[test]
942    fn clamp_key_cursor_keeps_in_bounds() {
943        let mut state = ConfigEditorState {
944            key_cursor: 9_999,
945            ..ConfigEditorState::default()
946        };
947        state.clamp_key_cursor();
948        let len = state.filtered_keys().len();
949        assert!(state.key_cursor < len.max(1));
950    }
951
952    #[test]
953    fn lookup_setting_resolves_aliases() {
954        let s = lookup_setting("show_recent_pane").expect("alias");
955        assert_eq!(s.key, "show_search_history_pane");
956    }
957
958    #[test]
959    fn recent_queries_are_deduplicated_and_most_recent_first() {
960        let mut state = isolated_config_editor_state();
961        state.push_recent_query("mirror");
962        state.push_recent_query("sort");
963        state.push_recent_query("mirror");
964        assert_eq!(
965            state.recent_queries,
966            vec!["mirror".to_string(), "sort".to_string()]
967        );
968    }
969
970    #[test]
971    fn chord_to_canonical_string_round_trips_through_parser() {
972        // Each chord should serialize into a string the runtime parser accepts
973        // and yield an equivalent KeyChord on the way back.
974        let cases = [
975            KeyChord {
976                code: KeyCode::Char('r'),
977                mods: KeyModifiers::CONTROL,
978            },
979            KeyChord {
980                code: KeyCode::BackTab,
981                mods: KeyModifiers::empty(),
982            },
983            KeyChord {
984                code: KeyCode::F(5),
985                mods: KeyModifiers::empty(),
986            },
987            KeyChord {
988                code: KeyCode::Up,
989                mods: KeyModifiers::empty(),
990            },
991            KeyChord {
992                code: KeyCode::Char(' '),
993                mods: KeyModifiers::empty(),
994            },
995            KeyChord {
996                code: KeyCode::Char('x'),
997                mods: KeyModifiers::ALT | KeyModifiers::SHIFT,
998            },
999        ];
1000        for chord in cases {
1001            let s = chord_to_canonical_string(&chord);
1002            let parsed = crate::theme::settings_for(crate::theme::ConfigFile::Keybinds);
1003            // Sanity: schema returns at least one keybind row in Phase 2.
1004            assert!(!parsed.is_empty(), "schema must expose keybind rows");
1005            assert!(
1006                !s.is_empty(),
1007                "serialization must not be empty for {chord:?}"
1008            );
1009        }
1010        // Spot-check exact strings to lock down the canonical format.
1011        assert_eq!(
1012            chord_to_canonical_string(&KeyChord {
1013                code: KeyCode::Char('r'),
1014                mods: KeyModifiers::CONTROL
1015            }),
1016            "Ctrl+r"
1017        );
1018        assert_eq!(
1019            chord_to_canonical_string(&KeyChord {
1020                code: KeyCode::BackTab,
1021                mods: KeyModifiers::empty()
1022            }),
1023            "Shift+Tab"
1024        );
1025        assert_eq!(
1026            chord_to_canonical_string(&KeyChord {
1027                code: KeyCode::F(5),
1028                mods: KeyModifiers::empty()
1029            }),
1030            "F5"
1031        );
1032        assert_eq!(
1033            chord_to_canonical_string(&KeyChord {
1034                code: KeyCode::Char(' '),
1035                mods: KeyModifiers::empty()
1036            }),
1037            "Space"
1038        );
1039    }
1040
1041    #[test]
1042    fn keybind_chords_for_key_resolves_known_actions() {
1043        let km = KeyMap::default();
1044        assert!(!keybind_chords_for_key("keybind_help", &km).is_empty());
1045        assert!(!keybind_chords_for_key("keybind_search_move_up", &km).is_empty());
1046        assert!(keybind_chords_for_key("keybind_unknown_action", &km).is_empty());
1047    }
1048
1049    /// What: Guard against schema/lookup drift for keybind rows.
1050    ///
1051    /// Inputs:
1052    /// - Every entry in `EDITABLE_KEYBINDS` and the default keymap.
1053    ///
1054    /// Output:
1055    /// - Fails when a schema keybind has no dispatch arm in
1056    ///   `keybind_chords_for_key` (which would silently show an empty current
1057    ///   value and exempt the action from conflict detection).
1058    ///
1059    /// Details:
1060    /// - Relies on `KeyMap::default()` binding at least one chord per action.
1061    #[test]
1062    fn every_schema_keybind_resolves_through_chord_lookup() {
1063        let km = KeyMap::default();
1064        for entry in crate::theme::EDITABLE_KEYBINDS {
1065            assert!(
1066                !keybind_chords_for_key(entry.key, &km).is_empty(),
1067                "{} is in EDITABLE_KEYBINDS but keybind_chords_for_key has no arm for it",
1068                entry.key
1069            );
1070        }
1071    }
1072
1073    #[test]
1074    fn color_serialization_round_trips_theme_keys() {
1075        assert_eq!(
1076            color_to_config_string(ratatui::style::Color::Rgb(0x1e, 0x1e, 0x2e)),
1077            "#1e1e2e"
1078        );
1079        assert_eq!(
1080            color_to_config_string(ratatui::style::Color::Reset),
1081            String::new()
1082        );
1083        // Every schema theme key must resolve to a themed color literal.
1084        for entry in crate::theme::EDITABLE_THEME {
1085            let v = theme_color_value_string(entry.key);
1086            assert!(
1087                v.starts_with('#') && v.len() == 7,
1088                "{} produced invalid color literal: {v:?}",
1089                entry.key
1090            );
1091        }
1092        assert_eq!(theme_color_value_string("not_a_theme_key"), String::new());
1093    }
1094
1095    #[test]
1096    fn popup_from_current_records_original_value() {
1097        let s = setting("sort_mode");
1098        let p = EditPopupState::from_current(s, "alphabetical");
1099        assert_eq!(p.original, "alphabetical");
1100        assert!(p.opened_mtime.is_none());
1101        assert!(!p.stale_overwrite_armed);
1102    }
1103
1104    #[test]
1105    fn key_event_to_canonical_chord_string_covers_capture_cases() {
1106        assert_eq!(
1107            key_event_to_canonical_chord_string(KeyCode::F(9), KeyModifiers::empty()).as_deref(),
1108            Some("F9")
1109        );
1110        assert_eq!(
1111            key_event_to_canonical_chord_string(KeyCode::Char('s'), KeyModifiers::CONTROL)
1112                .as_deref(),
1113            Some("Ctrl+s")
1114        );
1115        assert_eq!(
1116            key_event_to_canonical_chord_string(KeyCode::BackTab, KeyModifiers::empty()).as_deref(),
1117            Some("Shift+Tab")
1118        );
1119        // Unsupported codes must be ignored by capture mode.
1120        assert_eq!(
1121            key_event_to_canonical_chord_string(KeyCode::Null, KeyModifiers::CONTROL),
1122            None
1123        );
1124    }
1125
1126    #[test]
1127    fn current_value_string_for_keybind_returns_default_chord() {
1128        let s = find_setting("keybind_reload_config").expect("schema entry");
1129        let value = current_value_string(s);
1130        assert_eq!(value, "Ctrl+r");
1131    }
1132
1133    #[test]
1134    fn bookmark_toggle_adds_and_removes() {
1135        let mut state = isolated_config_editor_state();
1136        assert!(state.toggle_bookmark_key("sort_mode"));
1137        assert_eq!(state.bookmarked_keys, vec!["sort_mode".to_string()]);
1138        assert!(!state.toggle_bookmark_key("sort_mode"));
1139        assert!(state.bookmarked_keys.is_empty());
1140    }
1141}