Skip to main content

kimun_notes/settings/
mod.rs

1use crate::keys::action_shortcuts::ActionShortcuts;
2use crate::keys::key_strike::KeyStrike;
3use crate::settings::themes::Theme;
4use crate::settings::workspace_config::WorkspaceConfig;
5use std::io::Read;
6use std::path::PathBuf;
7use std::sync::{Arc, RwLock};
8
9use std::fs::{self, File};
10
11/// Errors from loading and saving settings and themes. Typed at this seam so
12/// callers can match on the failure; the binary's eyre top level (CLI, main)
13/// wraps it automatically via `?`.
14#[derive(Debug, thiserror::Error)]
15pub enum SettingsError {
16    #[error(transparent)]
17    Io(#[from] std::io::Error),
18    #[error("cannot serialize settings: {0}")]
19    Serialize(#[from] toml::ser::Error),
20    #[error("corrupt theme file: {0}")]
21    CorruptTheme(toml::de::Error),
22    #[error("config migration failed: {0}")]
23    Migration(String),
24    #[error(transparent)]
25    System(#[from] kimun_core::system::SystemError),
26}
27
28/// Shared settings handle — all screens and components reference the same instance.
29pub type SharedSettings = Arc<RwLock<AppSettings>>;
30use kimun_core::IndexFile;
31
32use self::history::HistoryFile;
33use kimun_core::nfs::{VaultPath, pinned_notes};
34use kimun_core::system::{self, SystemPath};
35
36use crate::keys::KeyBindings;
37pub mod config_migration;
38pub mod history;
39pub mod icons;
40pub mod themes;
41pub mod workspace_config;
42
43// ---------------------------------------------------------------------------
44// Sort settings types (shared between AppSettings and sorting UI)
45// ---------------------------------------------------------------------------
46
47#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum SortFieldSetting {
50    Name,
51    Title,
52}
53
54#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum SortOrderSetting {
57    Ascending,
58    Descending,
59}
60
61#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum EditorBackendSetting {
64    /// The built-in engine, keys applied directly.
65    ///
66    /// `alias` rather than a migration entry: `ConfigMigration::run` happens
67    /// *after* deserialisation, so a config still saying `textarea` would fail to
68    /// load before any migration could rewrite it. The alias upgrades on the next
69    /// save, when the setting is written back as `plain`.
70    #[default]
71    #[serde(alias = "textarea")]
72    Plain,
73    Nvim,
74    Vim,
75}
76
77/// What a bare `0x08` byte from the terminal means — see
78/// [`crate::app::ctrl_h`] for why one byte has to stand for two keys.
79#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
80#[serde(rename_all = "lowercase")]
81pub enum CtrlHSetting {
82    /// Let the session decide: the Ctrl-H chord on a terminal that can tell
83    /// the two keys apart, Backspace on one whose erase character is `0x08`,
84    /// the chord otherwise.
85    #[default]
86    Auto,
87    /// Always Backspace. For a terminal whose Backspace key sends `0x08`
88    /// without the tty's erase character saying so — a Konsole keytab, say.
89    /// The Ctrl-H chord becomes unreachable; rebind the action to reach it.
90    Backspace,
91    /// Always the Ctrl-H chord, even where that leaves a `0x08` Backspace key
92    /// unable to delete.
93    Chord,
94}
95
96// pub mod theme;
97
98/// Path to kimün's directory on this machine, creating it if needed — used by
99/// the update module for the install marker and update-state file. The layout
100/// (and the debug/release name) belongs to [`kimun_core::system`].
101pub fn config_dir() -> Result<PathBuf, system::SystemError> {
102    system::ensure_app_dir().map(SystemPath::into_path_buf)
103}
104
105const BASE_CONFIG_FILE: &str = "config.toml";
106const THEMES_DIR: &str = "themes";
107
108const CONFIG_HEADER: &str = "\
109# ─── Kimün configuration ────────────────────────────────────────────────────
110#
111# KEY BINDINGS
112# ────────────
113# Supported combinations:
114#   - ctrl and/or alt (with optional shift) + a letter (a-z)
115#   - bare F-key (F1–F12, no modifier required)
116# Any combo that does not follow these rules is silently ignored when loaded.
117#
118# Format per action:
119#   ActionName = [\"<modifiers> & <letter>\", ...]
120#
121# Available modifiers (combine with +):  ctrl   alt   shift
122#
123# Examples:
124#   Quit         = [\"ctrl&Q\"]            # Ctrl+Q
125#   SearchNotes  = [\"ctrl&K\"]            # Ctrl+K
126#   OpenNote     = [\"ctrl&O\"]            # Ctrl+O  (fuzzy file finder)
127#   OpenSettings = [\"F4\", \"ctrl&,\"]     # F4 (Ctrl+, alias)
128#   NewJournal   = [\"ctrl&J\"]            # Ctrl+J
129#   FileOperations = [\"F2\"]              # F2  (open file-ops menu: delete/rename/move)
130#   Leader       = [\"ctrl&B\"]            # Ctrl+B  (leader gateway: Ctrl+B f f, ...)
131#   OpenCommandPalette = [\"ctrl&P\"]      # Ctrl+P  (every leader command, fuzzy)
132#
133# OTHER SETTINGS
134# ──────────────
135#   theme             = \"Gruvbox Dark\"   # or any built-in / custom theme name
136#   leader_timeout_ms = 400               # hesitation before the which-key menu
137#   ctrl_h            = \"auto\"            # auto | backspace | chord
138#       What a bare 0x08 byte means. Backspace and Ctrl-H are the same byte
139#       on terminals without the kitty keyboard protocol, so only one of the
140#       two can work there. \"auto\" keeps the Ctrl-H chord unless the tty's
141#       erase character is 0x08; set \"backspace\" if your Backspace key moves
142#       focus instead of deleting, \"chord\" to always keep the chord.
143#
144# LEADER TREE OVERRIDES
145# ─────────────────────
146#   Remap, add, or remove leader sequences ([leader.bind]) and rename group
147#   captions ([leader.labels]). Keys are the sequence AFTER the gateway;
148#   bind values are action ids (see the cheatsheet) or \"none\" to unbind.
149#   [leader.bind]
150#   \"o f\" = \"find.files\"     # remap: leader o f now opens the file picker
151#   \"x\"   = \"note.daily\"     # add:   leader x opens today's journal
152#   \"g p\" = \"none\"           # remove the git-sync stub binding
153#   [leader.labels]
154#   \"f\"   = \"+search\"        # rename the +find group caption
155#
156# ─────────────────────────────────────────────────────────────────────────────
157";
158
159#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
160pub struct AppSettings {
161    // Workspace layout
162    #[serde(default)]
163    pub config_version: u32,
164    #[serde(flatten, skip_serializing_if = "Option::is_none")]
165    pub workspace_config: Option<WorkspaceConfig>,
166
167    // Preserved fields
168    #[serde(default)]
169    pub theme: String,
170    /// As written in the config file — may be relative, may start with `~`.
171    #[serde(default = "default_cache_dir")]
172    pub cache_dir: PathBuf,
173    /// [`Self::cache_dir`] made absolute. Never optional: an unresolved cache
174    /// dir means the index lands relative to the process's working directory,
175    /// so the type does not let that state exist. `resolve_paths` overwrites
176    /// it once the config file's directory is known; until then it is resolved
177    /// against the default config directory.
178    #[serde(skip, default = "default_cache_dir_resolved")]
179    cache_dir_resolved: SystemPath,
180
181    /// As written in the config file — see [`Self::cache_dir`].
182    #[serde(default = "default_history_dir")]
183    pub history_dir: PathBuf,
184    /// [`Self::history_dir`] made absolute — see [`Self::cache_dir_resolved`].
185    #[serde(skip, default = "default_history_dir_resolved")]
186    history_dir_resolved: SystemPath,
187    #[serde(skip, default = "yes")]
188    needs_indexing: bool,
189    #[serde(default = "default_keybindings")]
190    pub key_bindings: KeyBindings,
191    #[serde(default = "default_autosave_interval")]
192    pub autosave_interval_secs: u64,
193    /// Hesitation timeout (ms) before the which-key overlay reveals itself
194    /// during a pending leader sequence. Sequences typed faster never wait.
195    #[serde(default = "default_leader_timeout_ms")]
196    pub leader_timeout_ms: u64,
197    /// Leader-tree customization: `[leader.bind]` sequence→action-id
198    /// overrides and `[leader.labels]` group captions. Applied over the
199    /// built-in tree.
200    #[serde(default)]
201    pub leader: LeaderConfig,
202    #[serde(default = "default_use_nerd_fonts")]
203    pub use_nerd_fonts: bool,
204    #[serde(default)]
205    pub editor_backend: EditorBackendSetting,
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub nvim_path: Option<std::path::PathBuf>,
208    #[serde(default = "default_sort_field")]
209    pub default_sort_field: SortFieldSetting,
210    #[serde(default = "default_sort_order")]
211    pub default_sort_order: SortOrderSetting,
212    #[serde(default = "default_journal_sort_field")]
213    pub journal_sort_field: SortFieldSetting,
214    #[serde(default = "default_journal_sort_order")]
215    pub journal_sort_order: SortOrderSetting,
216    #[serde(default)]
217    pub group_directories: bool,
218    /// What a bare `0x08` byte means: the Backspace key, or the Ctrl-H chord.
219    /// Only one of the two can work on a terminal that spells them the same
220    /// (see [`crate::app::ctrl_h`]); this picks which.
221    #[serde(default)]
222    pub ctrl_h: CtrlHSetting,
223    /// Custom config file path. `None` means use the default location.
224    /// Not serialized — it's a runtime-only override.
225    #[serde(skip)]
226    pub config_file: Option<PathBuf>,
227}
228
229fn default_keybindings() -> KeyBindings {
230    let mut kb = KeyBindings::empty();
231    // No formatting chords. Markdown formatting lives on the leader's `+text`
232    // group (`Ctrl+B t b` / `t i` / `t s`) and nowhere else, because `Ctrl+I`
233    // is byte 0x09 — Tab's byte — on every terminal without the kitty keyboard
234    // protocol, and when the move was made no other Ctrl+letter was free: all
235    // 26 were claimed by this table or by the editor's own clipboard and undo
236    // chords, so Italic had nowhere to go. Keeping the old `Ctrl+B` and
237    // `Ctrl+S` chords working while `Ctrl+I` silently indented was the
238    // inconsistency worth removing, so all three moved rather than two
239    // staying — which is what freed those letters again (`Ctrl+B` has since
240    // been reclaimed below, for the gateway itself).
241    //
242    // The `Text(..)` actions are still bindable: they parse from a config file
243    // and `editor_input::classify_tail` still claims them, so a user who wants
244    // a direct chord back writes one line. `Text(Underline)`, `Link`, `Image`
245    // and `ToggleHeader` are absent from the leader group too —
246    // `emphasis_marker` implements none of them, so there is nothing yet to
247    // reach.
248    kb.batch_add()
249        .with_ctrl()
250        .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
251        .add(KeyStrike::KeyO, ActionShortcuts::OpenNote);
252
253    // TUI navigation shortcuts (always Ctrl — terminal apps don't use Cmd/Meta).
254    // NOTE: the `Quit` entry must match `crate::keys::default_quit_combo()`,
255    // which the deserialize safety net uses to recover an unreachable app.
256    kb.batch_add()
257        .with_ctrl()
258        // Ctrl-P is the command palette; settings live on Ctrl+Shift+P.
259        .add(KeyStrike::KeyP, ActionShortcuts::OpenCommandPalette)
260        .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
261        .add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
262        // Drawer toggle. Deliberately not Ctrl-B: the toggle went to Ctrl-T
263        // to leave Ctrl-B on Bold. Bold has since left the chord table for
264        // the leader, and Ctrl-B now hosts the gateway itself — but the
265        // toggle stays on Ctrl-T regardless: moving a chord people have in
266        // their fingers would cost more than it gains.
267        .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
268        .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
269        // Leader gateway. Not Ctrl-K, which stays the note browser. Ctrl-G
270        // held the gateway until Ctrl-B freed up (formatting left for the
271        // leader's own `+text` group); Ctrl-B is tmux's standard prefix, so
272        // new installs get it as the gateway instead. Existing config.toml
273        // files already have `Leader = ["ctrl&G"]` written from first run and
274        // are unaffected — this only changes what a fresh install picks up.
275        .add(KeyStrike::KeyB, ActionShortcuts::Leader)
276        // FollowLink's always-works binding; Ctrl+Enter also follows on
277        // kitty-protocol terminals (hardcoded in the editor screen).
278        .add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
279        .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
280        .add(KeyStrike::KeyL, ActionShortcuts::FocusEditor)
281        .add(KeyStrike::KeyW, ActionShortcuts::QuickNote)
282        // Ctrl-E opens (or switches the drawer to) the file browser; the
283        // pure drawer toggle is Ctrl-T above. ToggleQueryPanel has no
284        // default binding — FIND stays reachable via the rail and leader.
285        .add(KeyStrike::KeyE, ActionShortcuts::OpenFileBrowser)
286        .add(KeyStrike::KeyF, ActionShortcuts::FindInBuffer)
287        // Copy the selected list row. Shares Ctrl-Y with the editor's redo,
288        // resolved by focus: the shortcut tier only claims it away from the
289        // editor. Sourced from `default_yank_combo` so SearchList,
290        // which claims the same chord internally, cannot drift from it.
291        .add(
292            crate::keys::default_yank_combo().key,
293            ActionShortcuts::YankRow,
294        );
295
296    // Settings — F4 (no modifier, reliable in all terminals) plus the classic
297    // Ctrl+, kept as an alias. Ctrl+, doesn't transmit a distinct code on many
298    // terminals outside the kitty protocol, so F4 is the dependable default.
299    // (Ctrl+Shift+P collides with kitty's default hints-kitten chord prefix,
300    // which holds the screen mid-chord, so it isn't used.)
301    kb.batch_add()
302        .add(KeyStrike::F4, ActionShortcuts::OpenPreferences);
303    kb.batch_add()
304        .with_ctrl()
305        .add(KeyStrike::Comma, ActionShortcuts::OpenPreferences);
306
307    // File operations menu (F2 — no modifier, reliable in all terminals).
308    kb.batch_add()
309        .add(KeyStrike::F2, ActionShortcuts::FileOperations);
310
311    kb.batch_add()
312        .add(KeyStrike::F3, ActionShortcuts::OpenSavedSearches);
313
314    // Ask workspace (F6 — free key; the feature is inert without a server).
315    kb.batch_add().add(KeyStrike::F6, ActionShortcuts::OpenAsk);
316
317    // Workspace switcher — F5 (moved off F4, which is now Settings).
318    kb.batch_add()
319        .add(KeyStrike::F5, ActionShortcuts::SwitchWorkspace);
320
321    // Ctrl+D — save the current query to saved searches. Ctrl-only by design:
322    // Ctrl+Shift is unreliable on some terminals and Ctrl+{A,C,V,X,Y,Z} are
323    // claimed by the editor's own clipboard and undo chords. Ctrl+D was the
324    // only free, terminal-safe combo when it was chosen; retiring the
325    // formatting chords has since freed Ctrl+{I,S,U} as well (Ctrl+B is
326    // taken again — it now hosts the leader gateway, above).
327    kb.batch_add()
328        .with_ctrl()
329        .add(KeyStrike::KeyD, ActionShortcuts::SaveCurrentQuery);
330
331    kb
332}
333
334/// Deletes a removed workspace's artifacts, returning the ones that would not
335/// go, each with the reason.
336///
337/// A free function rather than a method so the caller can pull the artifacts
338/// out with [`AppSettings::workspace_artifacts`], release the settings lock,
339/// and delete outside it: `system::remove_file` waits out a handle another
340/// process holds, and parking every settings reader for that wait is what the
341/// "filesystem work before the lock" rule exists to avoid.
342///
343/// Best-effort by design. Whoever calls this has already decided the workspace
344/// is going, so a stuck file is a leftover to report rather than a reason to
345/// fail — and it is *returned*, because `tracing::warn!` in a CLI with no
346/// subscriber attached goes nowhere.
347pub fn delete_artifacts(index: &IndexFile, history: &HistoryFile) -> Vec<String> {
348    let mut leftovers = Vec::new();
349    // Every file the index is made of, not just the first that failed: a held
350    // handle is normally on a sidecar, and naming only that one would send the
351    // user to delete one file out of three.
352    let stuck = index.remove();
353    if stuck.is_empty() {
354        tracing::info!("removed index {}", index);
355    }
356    for (path, e) in stuck {
357        tracing::warn!("failed to remove {}: {}", path, e);
358        leftovers.push(format!("  {path}\n    {e}"));
359    }
360    if let Err(e) = history.remove() {
361        tracing::warn!("failed to remove history {}: {}", history, e);
362        leftovers.push(format!("  {history}\n    {e}"));
363    } else {
364        tracing::info!("removed history {}", history);
365    }
366    leftovers
367}
368
369fn yes() -> bool {
370    true
371}
372
373fn default_autosave_interval() -> u64 {
374    5
375}
376
377fn default_leader_timeout_ms() -> u64 {
378    400
379}
380
381/// The `[leader]` config section: binding overrides + group captions.
382#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
383pub struct LeaderConfig {
384    /// `[leader.bind]`: sequence (after the gateway, e.g. `"o f"` / `"x"`) →
385    /// action id (see the cheatsheet) or `"none"` to unbind.
386    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
387    pub bind: std::collections::BTreeMap<String, String>,
388    /// `[leader.labels]`: group sequence (e.g. `"f"`) → caption shown in the
389    /// which-key overlay and cheatsheet.
390    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
391    pub labels: std::collections::BTreeMap<String, String>,
392}
393
394impl AppSettings {
395    /// Suggested directory for a first workspace (`~/kimun-notes`). `None`
396    /// when the home directory cannot be determined.
397    pub fn default_workspace_suggestion() -> Option<PathBuf> {
398        system::home()
399            .ok()
400            .map(|h| h.join("kimun-notes").into_path_buf())
401    }
402
403    /// The leader tree with this config's `[leader]` overrides applied — the
404    /// ONE constructor every surface (engine, which-key, cheatsheet, palette)
405    /// must use, so they can never disagree.
406    pub fn leader_tree(&self) -> crate::keys::leader::LeaderNode {
407        let tree = crate::keys::leader::apply_overrides(
408            crate::keys::leader::leader_tree(),
409            self.leader
410                .bind
411                .iter()
412                .map(|(k, v)| (k.as_str(), v.as_str())),
413        );
414        crate::keys::leader::apply_labels(
415            tree,
416            self.leader
417                .labels
418                .iter()
419                .map(|(k, v)| (k.as_str(), v.as_str())),
420        )
421    }
422}
423
424fn default_cache_dir() -> PathBuf {
425    PathBuf::from(".")
426}
427
428fn default_history_dir() -> PathBuf {
429    PathBuf::from("history")
430}
431
432/// What relative `cache_dir` / `history_dir` values resolve against when no
433/// config file anchors them: the default config directory, read *without*
434/// creating it (settings that were never loaded from a file must not
435/// materialise directories as a side effect of `Default`).
436///
437/// Anything absolute beats the alternative. The one thing these paths must
438/// never fall back to is the raw relative form, which anchors on the process's
439/// working directory: every kimün started from the same directory would then
440/// share one index, and a first run would drop its index wherever the binary
441/// happened to be launched. Hence the temp-dir fallback when even the home
442/// directory is unknown.
443fn default_settings_base_dir() -> SystemPath {
444    system::app_dir().unwrap_or_else(|_| {
445        let fallback = std::env::temp_dir().join("kimun");
446        SystemPath::try_absolute(&fallback).unwrap_or_else(|_| system::log_dir())
447    })
448}
449
450/// Resolved form of [`default_cache_dir`] for settings with no config file.
451/// Also the `serde` default, so deserializing a config never yields an
452/// unresolved path even before `resolve_paths` runs.
453fn default_cache_dir_resolved() -> SystemPath {
454    SystemPath::resolve(default_cache_dir(), &default_settings_base_dir())
455}
456
457/// Resolved form of [`default_history_dir`] — see [`default_cache_dir_resolved`].
458fn default_history_dir_resolved() -> SystemPath {
459    SystemPath::resolve(default_history_dir(), &default_settings_base_dir())
460}
461
462fn default_use_nerd_fonts() -> bool {
463    false
464}
465
466fn default_sort_field() -> SortFieldSetting {
467    SortFieldSetting::Name
468}
469
470fn default_sort_order() -> SortOrderSetting {
471    SortOrderSetting::Ascending
472}
473
474fn default_journal_sort_field() -> SortFieldSetting {
475    SortFieldSetting::Name
476}
477
478fn default_journal_sort_order() -> SortOrderSetting {
479    SortOrderSetting::Descending
480}
481
482impl Default for AppSettings {
483    fn default() -> Self {
484        Self {
485            config_version: 0,
486            workspace_config: None,
487            theme: Default::default(),
488            cache_dir: default_cache_dir(),
489            cache_dir_resolved: default_cache_dir_resolved(),
490            history_dir: default_history_dir(),
491            history_dir_resolved: default_history_dir_resolved(),
492            needs_indexing: true,
493            key_bindings: default_keybindings(),
494            autosave_interval_secs: default_autosave_interval(),
495            leader_timeout_ms: default_leader_timeout_ms(),
496            leader: LeaderConfig::default(),
497            use_nerd_fonts: false,
498            editor_backend: EditorBackendSetting::Plain,
499            nvim_path: None,
500            default_sort_field: default_sort_field(),
501            default_sort_order: default_sort_order(),
502            journal_sort_field: default_journal_sort_field(),
503            journal_sort_order: default_journal_sort_order(),
504            group_directories: false,
505            ctrl_h: CtrlHSetting::default(),
506            config_file: None,
507        }
508    }
509}
510
511impl AppSettings {
512    pub fn theme_list(&self) -> Vec<Theme> {
513        let mut list = Theme::builtins();
514        list.append(&mut Self::load_custom_themes());
515        // Merge the user's default.toml override if present.
516        if let Ok(custom_default) = Self::load_default_theme() {
517            list.push(custom_default);
518        }
519        list.sort_by(|a, b| a.name.cmp(&b.name));
520        list
521    }
522
523    fn default_config_file_path() -> Result<PathBuf, SettingsError> {
524        Ok(system::ensure_app_dir()?
525            .join(BASE_CONFIG_FILE)
526            .into_path_buf())
527    }
528
529    fn get_config_file_path(&self) -> Result<PathBuf, SettingsError> {
530        if let Some(ref path) = self.config_file {
531            Ok(path.clone())
532        } else {
533            Self::default_config_file_path()
534        }
535    }
536
537    fn get_themes_path() -> Result<PathBuf, SettingsError> {
538        Ok(system::ensure_app_dir()?.join(THEMES_DIR).into_path_buf())
539    }
540
541    fn load_theme_from_path(path: &std::path::Path) -> Result<Theme, SettingsError> {
542        let theme_string = fs::read_to_string(path)?;
543        match toml::from_str::<Theme>(&theme_string) {
544            Ok(theme) => Ok(theme),
545            Err(e) => {
546                // Never delete a user-authored file over a typo — warn and
547                // skip, exactly like load_custom_themes does.
548                tracing::warn!("Skipping unparsable theme file {:?}: {}", path, e);
549                Err(SettingsError::CorruptTheme(e))
550            }
551        }
552    }
553
554    fn load_default_theme() -> Result<Theme, SettingsError> {
555        let theme_path = AppSettings::get_themes_path()?.join("default.toml");
556        Self::load_theme_from_path(&theme_path)
557    }
558
559    fn load_custom_themes() -> Vec<Theme> {
560        let mut themes = Vec::new();
561
562        // Get themes directory, return empty vec if it fails
563        let themes_path = match Self::get_themes_path() {
564            Ok(path) => path,
565            Err(_) => return themes,
566        };
567
568        // Read directory entries, return empty vec if it fails
569        let entries =
570            match SystemPath::try_absolute(&themes_path).and_then(|dir| system::read_dir(&dir)) {
571                Ok(entries) => entries,
572                Err(_) => return themes,
573            };
574
575        // Iterate through all entries in the themes directory
576        for entry in entries {
577            let path = entry.into_path_buf();
578
579            // Skip if not a file
580            if !path.is_file() {
581                continue;
582            }
583
584            // Skip if not a .toml file
585            if path.extension().and_then(|s| s.to_str()) != Some("toml") {
586                continue;
587            }
588
589            // Skip default.toml
590            if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
591                continue;
592            }
593
594            // Try to read and deserialize the theme file
595            match fs::read_to_string(&path)
596                .and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
597            {
598                Ok(theme) => themes.push(theme),
599                Err(e) => tracing::warn!("Skipping theme file {:?}: {}", path, e),
600            }
601        }
602
603        themes
604    }
605
606    /// Whether the startup update check is enabled. Lives in `GlobalConfig`;
607    /// defaults to on when no workspace config exists yet. Single source for
608    /// the four read sites (startup, preferences, onboarding).
609    pub fn update_check(&self) -> bool {
610        self.workspace_config
611            .as_ref()
612            .map(|wc| wc.global.update_check)
613            .unwrap_or(true)
614    }
615
616    /// Whether kimün captures the mouse for in-app use; defaults on when no
617    /// workspace config exists yet. Read at startup (`app::run_tui`) and in preferences.
618    pub fn mouse(&self) -> bool {
619        self.workspace_config
620            .as_ref()
621            .map(|wc| wc.global.mouse)
622            .unwrap_or(true)
623    }
624
625    pub fn save_to_disk(&self) -> Result<(), SettingsError> {
626        tracing::debug!("Saving settings to disk");
627        let settings_file_path = self.get_config_file_path()?;
628        // Atomic: the config is rewritten on every preference change and on
629        // every migration, and a truncated one is what the corrupt-config
630        // branch below exists to clean up after.
631        let body = format!("{CONFIG_HEADER}{}", toml::to_string(&self)?);
632        system::replace_atomically(&settings_file_path, body.as_bytes())?;
633        Ok(())
634    }
635
636    pub fn load_from_disk() -> Result<Self, SettingsError> {
637        let settings_file_path = Self::default_config_file_path()?;
638
639        if !settings_file_path.exists() {
640            let default_settings = Self::defaults_for_config_file(settings_file_path);
641            default_settings.save_to_disk()?;
642            Ok(default_settings)
643        } else {
644            let mut settings_file = File::open(&settings_file_path)?;
645
646            let mut toml = String::new();
647            settings_file.read_to_string(&mut toml)?;
648
649            match toml::from_str::<AppSettings>(toml.as_ref()) {
650                Ok(mut setting) => {
651                    setting.config_file = Some(settings_file_path.clone());
652                    // Resolve ~ and relative paths against the config file's
653                    // directory (see `config_base_dir` for why it is canonicalized).
654                    setting.resolve_paths(&Self::config_base_dir(&settings_file_path));
655                    if config_migration::ConfigMigration::run(&mut setting)? {
656                        setting.save_to_disk()?;
657                    }
658                    setting.merge_missing_default_bindings();
659                    Ok(setting)
660                }
661                Err(e) => {
662                    tracing::warn!(
663                        "Config file at {:?} could not be parsed ({}). \
664                         Renaming to .corrupt and starting with defaults.",
665                        settings_file_path,
666                        e
667                    );
668                    let corrupt_path = settings_file_path.with_extension("toml.corrupt");
669                    let _ = system::move_file(&settings_file_path, &corrupt_path);
670                    let defaults = Self::defaults_for_config_file(settings_file_path);
671                    defaults.save_to_disk()?;
672                    Ok(defaults)
673                }
674            }
675        }
676    }
677
678    pub fn load_from_file(path: PathBuf) -> Result<Self, SettingsError> {
679        // A bare filename (`--config kimun.toml`) has an *empty* parent, not
680        // no parent. Creating it is a no-op the OS accepts, but canonicalizing
681        // "" fails, so the empty case has to be filtered out here — otherwise
682        // every such invocation aborts before the config is even read.
683        if let Some(parent) = path.parent()
684            && !parent.as_os_str().is_empty()
685        {
686            system::create_dir(parent)?;
687        }
688        if !path.exists() {
689            let default_settings = Self::defaults_for_config_file(path);
690            default_settings.save_to_disk()?;
691            return Ok(default_settings);
692        }
693        let mut toml_str = String::new();
694        File::open(&path)?.read_to_string(&mut toml_str)?;
695        match toml::from_str::<AppSettings>(&toml_str) {
696            Ok(mut setting) => {
697                setting.config_file = Some(path.clone());
698
699                // Resolve ~ and relative paths against the config file's
700                // directory (see `config_base_dir` for why it is canonicalized).
701                setting.resolve_paths(&Self::config_base_dir(&path));
702
703                // Run config migrations (keybinding moves, v3 onwards).
704                if config_migration::ConfigMigration::run(&mut setting)? {
705                    setting.save_to_disk()?;
706                }
707
708                setting.merge_missing_default_bindings();
709                Ok(setting)
710            }
711            Err(e) => {
712                tracing::warn!(
713                    "Config file at {:?} could not be parsed ({}). \
714                     Renaming to .corrupt and starting with defaults.",
715                    path,
716                    e
717                );
718                let corrupt_path = path.with_extension("toml.corrupt");
719                let _ = system::move_file(&path, &corrupt_path);
720                let defaults = Self::defaults_for_config_file(path);
721                defaults.save_to_disk()?;
722                Ok(defaults)
723            }
724        }
725    }
726
727    /// Fills in defaults from `default_keybindings()` that are absent in the
728    /// loaded config: actions with no binding at all, plus default combos
729    /// added in newer versions (e.g. Ctrl-B for the drawer toggle) — as long
730    /// as the combo is not already bound to *any* action. Existing
731    /// user-customised bindings are never overwritten.
732    fn merge_missing_default_bindings(&mut self) {
733        let defaults = default_keybindings().to_hashmap();
734        let mut current = self.key_bindings.to_hashmap();
735        let mut bound: std::collections::HashSet<_> = current.values().flatten().cloned().collect();
736        for (action, combos) in defaults {
737            match current.entry(action) {
738                std::collections::hash_map::Entry::Vacant(e) => {
739                    // Never steal a combo the user has bound to something
740                    // else — insert only the free ones, and claim them so a
741                    // later default in this pass cannot double-bind.
742                    let free: Vec<_> = combos.into_iter().filter(|c| !bound.contains(c)).collect();
743                    if !free.is_empty() {
744                        bound.extend(free.iter().copied());
745                        e.insert(free);
746                    }
747                }
748                std::collections::hash_map::Entry::Occupied(mut e) => {
749                    for combo in combos {
750                        if !bound.contains(&combo) && !e.get().contains(&combo) {
751                            bound.insert(combo);
752                            e.get_mut().push(combo);
753                        }
754                    }
755                }
756            }
757        }
758        self.key_bindings = KeyBindings::from_hashmap(current);
759    }
760
761    /// Points the *selected* workspace entry at `workspace_path`, flagging a
762    /// reindex when that actually changes where its notes are.
763    ///
764    /// `name` is the entry to repoint; a name with no entry is a no-op.
765    pub fn set_workspace_path(&mut self, name: &str, workspace_path: PathBuf) {
766        let Some(entry) = self
767            .workspace_config
768            .as_mut()
769            .and_then(|wc| wc.workspaces.get_mut(name))
770        else {
771            return;
772        };
773        if *entry.effective_path() != workspace_path {
774            self.needs_indexing = true;
775        }
776        entry.path = workspace_path;
777        entry.resolved_path = None;
778    }
779
780    /// Removes the active workspace entry so the user is prompted to choose a
781    /// new one.
782    ///
783    /// Only the currently active entry is removed; other workspace entries are
784    /// preserved. After this call, `workspace_config` remains `Some` but
785    /// `get_current_workspace()` returns `None`.
786    ///
787    /// Deliberately leaves the index and history on disk: the caller for this
788    /// is a vault the app could not open (a case conflict it wants fixed), and
789    /// the user is expected to re-add the same workspace once they have. See
790    /// [`crate::settings::delete_artifacts`] for the deletion that goes with an
791    /// intentional "remove this workspace".
792    pub fn clear_workspace(&mut self) {
793        if let Some(wc) = &mut self.workspace_config {
794            let key = wc.global.current_workspace.clone();
795            if !key.is_empty() {
796                wc.workspaces.remove(&key);
797            }
798            wc.global.current_workspace = String::new();
799        }
800    }
801
802    /// Resolve the active workspace's path. Returns `None` if no workspace is
803    /// configured.
804    pub fn resolve_workspace_path(&self) -> Option<SystemPath> {
805        let raw = self
806            .workspace_config
807            .as_ref()
808            .and_then(|wc| wc.get_current_workspace())
809            .map(|entry| entry.effective_path().clone())?;
810        // Config paths are made absolute by `resolve_paths` on load. One that
811        // still is not cannot name a vault on this machine, so it is reported
812        // as "no workspace" rather than opened relative to wherever the
813        // process happens to be running.
814        match SystemPath::try_absolute(&raw) {
815            Ok(path) => Some(path),
816            Err(e) => {
817                tracing::warn!("ignoring unusable workspace path {raw:?}: {e}");
818                None
819            }
820        }
821    }
822
823    /// Resolve `~` and relative paths in workspace entries.
824    /// Relative paths are resolved against `base` (typically the config file's
825    /// parent directory). Called once after deserialization.
826    fn resolve_paths(&mut self, base: &SystemPath) {
827        // Workspace entries — populate resolved_path, keep original path intact.
828        if let Some(ref mut wc) = self.workspace_config {
829            for entry in wc.workspaces.values_mut() {
830                let resolved = SystemPath::resolve(&entry.path, base).into_path_buf();
831                if resolved != entry.path {
832                    entry.resolved_path = Some(resolved);
833                }
834            }
835        }
836        self.cache_dir_resolved = SystemPath::resolve(&self.cache_dir, base);
837        self.history_dir_resolved = SystemPath::resolve(&self.history_dir, base);
838    }
839
840    /// Freshly defaulted settings for a config file that could not be loaded —
841    /// one that does not exist yet, or one too corrupt to parse — with
842    /// `config_file` pointing at it and its relative paths resolved against
843    /// its directory.
844    ///
845    /// The resolve is the whole point: `cache_dir` / `history_dir` default to
846    /// `.` and `history`, and [`Self::default`] can only anchor them on the
847    /// *default* config directory. A config file elsewhere (`--config`) must
848    /// re-anchor them on its own directory, or its workspaces' indexes land
849    /// next to somebody else's.
850    fn defaults_for_config_file(path: PathBuf) -> Self {
851        let base = Self::config_base_dir(&path);
852        let mut settings = Self {
853            config_file: Some(path),
854            ..Self::default()
855        };
856        settings.resolve_paths(&base);
857        settings
858    }
859
860    /// The directory a config file's relative paths resolve against, in the
861    /// canonical form [`Self::resolve_paths`] expects as its `base`.
862    ///
863    /// Canonicalized up front (the directory exists — we only ever ask this of
864    /// a config file we just read): `expand_path` canonicalizes a relative
865    /// path's *result* only when that exact target already exists on disk (e.g.
866    /// `cache_dir = "."`), so an as-yet-uncreated one (e.g. `history_dir`
867    /// before its first write) would otherwise resolve against this
868    /// directory's raw form and silently disagree with the paths resolved once
869    /// the target does exist (`/var/...` vs macOS's real `/private/var/...`).
870    ///
871    /// [`Path::parent`] yields `Some("")` — not `None` — for a bare filename
872    /// (`--config kimun.toml`), so the empty parent falls back to `.` next to
873    /// the no-parent case; without that, `base` would be empty and relative
874    /// settings paths would never become absolute.
875    ///
876    /// [`Path::parent`]: std::path::Path::parent
877    fn config_base_dir(config_file: &std::path::Path) -> SystemPath {
878        let dir = config_file
879            .parent()
880            .filter(|p| !p.as_os_str().is_empty())
881            .unwrap_or(std::path::Path::new("."));
882        // `.` (a bare `--config kimun.toml`) is the one place resolving
883        // against the working directory is what the user meant: they named
884        // the file from there moments ago.
885        SystemPath::canonical(dir)
886            .or_else(|_| {
887                let cwd = std::env::current_dir().unwrap_or_default();
888                SystemPath::try_absolute(cwd.join(dir))
889            })
890            .unwrap_or_else(|_| default_settings_base_dir())
891    }
892
893    pub fn set_theme(&mut self, theme: String) {
894        self.theme = theme;
895    }
896
897    pub fn report_indexed(&mut self) {
898        self.needs_indexing = false;
899    }
900
901    pub fn needs_indexing(&self) -> bool {
902        self.needs_indexing
903    }
904
905    pub fn add_path_history(&self, note_path: &VaultPath) {
906        if !note_path.is_note() {
907            return;
908        }
909        let Some(history) = self.current_history() else {
910            return;
911        };
912        if let Err(e) = history.push(note_path) {
913            tracing::warn!("failed to write history {history}: {e}");
914        }
915    }
916
917    /// Follows a rename or move done inside kimün — of a note, or of a
918    /// directory and every note beneath it — so the history keeps pointing
919    /// at the notes. Same rules as pinned notes.
920    pub fn follow_rename_in_history(&self, from: &VaultPath, to: &VaultPath) {
921        self.edit_path_history(|paths| {
922            if from.is_note() {
923                pinned_notes::rewrite_note_rename(paths, from, to)
924            } else {
925                pinned_notes::rewrite_directory_rename(paths, from, to)
926            }
927        });
928    }
929
930    /// Follows a delete done inside kimün — of a note, or of a directory and
931    /// every note beneath it — so the history stops offering it.
932    pub fn follow_delete_in_history(&self, path: &VaultPath) {
933        self.edit_path_history(|paths| {
934            if path.is_note() {
935                pinned_notes::remove(paths, path)
936            } else {
937                pinned_notes::remove_under_directory(paths, path)
938            }
939        });
940    }
941
942    /// Applies `f` to the current workspace's history, writing it back when
943    /// `f` reports a change. Read and rewritten in one go, so an edit never
944    /// lands on a list loaded before some other write.
945    pub fn edit_path_history(&self, f: impl FnOnce(&mut Vec<VaultPath>) -> bool) {
946        let Some(history) = self.current_history() else {
947            return;
948        };
949        if let Err(e) = history.edit(f) {
950            tracing::warn!("failed to write history {history}: {e}");
951        }
952    }
953
954    /// The current workspace's history file; `None` with no workspace.
955    fn current_history(&self) -> Option<HistoryFile> {
956        self.current_workspace_name()
957            .map(|name| self.history_for(&name))
958    }
959
960    pub fn current_workspace_name(&self) -> Option<String> {
961        self.workspace_config
962            .as_ref()
963            .map(|wc| wc.global.current_workspace.clone())
964            .filter(|s| !s.is_empty())
965    }
966
967    /// The directory workspace cache files live in.
968    pub fn cache_dir_resolved(&self) -> &SystemPath {
969        &self.cache_dir_resolved
970    }
971
972    /// The directory workspace history files live in.
973    pub fn history_dir_resolved(&self) -> &SystemPath {
974        &self.history_dir_resolved
975    }
976
977    /// What the named workspace's files are called on disk.
978    ///
979    /// Its name, until the workspace is renamed — from then on the name it had
980    /// when its files were created (see [`WorkspaceEntry::file_key`]). Falling
981    /// back to the name when there is no entry is what lets `workspace init`
982    /// name the index before the entry exists.
983    ///
984    /// [`WorkspaceEntry::file_key`]: workspace_config::WorkspaceEntry::file_key
985    fn file_key_for(&self, workspace_name: &str) -> String {
986        self.workspace_config
987            .as_ref()
988            .and_then(|wc| wc.get_workspace(workspace_name))
989            .map(|entry| entry.file_key_or(workspace_name))
990            .unwrap_or_else(|| workspace_name.to_string())
991    }
992
993    /// The named workspace's index file.
994    ///
995    /// Returns the artifact, not a path: what the file is called, and which
996    /// journal siblings it carries, is [`IndexFile`]'s business. Caller
997    /// must have already validated `workspace_name` via
998    /// `kimun_core::nfs::filename::validate_filename`.
999    pub fn index_for(&self, workspace_name: &str) -> IndexFile {
1000        IndexFile::in_dir(&self.cache_dir_resolved, &self.file_key_for(workspace_name))
1001    }
1002
1003    /// The named workspace's history file — the artifact, not a path, for the
1004    /// same reason as [`Self::index_for`]. Caller must have already validated
1005    /// `workspace_name`.
1006    pub fn history_for(&self, workspace_name: &str) -> HistoryFile {
1007        HistoryFile::in_dir(
1008            &self.history_dir_resolved,
1009            &self.file_key_for(workspace_name),
1010        )
1011    }
1012
1013    /// Everything on disk that belongs to the named workspace.
1014    ///
1015    /// Must be read *before* the entry is dropped from `workspace_config`:
1016    /// both file names come from its [`file_key`], so removing the entry first
1017    /// strands them under a name nothing can map back to a workspace. Pure and
1018    /// cheap, so the caller can take these, release the settings lock, and do
1019    /// the deleting outside it — see [`delete_artifacts`].
1020    ///
1021    /// [`file_key`]: workspace_config::WorkspaceEntry::file_key
1022    pub fn workspace_artifacts(&self, workspace_name: &str) -> (IndexFile, HistoryFile) {
1023        (
1024            self.index_for(workspace_name),
1025            self.history_for(workspace_name),
1026        )
1027    }
1028
1029    /// Returns the last-visited paths for the current workspace.
1030    pub fn current_last_paths(&self) -> Vec<VaultPath> {
1031        self.current_history()
1032            .map(|history| history.load())
1033            .unwrap_or_default()
1034    }
1035
1036    /// Defaults with `name` as the current workspace (rooted at `workspace`),
1037    /// and both the history files and the config file kept in `scratch_dir`,
1038    /// so a test never touches the real config directory — opening a note
1039    /// saves the settings, and with no `config_file` that save lands in the
1040    /// developer's own `config.toml`.
1041    #[cfg(test)]
1042    pub(crate) fn for_test_workspace(
1043        name: &str,
1044        workspace: &SystemPath,
1045        scratch_dir: SystemPath,
1046    ) -> Self {
1047        let mut wc = WorkspaceConfig::new_empty();
1048        wc.add_workspace(name.to_string(), workspace.clone().into_path_buf())
1049            .unwrap();
1050        Self {
1051            workspace_config: Some(wc),
1052            config_file: Some(scratch_dir.join("config.toml").into_path_buf()),
1053            history_dir_resolved: scratch_dir,
1054            ..Self::default()
1055        }
1056    }
1057
1058    /// Seeds the current workspace's history with `paths`, newest first.
1059    #[cfg(test)]
1060    pub(crate) fn seed_test_history(&self, paths: &[&str]) {
1061        let paths: Vec<VaultPath> = paths.iter().map(|p| VaultPath::new(*p)).collect();
1062        self.current_history().unwrap().write(&paths).unwrap();
1063    }
1064
1065    /// Build the icon set for the current `use_nerd_fonts` setting.
1066    pub fn icons(&self) -> icons::Icons {
1067        icons::Icons::new(self.use_nerd_fonts)
1068    }
1069
1070    /// The chords bound to [`ActionShortcuts::YankRow`], for handing to a
1071    /// [`SearchList`](crate::components::search_list::SearchList) so a rebinding
1072    /// reaches the list surfaces. Empty when the user unbound it.
1073    pub fn yank_combos(&self) -> Vec<crate::keys::key_combo::KeyCombo> {
1074        self.key_bindings.combos_for(&ActionShortcuts::YankRow)
1075    }
1076
1077    /// Name of the theme the app is effectively using: the configured name,
1078    /// or the default theme's name when none is configured. Single owner of
1079    /// the empty-name fallback rule — use this instead of re-deriving it.
1080    pub fn effective_theme_name(&self) -> String {
1081        if self.theme.is_empty() {
1082            Theme::default().name
1083        } else {
1084            self.theme.clone()
1085        }
1086    }
1087
1088    /// Resolve the active theme by name, falling back to the default.
1089    ///
1090    /// The resolved theme is adapted to the terminal's color depth (truecolor
1091    /// themes are quantized on 256-color terminals and mapped to role-semantic
1092    /// ANSI slots on 16-color terminals).
1093    pub fn get_theme(&self) -> Theme {
1094        let theme = if self.theme.is_empty() {
1095            Theme::default()
1096        } else {
1097            self.theme_list()
1098                .into_iter()
1099                .find(|t| t.name == self.theme)
1100                .unwrap_or_default()
1101        };
1102        theme.adapt_to_terminal()
1103    }
1104}
1105
1106/// Path helpers shared by this file's test modules.
1107#[cfg(test)]
1108mod test_paths {
1109    use std::path::PathBuf;
1110
1111    /// Builds a genuinely absolute path for the host from `/`-separated
1112    /// components.
1113    ///
1114    /// A literal like `"/config/dir"` is absolute on Unix but merely *rooted*
1115    /// on Windows, where [`Path::is_absolute`] also wants a prefix (`C:\`).
1116    /// Passing the literal straight in doesn't just weaken these tests there,
1117    /// it inverts them: `expand_path` sees a relative path, rebases it onto
1118    /// `base`, and the `is_absolute` assertion then fails on behavior that is
1119    /// in fact correct.
1120    ///
1121    /// [`Path::is_absolute`]: std::path::Path::is_absolute
1122    pub(super) fn absolute(unix_style: &str) -> PathBuf {
1123        let trimmed = unix_style.trim_start_matches('/');
1124        if cfg!(windows) {
1125            PathBuf::from(format!("C:\\{}", trimmed.replace('/', "\\")))
1126        } else {
1127            PathBuf::from(format!("/{trimmed}"))
1128        }
1129    }
1130
1131    /// The same path as a TOML string literal's contents — Windows separators
1132    /// have to survive TOML's own escaping.
1133    pub(super) fn absolute_toml(unix_style: &str) -> String {
1134        absolute(unix_style).to_string_lossy().replace('\\', "\\\\")
1135    }
1136}
1137
1138#[cfg(test)]
1139#[allow(clippy::field_reassign_with_default)]
1140mod tests {
1141    use super::test_paths::absolute;
1142    use super::*;
1143
1144    #[test]
1145    fn default_workspace_suggestion_is_under_home() {
1146        let suggestion = AppSettings::default_workspace_suggestion();
1147        if let Some(p) = suggestion {
1148            assert!(p.ends_with("kimun-notes"));
1149            assert!(p.is_absolute());
1150        }
1151        // None is acceptable only when the platform has no home dir.
1152    }
1153
1154    #[test]
1155    fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
1156        // RED: fails to compile because load_theme_from_path doesn't exist.
1157        // GREEN: method exists, returns Err, and does NOT create the file.
1158        let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
1159        let _ = std::fs::remove_file(&path); // ensure clean state
1160
1161        let result = AppSettings::load_theme_from_path(&path);
1162
1163        assert!(result.is_err(), "should return Err when file is absent");
1164        assert!(!path.exists(), "must not create the file as a side effect");
1165    }
1166
1167    #[test]
1168    fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
1169        // After a corrupt file is removed, no replacement must be written.
1170        let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
1171        std::fs::write(&path, b"not valid toml {{{{").unwrap();
1172
1173        let result = AppSettings::load_theme_from_path(&path);
1174
1175        assert!(result.is_err(), "should return Err for corrupt TOML");
1176        // The user's file must SURVIVE a parse error (a typo must never
1177        // delete a hand-authored theme).
1178        assert!(path.exists(), "corrupt theme file must not be deleted");
1179        std::fs::remove_file(&path).ok();
1180    }
1181
1182    #[test]
1183    fn default_keybindings_quit_matches_canonical_combo() {
1184        let kb = default_keybindings();
1185        let combo = crate::keys::default_quit_combo();
1186        assert_eq!(
1187            kb.get_action(&combo),
1188            Some(ActionShortcuts::Quit),
1189            "default_keybindings() must bind default_quit_combo() to Quit so the \
1190             deserialize safety net can recover an unreachable app"
1191        );
1192    }
1193
1194    #[test]
1195    fn autosave_interval_defaults_to_five() {
1196        let settings = AppSettings::default();
1197        assert_eq!(settings.autosave_interval_secs, 5);
1198    }
1199
1200    #[test]
1201    fn autosave_interval_deserializes_from_toml() {
1202        let toml = "autosave_interval_secs = 30\n";
1203        let settings: AppSettings = toml::from_str(toml).unwrap();
1204        assert_eq!(settings.autosave_interval_secs, 30);
1205    }
1206
1207    #[test]
1208    fn autosave_interval_defaults_when_missing_from_toml() {
1209        let toml = ""; // no autosave_interval_secs key
1210        let settings: AppSettings = toml::from_str(toml).unwrap();
1211        assert_eq!(settings.autosave_interval_secs, 5);
1212    }
1213
1214    /// Verify the full load path: TOML with FileOperations = ["F2"] → keybinding lookup.
1215    #[test]
1216    fn f2_file_operations_survives_toml_deserialize() {
1217        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1218        use crate::keys::key_strike::KeyStrike;
1219
1220        let toml = r#"
1221[key_bindings]
1222FileOperations = ["F2"]
1223"#;
1224        let settings: AppSettings = toml::from_str(toml).unwrap();
1225        let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
1226        let action = settings.key_bindings.get_action(&f2);
1227        assert_eq!(
1228            action,
1229            Some(ActionShortcuts::FileOperations),
1230            "F2 should survive deserialization and map to FileOperations"
1231        );
1232    }
1233
1234    /// Verify merge_missing_default_bindings adds F2 when absent from config.
1235    #[test]
1236    fn merge_adds_f2_when_absent() {
1237        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1238        use crate::keys::key_strike::KeyStrike;
1239
1240        // Settings with no FileOperations binding
1241        let toml = r#"
1242[key_bindings]
1243Quit = ["ctrl&Q"]
1244"#;
1245        let mut settings: AppSettings = toml::from_str(toml).unwrap();
1246        settings.merge_missing_default_bindings();
1247
1248        let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
1249        let action = settings.key_bindings.get_action(&f2);
1250        assert_eq!(
1251            action,
1252            Some(ActionShortcuts::FileOperations),
1253            "merge_missing_default_bindings should add F2 → FileOperations"
1254        );
1255    }
1256
1257    #[test]
1258    fn clear_workspace_removes_the_current_workspace_entry() {
1259        let mut settings = AppSettings::default();
1260        let mut wc = WorkspaceConfig::new_empty();
1261        wc.add_workspace("vault1".to_string(), absolute("/tmp/vault1"))
1262            .unwrap();
1263        settings.workspace_config = Some(wc);
1264        // Assert precondition: add_workspace auto-selects the first workspace
1265        assert_eq!(
1266            settings
1267                .workspace_config
1268                .as_ref()
1269                .unwrap()
1270                .global
1271                .current_workspace,
1272            "vault1"
1273        );
1274        settings.clear_workspace();
1275        let wc = settings.workspace_config.as_ref().unwrap();
1276        assert!(
1277            wc.workspaces.is_empty(),
1278            "workspace entry should be removed"
1279        );
1280        assert!(
1281            wc.global.current_workspace.is_empty(),
1282            "current_workspace should be empty"
1283        );
1284    }
1285
1286    #[test]
1287    fn clear_workspace_preserves_other_workspaces() {
1288        let mut settings = AppSettings::default();
1289        let mut wc = WorkspaceConfig::new_empty();
1290        wc.add_workspace("vault1".to_string(), absolute("/tmp/vault1"))
1291            .unwrap();
1292        wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
1293            .unwrap();
1294        wc.global.current_workspace = "vault1".to_string();
1295        settings.workspace_config = Some(wc);
1296        settings.clear_workspace();
1297        let wc = settings.workspace_config.as_ref().unwrap();
1298        assert!(
1299            !wc.workspaces.contains_key("vault1"),
1300            "active workspace should be removed"
1301        );
1302        assert!(
1303            wc.workspaces.contains_key("vault2"),
1304            "other workspaces should be preserved"
1305        );
1306        assert!(
1307            wc.global.current_workspace.is_empty(),
1308            "current_workspace should be empty"
1309        );
1310    }
1311}
1312
1313#[cfg(test)]
1314mod backend_tests {
1315    use super::test_paths::{absolute, absolute_toml};
1316    use super::*;
1317
1318    #[test]
1319    fn a_config_still_saying_textarea_loads() {
1320        // The value was renamed; a config written by an older version must keep
1321        // working. This has to be an alias rather than a migration entry, because
1322        // migrations run after deserialisation — an unknown variant fails first.
1323        #[derive(serde::Deserialize)]
1324        struct Holder {
1325            editor_backend: EditorBackendSetting,
1326        }
1327        let old: Holder = toml::from_str("editor_backend = \"textarea\"").expect("still loads");
1328        assert_eq!(old.editor_backend, EditorBackendSetting::Plain);
1329    }
1330
1331    #[test]
1332    fn the_backend_is_written_back_as_plain() {
1333        let written = toml::to_string(&AppSettings::default()).expect("serialises");
1334        assert!(
1335            written.contains("editor_backend = \"plain\""),
1336            "a saved config should name the value as it is now: {written}"
1337        );
1338    }
1339
1340    #[test]
1341    fn default_backend_is_plain() {
1342        let settings = AppSettings::default();
1343        assert!(matches!(
1344            settings.editor_backend,
1345            EditorBackendSetting::Plain
1346        ));
1347    }
1348
1349    #[test]
1350    fn nvim_backend_round_trips_toml() {
1351        let toml = "editor_backend = \"nvim\"\n";
1352        let parsed: AppSettings = toml::from_str(toml).unwrap();
1353        assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
1354    }
1355
1356    #[test]
1357    fn editor_backend_vim_roundtrips_through_toml() {
1358        #[derive(serde::Serialize, serde::Deserialize)]
1359        struct W {
1360            editor_backend: EditorBackendSetting,
1361        }
1362        let w = W {
1363            editor_backend: EditorBackendSetting::Vim,
1364        };
1365        let s = toml::to_string(&w).unwrap();
1366        assert!(s.contains("editor_backend = \"vim\""), "serialized: {s}");
1367        let back: W = toml::from_str(&s).unwrap();
1368        assert_eq!(back.editor_backend, EditorBackendSetting::Vim);
1369    }
1370
1371    /// Every action the default keymap binds must keep at least one chord
1372    /// that survives a terminal without the kitty keyboard protocol — the
1373    /// weakest terminal kimün supports, and the common case on Linux and
1374    /// macOS.
1375    ///
1376    /// This is the guard that `Ctrl+I` needed and did not have: it was bound
1377    /// to Italic for as long as nobody noticed that `0x09` is Tab's byte, and
1378    /// nothing failed. An action with several chords passes on any one of
1379    /// them, which is exactly why `OpenPreferences` leads with F4 and keeps
1380    /// `Ctrl+,` as the alias rather than the reverse.
1381    #[test]
1382    fn every_default_action_keeps_a_chord_a_legacy_terminal_can_send() {
1383        use crate::keys::reachability::{TerminalKeys, reach};
1384
1385        for (action, combos) in default_keybindings().to_hashmap() {
1386            let verdicts: Vec<String> = combos
1387                .iter()
1388                .map(|c| format!("{c}: {}", reach(*c, TerminalKeys::LEGACY)))
1389                .collect();
1390            assert!(
1391                combos
1392                    .iter()
1393                    .any(|c| reach(*c, TerminalKeys::LEGACY).is_ok()),
1394                "{action} has no chord a legacy terminal can send: {}",
1395                verdicts.join("; ")
1396            );
1397        }
1398    }
1399
1400    /// The other half of the rule above, made explicit because it reads like
1401    /// a bug otherwise: an unreachable chord is fine *as an alias*. `Ctrl+,`
1402    /// is the classic Preferences chord and most terminals send nothing
1403    /// distinct for it, so F4 carries the action and `Ctrl+,` is a bonus on
1404    /// terminals that can manage it. Drop F4 and the invariant fires.
1405    #[test]
1406    fn an_unreachable_chord_is_allowed_as_an_alias() {
1407        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1408        use crate::keys::reachability::{Reach, TerminalKeys, reach};
1409
1410        let kb = default_keybindings();
1411        let combos = kb.combos_for(&ActionShortcuts::OpenPreferences);
1412        let ctrl_comma = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::Comma);
1413        assert!(combos.contains(&ctrl_comma), "the alias is still bound");
1414        assert_eq!(
1415            reach(ctrl_comma, TerminalKeys::LEGACY),
1416            Reach::Untransmitted,
1417            "and it is still the unreachable one"
1418        );
1419        assert!(
1420            combos.contains(&KeyCombo::new(KeyModifiers::new(), KeyStrike::F4)),
1421            "F4 is what actually carries Preferences"
1422        );
1423    }
1424
1425    /// All formatting lives on the leader (`Ctrl+B t b` / `t i` / `t s`), so
1426    /// the chord table binds no text action at all. `Ctrl+I` cannot work
1427    /// outside the kitty keyboard protocol — it is Tab's byte — and having
1428    /// `Ctrl+B` and `Ctrl+S` work while it silently did not was the
1429    /// inconsistency. Users who want a chord back can still bind one: the
1430    /// `Text(..)` actions stay parseable from config and the shortcut tier
1431    /// still claims them.
1432    #[test]
1433    fn the_default_table_binds_no_text_action() {
1434        let kb = default_keybindings();
1435        let bound: Vec<String> = kb
1436            .to_hashmap()
1437            .keys()
1438            .filter_map(|a| match a {
1439                ActionShortcuts::Text(t) => Some(format!("{t:?}")),
1440                _ => None,
1441            })
1442            .collect();
1443        assert!(
1444            bound.is_empty(),
1445            "formatting belongs on the leader, but these still hold chords: {bound:?}"
1446        );
1447    }
1448
1449    /// Ctrl+L and Ctrl+T belong to focus and the drawer. `Text(Link)` and
1450    /// `Text(ToggleHeader)` once asked for the same two chords and lost them
1451    /// in silence; the builder now panics on that, and
1452    /// `the_default_table_binds_no_text_action` covers the formatting side.
1453    /// What is left to pin here is who owns the two chords.
1454    #[test]
1455    fn ctrl_l_and_ctrl_t_belong_to_focus_and_the_drawer() {
1456        let kb = default_keybindings();
1457        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1458        let ctrl = |key| KeyCombo::new(KeyModifiers::new().and_ctrl(), key);
1459        assert_eq!(
1460            kb.get_action(&ctrl(KeyStrike::KeyL)),
1461            Some(ActionShortcuts::FocusEditor)
1462        );
1463        assert_eq!(
1464            kb.get_action(&ctrl(KeyStrike::KeyT)),
1465            Some(ActionShortcuts::ToggleSidebar)
1466        );
1467    }
1468
1469    /// Every spelling of the setting parses, and an older config without the
1470    /// key keeps the Ctrl-H chord it has always had.
1471    #[test]
1472    fn ctrl_h_parses_and_defaults_to_auto() {
1473        for (written, expected) in [
1474            ("auto", CtrlHSetting::Auto),
1475            ("backspace", CtrlHSetting::Backspace),
1476            ("chord", CtrlHSetting::Chord),
1477        ] {
1478            let s = toml::from_str::<AppSettings>(&format!("ctrl_h = \"{written}\"\n"))
1479                .unwrap_or_else(|e| panic!("ctrl_h = \"{written}\" must parse: {e}"));
1480            assert_eq!(s.ctrl_h, expected);
1481        }
1482        assert_eq!(
1483            toml::from_str::<AppSettings>("theme = \"gruvbox_dark\"\n")
1484                .unwrap()
1485                .ctrl_h,
1486            CtrlHSetting::Auto,
1487            "a config written before the setting existed must not change behaviour"
1488        );
1489    }
1490
1491    /// Workspace cache/history paths are absolute however the settings were
1492    /// built — including the two constructions that never run `resolve_paths`:
1493    /// a bare `default()` and a plain deserialize. A relative one anchors the
1494    /// index on the process's working directory, so every kimün started from
1495    /// the same place would share one index.
1496    #[test]
1497    fn workspace_paths_are_absolute_without_resolve_paths() {
1498        for settings in [
1499            AppSettings::default(),
1500            toml::from_str::<AppSettings>("theme = \"gruvbox_dark\"\n").unwrap(),
1501        ] {
1502            let cache = settings.index_for("w");
1503            let history = settings.history_for("w");
1504            assert!(
1505                cache.path().as_path().is_absolute(),
1506                "cache path not absolute: {cache}"
1507            );
1508            assert!(
1509                history.path().as_path().is_absolute(),
1510                "history path not absolute: {history}"
1511            );
1512        }
1513    }
1514
1515    // `expand_path` and its tests moved to `kimun_core::system`, where the
1516    // path rules now live; what stays here is how *settings* anchor them.
1517
1518    #[test]
1519    fn resolve_paths_populates_resolved_path() {
1520        let base = tempfile::TempDir::new().unwrap();
1521        let notes = base.path().join("notes");
1522        std::fs::create_dir_all(&notes).unwrap();
1523
1524        let toml = r#"
1525config_version = 2
1526[global]
1527current_workspace = "test"
1528[workspaces.test]
1529path = "notes"
1530last_paths = []
1531created = "2026-01-01T00:00:00Z"
1532"#
1533        .to_string();
1534        let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1535        settings.resolve_paths(&SystemPath::try_absolute(base.path()).unwrap());
1536
1537        let wc = settings.workspace_config.as_ref().unwrap();
1538        let entry = wc.workspaces.get("test").unwrap();
1539        // Original path preserved
1540        assert_eq!(entry.path, PathBuf::from("notes"));
1541        // Resolved path is absolute
1542        assert!(entry.resolved_path.is_some());
1543        assert!(entry.effective_path().is_absolute());
1544    }
1545
1546    /// A config file that does not exist yet still has to yield paths anchored
1547    /// to its own directory. `index_for`/`history_for` fall back to
1548    /// the *raw* `cache_dir`/`history_dir` when nothing resolved them, and
1549    /// those default to `.` and `history` — relative, so unresolved defaults
1550    /// put the workspace index wherever the process happens to be running.
1551    /// Every process sharing a working directory then shares one index file:
1552    /// in CI that is several test binaries racing to create the same schema
1553    /// ("table appData already exists"), and for a user it is a first-run
1554    /// index dropped in whatever directory they launched from.
1555    #[test]
1556    fn load_from_file_resolves_paths_for_a_config_that_does_not_exist_yet() {
1557        let dir = tempfile::TempDir::new().unwrap();
1558        let config_dir = dir.path().canonicalize().unwrap();
1559        let settings = AppSettings::load_from_file(config_dir.join("config.toml")).unwrap();
1560
1561        let cache = settings.index_for("work");
1562        let history = settings.history_for("work");
1563        assert!(
1564            cache.path().as_path().starts_with(&config_dir),
1565            "cache path must sit next to the config file, got {cache}"
1566        );
1567        assert!(
1568            history.path().as_path().starts_with(&config_dir),
1569            "history path must sit next to the config file, got {history}"
1570        );
1571    }
1572
1573    /// Same requirement on the other branch that hands back bare defaults: an
1574    /// unparseable config is renamed aside and replaced, and those
1575    /// replacements need resolving just as much.
1576    #[test]
1577    fn load_from_file_resolves_paths_when_the_config_is_corrupt() {
1578        let dir = tempfile::TempDir::new().unwrap();
1579        let config_dir = dir.path().canonicalize().unwrap();
1580        let config_path = config_dir.join("config.toml");
1581        std::fs::write(&config_path, "not = valid toml [[[").unwrap();
1582
1583        let settings = AppSettings::load_from_file(config_path).unwrap();
1584
1585        let cache = settings.index_for("work");
1586        assert!(
1587            cache.path().as_path().starts_with(&config_dir),
1588            "cache path must sit next to the config file, got {cache}"
1589        );
1590    }
1591
1592    #[test]
1593    fn resolve_paths_absolute_no_resolved_path() {
1594        // Host-absolute, not just `/`-rooted: on Windows `/absolute/notes` is
1595        // a *relative* path, so it would pick up a resolved_path and invert
1596        // both assertions below.
1597        let toml = format!(
1598            r#"
1599config_version = 2
1600[global]
1601current_workspace = "test"
1602[workspaces.test]
1603path = "{}"
1604last_paths = []
1605created = "2026-01-01T00:00:00Z"
1606"#,
1607            absolute_toml("/absolute/notes")
1608        );
1609        let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1610        settings.resolve_paths(&SystemPath::try_absolute(absolute("/config")).unwrap());
1611
1612        let wc = settings.workspace_config.as_ref().unwrap();
1613        let entry = wc.workspaces.get("test").unwrap();
1614        // No resolved_path needed for already-absolute paths
1615        assert!(entry.resolved_path.is_none());
1616        assert_eq!(*entry.effective_path(), absolute("/absolute/notes"));
1617    }
1618}
1619
1620#[cfg(test)]
1621mod sort_settings_tests {
1622    use super::*;
1623
1624    #[test]
1625    fn group_directories_defaults_off() {
1626        let s = AppSettings::default();
1627        assert!(!s.group_directories);
1628    }
1629
1630    #[test]
1631    fn open_sort_dialog_is_bound_by_default() {
1632        let s = AppSettings::default();
1633        let map = s.key_bindings.to_hashmap();
1634        assert!(
1635            map.contains_key(&ActionShortcuts::OpenSortDialog),
1636            "OpenSortDialog must have a default binding"
1637        );
1638    }
1639}