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