Skip to main content

kimun_notes/settings/
mod.rs

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