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;
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(&mut self, note_path: &VaultPath) {
901        if !note_path.is_note() {
902            return;
903        }
904        let Some(workspace_name) = self.current_workspace_name() else {
905            return;
906        };
907        let history = self.history_for(&workspace_name);
908        if let Err(e) = history.push(note_path) {
909            tracing::warn!("failed to write history {history}: {e}");
910        }
911    }
912
913    pub fn current_workspace_name(&self) -> Option<String> {
914        self.workspace_config
915            .as_ref()
916            .map(|wc| wc.global.current_workspace.clone())
917            .filter(|s| !s.is_empty())
918    }
919
920    /// The directory workspace cache files live in.
921    pub fn cache_dir_resolved(&self) -> &SystemPath {
922        &self.cache_dir_resolved
923    }
924
925    /// The directory workspace history files live in.
926    pub fn history_dir_resolved(&self) -> &SystemPath {
927        &self.history_dir_resolved
928    }
929
930    /// What the named workspace's files are called on disk.
931    ///
932    /// Its name, until the workspace is renamed — from then on the name it had
933    /// when its files were created (see [`WorkspaceEntry::file_key`]). Falling
934    /// back to the name when there is no entry is what lets `workspace init`
935    /// name the index before the entry exists.
936    ///
937    /// [`WorkspaceEntry::file_key`]: workspace_config::WorkspaceEntry::file_key
938    fn file_key_for(&self, workspace_name: &str) -> String {
939        self.workspace_config
940            .as_ref()
941            .and_then(|wc| wc.get_workspace(workspace_name))
942            .map(|entry| entry.file_key_or(workspace_name))
943            .unwrap_or_else(|| workspace_name.to_string())
944    }
945
946    /// The named workspace's index file.
947    ///
948    /// Returns the artifact, not a path: what the file is called, and which
949    /// journal siblings it carries, is [`IndexFile`]'s business. Caller
950    /// must have already validated `workspace_name` via
951    /// `kimun_core::nfs::filename::validate_filename`.
952    pub fn index_for(&self, workspace_name: &str) -> IndexFile {
953        IndexFile::in_dir(&self.cache_dir_resolved, &self.file_key_for(workspace_name))
954    }
955
956    /// The named workspace's history file — the artifact, not a path, for the
957    /// same reason as [`Self::index_for`]. Caller must have already validated
958    /// `workspace_name`.
959    pub fn history_for(&self, workspace_name: &str) -> HistoryFile {
960        HistoryFile::in_dir(
961            &self.history_dir_resolved,
962            &self.file_key_for(workspace_name),
963        )
964    }
965
966    /// Everything on disk that belongs to the named workspace.
967    ///
968    /// Must be read *before* the entry is dropped from `workspace_config`:
969    /// both file names come from its [`file_key`], so removing the entry first
970    /// strands them under a name nothing can map back to a workspace. Pure and
971    /// cheap, so the caller can take these, release the settings lock, and do
972    /// the deleting outside it — see [`delete_artifacts`].
973    ///
974    /// [`file_key`]: workspace_config::WorkspaceEntry::file_key
975    pub fn workspace_artifacts(&self, workspace_name: &str) -> (IndexFile, HistoryFile) {
976        (
977            self.index_for(workspace_name),
978            self.history_for(workspace_name),
979        )
980    }
981
982    /// Returns the last-visited paths for the current workspace.
983    pub fn current_last_paths(&self) -> Vec<VaultPath> {
984        let Some(name) = self.current_workspace_name() else {
985            return Vec::new();
986        };
987        self.history_for(&name).load()
988    }
989
990    /// Build the icon set for the current `use_nerd_fonts` setting.
991    pub fn icons(&self) -> icons::Icons {
992        icons::Icons::new(self.use_nerd_fonts)
993    }
994
995    /// The chords bound to [`ActionShortcuts::YankRow`], for handing to a
996    /// [`SearchList`](crate::components::search_list::SearchList) so a rebinding
997    /// reaches the list surfaces. Empty when the user unbound it.
998    pub fn yank_combos(&self) -> Vec<crate::keys::key_combo::KeyCombo> {
999        self.key_bindings.combos_for(&ActionShortcuts::YankRow)
1000    }
1001
1002    /// Name of the theme the app is effectively using: the configured name,
1003    /// or the default theme's name when none is configured. Single owner of
1004    /// the empty-name fallback rule — use this instead of re-deriving it.
1005    pub fn effective_theme_name(&self) -> String {
1006        if self.theme.is_empty() {
1007            Theme::default().name
1008        } else {
1009            self.theme.clone()
1010        }
1011    }
1012
1013    /// Resolve the active theme by name, falling back to the default.
1014    ///
1015    /// The resolved theme is adapted to the terminal's color depth (truecolor
1016    /// themes are quantized on 256-color terminals and mapped to role-semantic
1017    /// ANSI slots on 16-color terminals).
1018    pub fn get_theme(&self) -> Theme {
1019        let theme = if self.theme.is_empty() {
1020            Theme::default()
1021        } else {
1022            self.theme_list()
1023                .into_iter()
1024                .find(|t| t.name == self.theme)
1025                .unwrap_or_default()
1026        };
1027        theme.adapt_to_terminal()
1028    }
1029}
1030
1031/// Path helpers shared by this file's test modules.
1032#[cfg(test)]
1033mod test_paths {
1034    use std::path::PathBuf;
1035
1036    /// Builds a genuinely absolute path for the host from `/`-separated
1037    /// components.
1038    ///
1039    /// A literal like `"/config/dir"` is absolute on Unix but merely *rooted*
1040    /// on Windows, where [`Path::is_absolute`] also wants a prefix (`C:\`).
1041    /// Passing the literal straight in doesn't just weaken these tests there,
1042    /// it inverts them: `expand_path` sees a relative path, rebases it onto
1043    /// `base`, and the `is_absolute` assertion then fails on behavior that is
1044    /// in fact correct.
1045    ///
1046    /// [`Path::is_absolute`]: std::path::Path::is_absolute
1047    pub(super) fn absolute(unix_style: &str) -> PathBuf {
1048        let trimmed = unix_style.trim_start_matches('/');
1049        if cfg!(windows) {
1050            PathBuf::from(format!("C:\\{}", trimmed.replace('/', "\\")))
1051        } else {
1052            PathBuf::from(format!("/{trimmed}"))
1053        }
1054    }
1055
1056    /// The same path as a TOML string literal's contents — Windows separators
1057    /// have to survive TOML's own escaping.
1058    pub(super) fn absolute_toml(unix_style: &str) -> String {
1059        absolute(unix_style).to_string_lossy().replace('\\', "\\\\")
1060    }
1061}
1062
1063#[cfg(test)]
1064#[allow(clippy::field_reassign_with_default)]
1065mod tests {
1066    use super::test_paths::absolute;
1067    use super::*;
1068
1069    #[test]
1070    fn default_workspace_suggestion_is_under_home() {
1071        let suggestion = AppSettings::default_workspace_suggestion();
1072        if let Some(p) = suggestion {
1073            assert!(p.ends_with("kimun-notes"));
1074            assert!(p.is_absolute());
1075        }
1076        // None is acceptable only when the platform has no home dir.
1077    }
1078
1079    #[test]
1080    fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
1081        // RED: fails to compile because load_theme_from_path doesn't exist.
1082        // GREEN: method exists, returns Err, and does NOT create the file.
1083        let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
1084        let _ = std::fs::remove_file(&path); // ensure clean state
1085
1086        let result = AppSettings::load_theme_from_path(&path);
1087
1088        assert!(result.is_err(), "should return Err when file is absent");
1089        assert!(!path.exists(), "must not create the file as a side effect");
1090    }
1091
1092    #[test]
1093    fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
1094        // After a corrupt file is removed, no replacement must be written.
1095        let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
1096        std::fs::write(&path, b"not valid toml {{{{").unwrap();
1097
1098        let result = AppSettings::load_theme_from_path(&path);
1099
1100        assert!(result.is_err(), "should return Err for corrupt TOML");
1101        // The user's file must SURVIVE a parse error (a typo must never
1102        // delete a hand-authored theme).
1103        assert!(path.exists(), "corrupt theme file must not be deleted");
1104        std::fs::remove_file(&path).ok();
1105    }
1106
1107    #[test]
1108    fn default_keybindings_quit_matches_canonical_combo() {
1109        let kb = default_keybindings();
1110        let combo = crate::keys::default_quit_combo();
1111        assert_eq!(
1112            kb.get_action(&combo),
1113            Some(ActionShortcuts::Quit),
1114            "default_keybindings() must bind default_quit_combo() to Quit so the \
1115             deserialize safety net can recover an unreachable app"
1116        );
1117    }
1118
1119    #[test]
1120    fn autosave_interval_defaults_to_five() {
1121        let settings = AppSettings::default();
1122        assert_eq!(settings.autosave_interval_secs, 5);
1123    }
1124
1125    #[test]
1126    fn autosave_interval_deserializes_from_toml() {
1127        let toml = "autosave_interval_secs = 30\n";
1128        let settings: AppSettings = toml::from_str(toml).unwrap();
1129        assert_eq!(settings.autosave_interval_secs, 30);
1130    }
1131
1132    #[test]
1133    fn autosave_interval_defaults_when_missing_from_toml() {
1134        let toml = ""; // no autosave_interval_secs key
1135        let settings: AppSettings = toml::from_str(toml).unwrap();
1136        assert_eq!(settings.autosave_interval_secs, 5);
1137    }
1138
1139    /// Verify the full load path: TOML with FileOperations = ["F2"] → keybinding lookup.
1140    #[test]
1141    fn f2_file_operations_survives_toml_deserialize() {
1142        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1143        use crate::keys::key_strike::KeyStrike;
1144
1145        let toml = r#"
1146[key_bindings]
1147FileOperations = ["F2"]
1148"#;
1149        let settings: AppSettings = toml::from_str(toml).unwrap();
1150        let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
1151        let action = settings.key_bindings.get_action(&f2);
1152        assert_eq!(
1153            action,
1154            Some(ActionShortcuts::FileOperations),
1155            "F2 should survive deserialization and map to FileOperations"
1156        );
1157    }
1158
1159    /// Verify merge_missing_default_bindings adds F2 when absent from config.
1160    #[test]
1161    fn merge_adds_f2_when_absent() {
1162        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1163        use crate::keys::key_strike::KeyStrike;
1164
1165        // Settings with no FileOperations binding
1166        let toml = r#"
1167[key_bindings]
1168Quit = ["ctrl&Q"]
1169"#;
1170        let mut settings: AppSettings = toml::from_str(toml).unwrap();
1171        settings.merge_missing_default_bindings();
1172
1173        let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
1174        let action = settings.key_bindings.get_action(&f2);
1175        assert_eq!(
1176            action,
1177            Some(ActionShortcuts::FileOperations),
1178            "merge_missing_default_bindings should add F2 → FileOperations"
1179        );
1180    }
1181
1182    #[test]
1183    fn clear_workspace_removes_the_current_workspace_entry() {
1184        let mut settings = AppSettings::default();
1185        let mut wc = WorkspaceConfig::new_empty();
1186        wc.add_workspace("vault1".to_string(), absolute("/tmp/vault1"))
1187            .unwrap();
1188        settings.workspace_config = Some(wc);
1189        // Assert precondition: add_workspace auto-selects the first workspace
1190        assert_eq!(
1191            settings
1192                .workspace_config
1193                .as_ref()
1194                .unwrap()
1195                .global
1196                .current_workspace,
1197            "vault1"
1198        );
1199        settings.clear_workspace();
1200        let wc = settings.workspace_config.as_ref().unwrap();
1201        assert!(
1202            wc.workspaces.is_empty(),
1203            "workspace entry should be removed"
1204        );
1205        assert!(
1206            wc.global.current_workspace.is_empty(),
1207            "current_workspace should be empty"
1208        );
1209    }
1210
1211    #[test]
1212    fn clear_workspace_preserves_other_workspaces() {
1213        let mut settings = AppSettings::default();
1214        let mut wc = WorkspaceConfig::new_empty();
1215        wc.add_workspace("vault1".to_string(), absolute("/tmp/vault1"))
1216            .unwrap();
1217        wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
1218            .unwrap();
1219        wc.global.current_workspace = "vault1".to_string();
1220        settings.workspace_config = Some(wc);
1221        settings.clear_workspace();
1222        let wc = settings.workspace_config.as_ref().unwrap();
1223        assert!(
1224            !wc.workspaces.contains_key("vault1"),
1225            "active workspace should be removed"
1226        );
1227        assert!(
1228            wc.workspaces.contains_key("vault2"),
1229            "other workspaces should be preserved"
1230        );
1231        assert!(
1232            wc.global.current_workspace.is_empty(),
1233            "current_workspace should be empty"
1234        );
1235    }
1236}
1237
1238#[cfg(test)]
1239mod backend_tests {
1240    use super::test_paths::{absolute, absolute_toml};
1241    use super::*;
1242
1243    #[test]
1244    fn a_config_still_saying_textarea_loads() {
1245        // The value was renamed; a config written by an older version must keep
1246        // working. This has to be an alias rather than a migration entry, because
1247        // migrations run after deserialisation — an unknown variant fails first.
1248        #[derive(serde::Deserialize)]
1249        struct Holder {
1250            editor_backend: EditorBackendSetting,
1251        }
1252        let old: Holder = toml::from_str("editor_backend = \"textarea\"").expect("still loads");
1253        assert_eq!(old.editor_backend, EditorBackendSetting::Plain);
1254    }
1255
1256    #[test]
1257    fn the_backend_is_written_back_as_plain() {
1258        let written = toml::to_string(&AppSettings::default()).expect("serialises");
1259        assert!(
1260            written.contains("editor_backend = \"plain\""),
1261            "a saved config should name the value as it is now: {written}"
1262        );
1263    }
1264
1265    #[test]
1266    fn default_backend_is_plain() {
1267        let settings = AppSettings::default();
1268        assert!(matches!(
1269            settings.editor_backend,
1270            EditorBackendSetting::Plain
1271        ));
1272    }
1273
1274    #[test]
1275    fn nvim_backend_round_trips_toml() {
1276        let toml = "editor_backend = \"nvim\"\n";
1277        let parsed: AppSettings = toml::from_str(toml).unwrap();
1278        assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
1279    }
1280
1281    #[test]
1282    fn editor_backend_vim_roundtrips_through_toml() {
1283        #[derive(serde::Serialize, serde::Deserialize)]
1284        struct W {
1285            editor_backend: EditorBackendSetting,
1286        }
1287        let w = W {
1288            editor_backend: EditorBackendSetting::Vim,
1289        };
1290        let s = toml::to_string(&w).unwrap();
1291        assert!(s.contains("editor_backend = \"vim\""), "serialized: {s}");
1292        let back: W = toml::from_str(&s).unwrap();
1293        assert_eq!(back.editor_backend, EditorBackendSetting::Vim);
1294    }
1295
1296    /// Every action the default keymap binds must keep at least one chord
1297    /// that survives a terminal without the kitty keyboard protocol — the
1298    /// weakest terminal kimün supports, and the common case on Linux and
1299    /// macOS.
1300    ///
1301    /// This is the guard that `Ctrl+I` needed and did not have: it was bound
1302    /// to Italic for as long as nobody noticed that `0x09` is Tab's byte, and
1303    /// nothing failed. An action with several chords passes on any one of
1304    /// them, which is exactly why `OpenPreferences` leads with F4 and keeps
1305    /// `Ctrl+,` as the alias rather than the reverse.
1306    #[test]
1307    fn every_default_action_keeps_a_chord_a_legacy_terminal_can_send() {
1308        use crate::keys::reachability::{TerminalKeys, reach};
1309
1310        for (action, combos) in default_keybindings().to_hashmap() {
1311            let verdicts: Vec<String> = combos
1312                .iter()
1313                .map(|c| format!("{c}: {}", reach(*c, TerminalKeys::LEGACY)))
1314                .collect();
1315            assert!(
1316                combos
1317                    .iter()
1318                    .any(|c| reach(*c, TerminalKeys::LEGACY).is_ok()),
1319                "{action} has no chord a legacy terminal can send: {}",
1320                verdicts.join("; ")
1321            );
1322        }
1323    }
1324
1325    /// The other half of the rule above, made explicit because it reads like
1326    /// a bug otherwise: an unreachable chord is fine *as an alias*. `Ctrl+,`
1327    /// is the classic Preferences chord and most terminals send nothing
1328    /// distinct for it, so F4 carries the action and `Ctrl+,` is a bonus on
1329    /// terminals that can manage it. Drop F4 and the invariant fires.
1330    #[test]
1331    fn an_unreachable_chord_is_allowed_as_an_alias() {
1332        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1333        use crate::keys::reachability::{Reach, TerminalKeys, reach};
1334
1335        let kb = default_keybindings();
1336        let combos = kb.combos_for(&ActionShortcuts::OpenPreferences);
1337        let ctrl_comma = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::Comma);
1338        assert!(combos.contains(&ctrl_comma), "the alias is still bound");
1339        assert_eq!(
1340            reach(ctrl_comma, TerminalKeys::LEGACY),
1341            Reach::Untransmitted,
1342            "and it is still the unreachable one"
1343        );
1344        assert!(
1345            combos.contains(&KeyCombo::new(KeyModifiers::new(), KeyStrike::F4)),
1346            "F4 is what actually carries Preferences"
1347        );
1348    }
1349
1350    /// All formatting lives on the leader (`Ctrl+G t b` / `t i` / `t s`), so
1351    /// the chord table binds no text action at all. `Ctrl+I` cannot work
1352    /// outside the kitty keyboard protocol — it is Tab's byte — and having
1353    /// `Ctrl+B` and `Ctrl+S` work while it silently did not was the
1354    /// inconsistency. Users who want a chord back can still bind one: the
1355    /// `Text(..)` actions stay parseable from config and the shortcut tier
1356    /// still claims them.
1357    #[test]
1358    fn the_default_table_binds_no_text_action() {
1359        let kb = default_keybindings();
1360        let bound: Vec<String> = kb
1361            .to_hashmap()
1362            .keys()
1363            .filter_map(|a| match a {
1364                ActionShortcuts::Text(t) => Some(format!("{t:?}")),
1365                _ => None,
1366            })
1367            .collect();
1368        assert!(
1369            bound.is_empty(),
1370            "formatting belongs on the leader, but these still hold chords: {bound:?}"
1371        );
1372    }
1373
1374    /// Ctrl+L and Ctrl+T belong to focus and the drawer. `Text(Link)` and
1375    /// `Text(ToggleHeader)` once asked for the same two chords and lost them
1376    /// in silence; the builder now panics on that, and
1377    /// `the_default_table_binds_no_text_action` covers the formatting side.
1378    /// What is left to pin here is who owns the two chords.
1379    #[test]
1380    fn ctrl_l_and_ctrl_t_belong_to_focus_and_the_drawer() {
1381        let kb = default_keybindings();
1382        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1383        let ctrl = |key| KeyCombo::new(KeyModifiers::new().and_ctrl(), key);
1384        assert_eq!(
1385            kb.get_action(&ctrl(KeyStrike::KeyL)),
1386            Some(ActionShortcuts::FocusEditor)
1387        );
1388        assert_eq!(
1389            kb.get_action(&ctrl(KeyStrike::KeyT)),
1390            Some(ActionShortcuts::ToggleSidebar)
1391        );
1392    }
1393
1394    /// Every spelling of the setting parses, and an older config without the
1395    /// key keeps the Ctrl-H chord it has always had.
1396    #[test]
1397    fn ctrl_h_parses_and_defaults_to_auto() {
1398        for (written, expected) in [
1399            ("auto", CtrlHSetting::Auto),
1400            ("backspace", CtrlHSetting::Backspace),
1401            ("chord", CtrlHSetting::Chord),
1402        ] {
1403            let s = toml::from_str::<AppSettings>(&format!("ctrl_h = \"{written}\"\n"))
1404                .unwrap_or_else(|e| panic!("ctrl_h = \"{written}\" must parse: {e}"));
1405            assert_eq!(s.ctrl_h, expected);
1406        }
1407        assert_eq!(
1408            toml::from_str::<AppSettings>("theme = \"gruvbox_dark\"\n")
1409                .unwrap()
1410                .ctrl_h,
1411            CtrlHSetting::Auto,
1412            "a config written before the setting existed must not change behaviour"
1413        );
1414    }
1415
1416    /// Workspace cache/history paths are absolute however the settings were
1417    /// built — including the two constructions that never run `resolve_paths`:
1418    /// a bare `default()` and a plain deserialize. A relative one anchors the
1419    /// index on the process's working directory, so every kimün started from
1420    /// the same place would share one index.
1421    #[test]
1422    fn workspace_paths_are_absolute_without_resolve_paths() {
1423        for settings in [
1424            AppSettings::default(),
1425            toml::from_str::<AppSettings>("theme = \"gruvbox_dark\"\n").unwrap(),
1426        ] {
1427            let cache = settings.index_for("w");
1428            let history = settings.history_for("w");
1429            assert!(
1430                cache.path().as_path().is_absolute(),
1431                "cache path not absolute: {cache}"
1432            );
1433            assert!(
1434                history.path().as_path().is_absolute(),
1435                "history path not absolute: {history}"
1436            );
1437        }
1438    }
1439
1440    // `expand_path` and its tests moved to `kimun_core::system`, where the
1441    // path rules now live; what stays here is how *settings* anchor them.
1442
1443    #[test]
1444    fn resolve_paths_populates_resolved_path() {
1445        let base = tempfile::TempDir::new().unwrap();
1446        let notes = base.path().join("notes");
1447        std::fs::create_dir_all(&notes).unwrap();
1448
1449        let toml = r#"
1450config_version = 2
1451[global]
1452current_workspace = "test"
1453[workspaces.test]
1454path = "notes"
1455last_paths = []
1456created = "2026-01-01T00:00:00Z"
1457"#
1458        .to_string();
1459        let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1460        settings.resolve_paths(&SystemPath::try_absolute(base.path()).unwrap());
1461
1462        let wc = settings.workspace_config.as_ref().unwrap();
1463        let entry = wc.workspaces.get("test").unwrap();
1464        // Original path preserved
1465        assert_eq!(entry.path, PathBuf::from("notes"));
1466        // Resolved path is absolute
1467        assert!(entry.resolved_path.is_some());
1468        assert!(entry.effective_path().is_absolute());
1469    }
1470
1471    /// A config file that does not exist yet still has to yield paths anchored
1472    /// to its own directory. `index_for`/`history_for` fall back to
1473    /// the *raw* `cache_dir`/`history_dir` when nothing resolved them, and
1474    /// those default to `.` and `history` — relative, so unresolved defaults
1475    /// put the workspace index wherever the process happens to be running.
1476    /// Every process sharing a working directory then shares one index file:
1477    /// in CI that is several test binaries racing to create the same schema
1478    /// ("table appData already exists"), and for a user it is a first-run
1479    /// index dropped in whatever directory they launched from.
1480    #[test]
1481    fn load_from_file_resolves_paths_for_a_config_that_does_not_exist_yet() {
1482        let dir = tempfile::TempDir::new().unwrap();
1483        let config_dir = dir.path().canonicalize().unwrap();
1484        let settings = AppSettings::load_from_file(config_dir.join("config.toml")).unwrap();
1485
1486        let cache = settings.index_for("work");
1487        let history = settings.history_for("work");
1488        assert!(
1489            cache.path().as_path().starts_with(&config_dir),
1490            "cache path must sit next to the config file, got {cache}"
1491        );
1492        assert!(
1493            history.path().as_path().starts_with(&config_dir),
1494            "history path must sit next to the config file, got {history}"
1495        );
1496    }
1497
1498    /// Same requirement on the other branch that hands back bare defaults: an
1499    /// unparseable config is renamed aside and replaced, and those
1500    /// replacements need resolving just as much.
1501    #[test]
1502    fn load_from_file_resolves_paths_when_the_config_is_corrupt() {
1503        let dir = tempfile::TempDir::new().unwrap();
1504        let config_dir = dir.path().canonicalize().unwrap();
1505        let config_path = config_dir.join("config.toml");
1506        std::fs::write(&config_path, "not = valid toml [[[").unwrap();
1507
1508        let settings = AppSettings::load_from_file(config_path).unwrap();
1509
1510        let cache = settings.index_for("work");
1511        assert!(
1512            cache.path().as_path().starts_with(&config_dir),
1513            "cache path must sit next to the config file, got {cache}"
1514        );
1515    }
1516
1517    #[test]
1518    fn resolve_paths_absolute_no_resolved_path() {
1519        // Host-absolute, not just `/`-rooted: on Windows `/absolute/notes` is
1520        // a *relative* path, so it would pick up a resolved_path and invert
1521        // both assertions below.
1522        let toml = format!(
1523            r#"
1524config_version = 2
1525[global]
1526current_workspace = "test"
1527[workspaces.test]
1528path = "{}"
1529last_paths = []
1530created = "2026-01-01T00:00:00Z"
1531"#,
1532            absolute_toml("/absolute/notes")
1533        );
1534        let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1535        settings.resolve_paths(&SystemPath::try_absolute(absolute("/config")).unwrap());
1536
1537        let wc = settings.workspace_config.as_ref().unwrap();
1538        let entry = wc.workspaces.get("test").unwrap();
1539        // No resolved_path needed for already-absolute paths
1540        assert!(entry.resolved_path.is_none());
1541        assert_eq!(*entry.effective_path(), absolute("/absolute/notes"));
1542    }
1543}
1544
1545#[cfg(test)]
1546mod sort_settings_tests {
1547    use super::*;
1548
1549    #[test]
1550    fn group_directories_defaults_off() {
1551        let s = AppSettings::default();
1552        assert!(!s.group_directories);
1553    }
1554
1555    #[test]
1556    fn open_sort_dialog_is_bound_by_default() {
1557        let s = AppSettings::default();
1558        let map = s.key_bindings.to_hashmap();
1559        assert!(
1560            map.contains_key(&ActionShortcuts::OpenSortDialog),
1561            "OpenSortDialog must have a default binding"
1562        );
1563    }
1564}