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