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::config_dir::get_or_create_config_dir;
4use crate::settings::themes::Theme;
5use crate::settings::workspace_config::WorkspaceConfig;
6use std::io::{Read, Write};
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, RwLock};
9
10use std::fs::{self, File};
11
12/// Errors from loading and saving settings and themes. Typed at this seam so
13/// callers can match on the failure; the binary's eyre top level (CLI, main)
14/// wraps it automatically via `?`.
15#[derive(Debug, thiserror::Error)]
16pub enum SettingsError {
17    #[error(transparent)]
18    Io(#[from] std::io::Error),
19    #[error("cannot serialize settings: {0}")]
20    Serialize(#[from] toml::ser::Error),
21    #[error("corrupt theme file: {0}")]
22    CorruptTheme(toml::de::Error),
23    #[error("config migration failed: {0}")]
24    Migration(String),
25}
26
27/// Shared settings handle — all screens and components reference the same instance.
28pub type SharedSettings = Arc<RwLock<AppSettings>>;
29use kimun_core::nfs::VaultPath;
30
31use crate::keys::KeyBindings;
32mod config_dir;
33pub(crate) use config_dir::get_home_dir;
34pub mod config_migration;
35pub mod history;
36pub mod icons;
37pub mod themes;
38pub mod workspace_config;
39
40// ---------------------------------------------------------------------------
41// Sort settings types (shared between AppSettings and sorting UI)
42// ---------------------------------------------------------------------------
43
44#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum SortFieldSetting {
47    Name,
48    Title,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum SortOrderSetting {
54    Ascending,
55    Descending,
56}
57
58#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum EditorBackendSetting {
61    /// The built-in engine, keys applied directly.
62    ///
63    /// `alias` rather than a migration entry: `ConfigMigration::run` happens
64    /// *after* deserialisation, so a config still saying `textarea` would fail to
65    /// load before any migration could rewrite it. The alias upgrades on the next
66    /// save, when the setting is written back as `plain`.
67    #[default]
68    #[serde(alias = "textarea")]
69    Plain,
70    Nvim,
71    Vim,
72}
73
74// pub mod theme;
75
76#[cfg(debug_assertions)]
77const CONFIG_DIR: &str = "kimun_debug";
78#[cfg(not(debug_assertions))]
79const CONFIG_DIR: &str = "kimun";
80
81/// Path to kimün's config directory (`~/.config/kimun`, or `kimun_debug` in
82/// debug builds), creating it if needed. Single source of truth for the
83/// debug/release directory name — used by the update module for the install
84/// marker and update-state file.
85pub fn config_dir() -> std::io::Result<PathBuf> {
86    get_or_create_config_dir(CONFIG_DIR)
87}
88
89const BASE_CONFIG_FILE: &str = "config.toml";
90const THEMES_DIR: &str = "themes";
91const CACHE_FILE_EXT: &str = "kimuncache";
92const HISTORY_FILE_EXT: &str = "txt";
93
94const CONFIG_HEADER: &str = "\
95# ─── Kimün configuration ────────────────────────────────────────────────────
96#
97# KEY BINDINGS
98# ────────────
99# Supported combinations:
100#   - ctrl and/or alt (with optional shift) + a letter (a-z)
101#   - bare F-key (F1–F12, no modifier required)
102# Any combo that does not follow these rules is silently ignored when loaded.
103#
104# Format per action:
105#   ActionName = [\"<modifiers> & <letter>\", ...]
106#
107# Available modifiers (combine with +):  ctrl   alt   shift
108#
109# Examples:
110#   Quit         = [\"ctrl&Q\"]            # Ctrl+Q
111#   SearchNotes  = [\"ctrl&K\"]            # Ctrl+K
112#   OpenNote     = [\"ctrl&O\"]            # Ctrl+O  (fuzzy file finder)
113#   OpenSettings = [\"F4\", \"ctrl&,\"]     # F4 (Ctrl+, alias)
114#   NewJournal   = [\"ctrl&J\"]            # Ctrl+J
115#   FileOperations = [\"F2\"]              # F2  (open file-ops menu: delete/rename/move)
116#   Leader       = [\"ctrl&G\"]            # Ctrl+G  (leader gateway: Ctrl+G f f, ...)
117#   OpenCommandPalette = [\"ctrl&P\"]      # Ctrl+P  (every leader command, fuzzy)
118#
119# OTHER SETTINGS
120# ──────────────
121#   theme             = \"Gruvbox Dark\"   # or any built-in / custom theme name
122#   leader_timeout_ms = 400               # hesitation before the which-key menu
123#
124# LEADER TREE OVERRIDES
125# ─────────────────────
126#   Remap, add, or remove leader sequences ([leader.bind]) and rename group
127#   captions ([leader.labels]). Keys are the sequence AFTER the gateway;
128#   bind values are action ids (see the cheatsheet) or \"none\" to unbind.
129#   [leader.bind]
130#   \"o f\" = \"find.files\"     # remap: leader o f now opens the file picker
131#   \"x\"   = \"note.daily\"     # add:   leader x opens today's journal
132#   \"g p\" = \"none\"           # remove the git-sync stub binding
133#   [leader.labels]
134#   \"f\"   = \"+search\"        # rename the +find group caption
135#
136# ─────────────────────────────────────────────────────────────────────────────
137";
138
139#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
140pub struct AppSettings {
141    // Phase 2 config
142    #[serde(default)]
143    pub config_version: u32,
144    #[serde(flatten, skip_serializing_if = "Option::is_none")]
145    pub workspace_config: Option<WorkspaceConfig>,
146
147    // Legacy Phase 1 fields — only kept for migration detection/deserialization.
148    // Never written back: workspace_dir is taken by migration, last_paths is
149    // moved into workspace_config entries.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub workspace_dir: Option<PathBuf>,
152    #[serde(default, skip_serializing)]
153    pub last_paths: Vec<VaultPath>,
154
155    // Preserved fields
156    #[serde(default)]
157    pub theme: String,
158    #[serde(default = "default_cache_dir")]
159    pub cache_dir: PathBuf,
160    #[serde(skip)]
161    cache_dir_resolved: Option<PathBuf>,
162
163    #[serde(default = "default_history_dir")]
164    pub history_dir: PathBuf,
165    #[serde(skip)]
166    history_dir_resolved: Option<PathBuf>,
167    #[serde(skip, default = "yes")]
168    needs_indexing: bool,
169    #[serde(default = "default_keybindings")]
170    pub key_bindings: KeyBindings,
171    #[serde(default = "default_autosave_interval")]
172    pub autosave_interval_secs: u64,
173    /// Hesitation timeout (ms) before the which-key overlay reveals itself
174    /// during a pending leader sequence. Sequences typed faster never wait.
175    #[serde(default = "default_leader_timeout_ms")]
176    pub leader_timeout_ms: u64,
177    /// Leader-tree customization: `[leader.bind]` sequence→action-id
178    /// overrides and `[leader.labels]` group captions. Applied over the
179    /// built-in tree.
180    #[serde(default)]
181    pub leader: LeaderConfig,
182    #[serde(default = "default_use_nerd_fonts")]
183    pub use_nerd_fonts: bool,
184    #[serde(default)]
185    pub editor_backend: EditorBackendSetting,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub nvim_path: Option<std::path::PathBuf>,
188    #[serde(default = "default_sort_field")]
189    pub default_sort_field: SortFieldSetting,
190    #[serde(default = "default_sort_order")]
191    pub default_sort_order: SortOrderSetting,
192    #[serde(default = "default_journal_sort_field")]
193    pub journal_sort_field: SortFieldSetting,
194    #[serde(default = "default_journal_sort_order")]
195    pub journal_sort_order: SortOrderSetting,
196    #[serde(default)]
197    pub group_directories: bool,
198    /// Custom config file path. `None` means use the default location.
199    /// Not serialized — it's a runtime-only override.
200    #[serde(skip)]
201    pub config_file: Option<PathBuf>,
202}
203
204fn default_keybindings() -> KeyBindings {
205    let mut kb = KeyBindings::empty();
206    kb.batch_add()
207        .with_ctrl()
208        .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
209        .add(KeyStrike::KeyO, ActionShortcuts::OpenNote)
210        .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
211        .add(KeyStrike::KeyI, ActionShortcuts::Text(TextAction::Italic))
212        .add(
213            KeyStrike::KeyU,
214            ActionShortcuts::Text(TextAction::Underline),
215        )
216        .add(
217            KeyStrike::KeyS,
218            ActionShortcuts::Text(TextAction::Strikethrough),
219        )
220        .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Link))
221        .add(
222            KeyStrike::KeyT,
223            ActionShortcuts::Text(TextAction::ToggleHeader),
224        )
225        // =============================
226        // We add shift to the modifiers
227        // =============================
228        .with_shift()
229        .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Image));
230
231    // TUI navigation shortcuts (always Ctrl — terminal apps don't use Cmd/Meta).
232    // NOTE: the `Quit` entry must match `crate::keys::default_quit_combo()`,
233    // which the deserialize safety net uses to recover an unreachable app.
234    kb.batch_add()
235        .with_ctrl()
236        // Ctrl-P is the command palette (decision 2026-06-05); settings
237        // live on Ctrl+Shift+P.
238        .add(KeyStrike::KeyP, ActionShortcuts::OpenCommandPalette)
239        .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
240        .add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
241        // Drawer toggle. Deliberate spec deviation: the spec's Tier-0 puts
242        // this on Ctrl-B, but Ctrl-B stays Bold (decision 2026-06-05) — the
243        // drawer toggle lives on Ctrl-T.
244        .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
245        .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
246        // Leader gateway. Spec deviation: spec says Ctrl-K, which stays the
247        // note browser; the gateway lives on Ctrl-G (decision 2026-06-05).
248        .add(KeyStrike::KeyG, ActionShortcuts::Leader)
249        // FollowLink's always-works binding; Ctrl+Enter also follows on
250        // kitty-protocol terminals (hardcoded in the editor screen).
251        .add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
252        .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
253        .add(KeyStrike::KeyL, ActionShortcuts::FocusEditor)
254        .add(KeyStrike::KeyW, ActionShortcuts::QuickNote)
255        // Ctrl-E opens (or switches the drawer to) the file browser; the
256        // pure drawer toggle is Ctrl-T above. ToggleQueryPanel has no
257        // default binding — FIND stays reachable via the rail and leader.
258        .add(KeyStrike::KeyE, ActionShortcuts::OpenFileBrowser)
259        .add(KeyStrike::KeyF, ActionShortcuts::FindInBuffer)
260        // Copy the selected list row. Shares Ctrl-Y with the editor's redo,
261        // resolved by focus: the shortcut tier only claims it away from the
262        // editor. Sourced from `default_yank_combo` so SearchList,
263        // which claims the same chord internally, cannot drift from it.
264        .add(
265            crate::keys::default_yank_combo().key,
266            ActionShortcuts::YankRow,
267        );
268
269    // Settings — F4 (no modifier, reliable in all terminals) plus the classic
270    // Ctrl+, kept as an alias. Ctrl+, doesn't transmit a distinct code on many
271    // terminals outside the kitty protocol, so F4 is the dependable default.
272    // (Ctrl+Shift+P collides with kitty's default hints-kitten chord prefix,
273    // which holds the screen mid-chord, so it isn't used.)
274    kb.batch_add()
275        .add(KeyStrike::F4, ActionShortcuts::OpenPreferences);
276    kb.batch_add()
277        .with_ctrl()
278        .add(KeyStrike::Comma, ActionShortcuts::OpenPreferences);
279
280    // File operations menu (F2 — no modifier, reliable in all terminals).
281    kb.batch_add()
282        .add(KeyStrike::F2, ActionShortcuts::FileOperations);
283
284    kb.batch_add()
285        .add(KeyStrike::F3, ActionShortcuts::OpenSavedSearches);
286
287    // Ask workspace (F6 — free key; the feature is inert without a server).
288    kb.batch_add().add(KeyStrike::F6, ActionShortcuts::OpenAsk);
289
290    // Workspace switcher — F5 (moved off F4, which is now Settings).
291    kb.batch_add()
292        .add(KeyStrike::F5, ActionShortcuts::SwitchWorkspace);
293
294    // Ctrl+D — save the current query to saved searches. Ctrl-only by design:
295    // Ctrl+Shift is unreliable on some terminals, Ctrl+S is taken by
296    // Strikethrough, and Ctrl+{A,C,X,Z} are claimed by the editor. Ctrl+D is
297    // the only free, terminal-safe Ctrl combo.
298    kb.batch_add()
299        .with_ctrl()
300        .add(KeyStrike::KeyD, ActionShortcuts::SaveCurrentQuery);
301
302    kb
303}
304
305fn yes() -> bool {
306    true
307}
308
309fn default_autosave_interval() -> u64 {
310    5
311}
312
313fn default_leader_timeout_ms() -> u64 {
314    400
315}
316
317/// The `[leader]` config section: binding overrides + group captions.
318#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
319pub struct LeaderConfig {
320    /// `[leader.bind]`: sequence (after the gateway, e.g. `"o f"` / `"x"`) →
321    /// action id (see the cheatsheet) or `"none"` to unbind.
322    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
323    pub bind: std::collections::BTreeMap<String, String>,
324    /// `[leader.labels]`: group sequence (e.g. `"f"`) → caption shown in the
325    /// which-key overlay and cheatsheet.
326    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
327    pub labels: std::collections::BTreeMap<String, String>,
328}
329
330impl AppSettings {
331    /// Suggested directory for a first workspace (`~/kimun-notes`). `None`
332    /// when the home directory cannot be determined.
333    pub fn default_workspace_suggestion() -> Option<PathBuf> {
334        config_dir::get_home_dir()
335            .ok()
336            .map(|h| h.join("kimun-notes"))
337    }
338
339    /// The leader tree with this config's `[leader]` overrides applied — the
340    /// ONE constructor every surface (engine, which-key, cheatsheet, palette)
341    /// must use, so they can never disagree.
342    pub fn leader_tree(&self) -> crate::keys::leader::LeaderNode {
343        let tree = crate::keys::leader::apply_overrides(
344            crate::keys::leader::leader_tree(),
345            self.leader
346                .bind
347                .iter()
348                .map(|(k, v)| (k.as_str(), v.as_str())),
349        );
350        crate::keys::leader::apply_labels(
351            tree,
352            self.leader
353                .labels
354                .iter()
355                .map(|(k, v)| (k.as_str(), v.as_str())),
356        )
357    }
358}
359
360fn default_cache_dir() -> PathBuf {
361    PathBuf::from(".")
362}
363
364fn default_history_dir() -> PathBuf {
365    PathBuf::from("history")
366}
367
368fn default_use_nerd_fonts() -> bool {
369    false
370}
371
372fn default_sort_field() -> SortFieldSetting {
373    SortFieldSetting::Name
374}
375
376fn default_sort_order() -> SortOrderSetting {
377    SortOrderSetting::Ascending
378}
379
380fn default_journal_sort_field() -> SortFieldSetting {
381    SortFieldSetting::Name
382}
383
384fn default_journal_sort_order() -> SortOrderSetting {
385    SortOrderSetting::Descending
386}
387
388impl Default for AppSettings {
389    fn default() -> Self {
390        Self {
391            config_version: 0,
392            workspace_config: None,
393            last_paths: vec![],
394            workspace_dir: None,
395            theme: Default::default(),
396            cache_dir: default_cache_dir(),
397            cache_dir_resolved: None,
398            history_dir: default_history_dir(),
399            history_dir_resolved: None,
400            needs_indexing: true,
401            key_bindings: default_keybindings(),
402            autosave_interval_secs: default_autosave_interval(),
403            leader_timeout_ms: default_leader_timeout_ms(),
404            leader: LeaderConfig::default(),
405            use_nerd_fonts: false,
406            editor_backend: EditorBackendSetting::Plain,
407            nvim_path: None,
408            default_sort_field: default_sort_field(),
409            default_sort_order: default_sort_order(),
410            journal_sort_field: default_journal_sort_field(),
411            journal_sort_order: default_journal_sort_order(),
412            group_directories: false,
413            config_file: None,
414        }
415    }
416}
417
418impl AppSettings {
419    pub fn theme_list(&self) -> Vec<Theme> {
420        let mut list = Theme::builtins();
421        list.append(&mut Self::load_custom_themes());
422        // Merge the user's default.toml override if present.
423        if let Ok(custom_default) = Self::load_default_theme() {
424            list.push(custom_default);
425        }
426        list.sort_by(|a, b| a.name.cmp(&b.name));
427        list
428    }
429
430    fn default_config_file_path() -> Result<PathBuf, SettingsError> {
431        let config_home = get_or_create_config_dir(CONFIG_DIR)?;
432        Ok(config_home.join(BASE_CONFIG_FILE))
433    }
434
435    fn get_config_file_path(&self) -> Result<PathBuf, SettingsError> {
436        if let Some(ref path) = self.config_file {
437            Ok(path.clone())
438        } else {
439            Self::default_config_file_path()
440        }
441    }
442
443    fn get_themes_path() -> Result<PathBuf, SettingsError> {
444        let config_home = get_or_create_config_dir(CONFIG_DIR)?;
445        Ok(config_home.join(THEMES_DIR))
446    }
447
448    fn load_theme_from_path(path: &std::path::Path) -> Result<Theme, SettingsError> {
449        let theme_string = fs::read_to_string(path)?;
450        match toml::from_str::<Theme>(&theme_string) {
451            Ok(theme) => Ok(theme),
452            Err(e) => {
453                // Never delete a user-authored file over a typo — warn and
454                // skip, exactly like load_custom_themes does.
455                tracing::warn!("Skipping unparsable theme file {:?}: {}", path, e);
456                Err(SettingsError::CorruptTheme(e))
457            }
458        }
459    }
460
461    fn load_default_theme() -> Result<Theme, SettingsError> {
462        let theme_path = AppSettings::get_themes_path()?.join("default.toml");
463        Self::load_theme_from_path(&theme_path)
464    }
465
466    fn load_custom_themes() -> Vec<Theme> {
467        let mut themes = Vec::new();
468
469        // Get themes directory, return empty vec if it fails
470        let themes_path = match Self::get_themes_path() {
471            Ok(path) => path,
472            Err(_) => return themes,
473        };
474
475        // Read directory entries, return empty vec if it fails
476        let entries = match fs::read_dir(&themes_path) {
477            Ok(entries) => entries,
478            Err(_) => return themes,
479        };
480
481        // Iterate through all entries in the themes directory
482        for entry in entries.flatten() {
483            let path = entry.path();
484
485            // Skip if not a file
486            if !path.is_file() {
487                continue;
488            }
489
490            // Skip if not a .toml file
491            if path.extension().and_then(|s| s.to_str()) != Some("toml") {
492                continue;
493            }
494
495            // Skip default.toml
496            if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
497                continue;
498            }
499
500            // Try to read and deserialize the theme file
501            match fs::read_to_string(&path)
502                .and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
503            {
504                Ok(theme) => themes.push(theme),
505                Err(e) => tracing::warn!("Skipping theme file {:?}: {}", path, e),
506            }
507        }
508
509        themes
510    }
511
512    /// Whether the startup update check is enabled. Lives in `GlobalConfig`;
513    /// defaults to on when no workspace config exists yet. Single source for
514    /// the four read sites (startup, preferences, onboarding).
515    pub fn update_check(&self) -> bool {
516        self.workspace_config
517            .as_ref()
518            .map(|wc| wc.global.update_check)
519            .unwrap_or(true)
520    }
521
522    /// Whether kimün captures the mouse for in-app use; defaults on when no
523    /// workspace config exists yet. Read at startup (main.rs) and in preferences.
524    pub fn mouse(&self) -> bool {
525        self.workspace_config
526            .as_ref()
527            .map(|wc| wc.global.mouse)
528            .unwrap_or(true)
529    }
530
531    pub fn save_to_disk(&self) -> Result<(), SettingsError> {
532        tracing::debug!("Saving settings to disk");
533        let settings_file_path = self.get_config_file_path()?;
534        let mut file = File::create(settings_file_path)?;
535        file.write_all(CONFIG_HEADER.as_bytes())?;
536        let toml = toml::to_string(&self)?;
537        file.write_all(toml.as_bytes())?;
538        Ok(())
539    }
540
541    pub fn load_from_disk() -> Result<Self, SettingsError> {
542        let settings_file_path = Self::default_config_file_path()?;
543
544        if !settings_file_path.exists() {
545            let default_settings = Self::default();
546            default_settings.save_to_disk()?;
547            Ok(default_settings)
548        } else {
549            let mut settings_file = File::open(&settings_file_path)?;
550
551            let mut toml = String::new();
552            settings_file.read_to_string(&mut toml)?;
553
554            match toml::from_str::<AppSettings>(toml.as_ref()) {
555                Ok(mut setting) => {
556                    setting.config_file = Some(settings_file_path.clone());
557                    let config_dir = settings_file_path
558                        .parent()
559                        .unwrap_or(std::path::Path::new("."));
560                    setting.resolve_paths(config_dir);
561                    if config_migration::ConfigMigration::run(&mut setting)? {
562                        setting.save_to_disk()?;
563                    }
564                    setting.merge_missing_default_bindings();
565                    Ok(setting)
566                }
567                Err(e) => {
568                    tracing::warn!(
569                        "Config file at {:?} could not be parsed ({}). \
570                         Renaming to .corrupt and starting with defaults.",
571                        settings_file_path,
572                        e
573                    );
574                    let corrupt_path = settings_file_path.with_extension("toml.corrupt");
575                    let _ = fs::rename(&settings_file_path, &corrupt_path);
576                    let defaults = Self::default();
577                    defaults.save_to_disk()?;
578                    Ok(defaults)
579                }
580            }
581        }
582    }
583
584    pub fn load_from_file(path: PathBuf) -> Result<Self, SettingsError> {
585        if let Some(parent) = path.parent() {
586            fs::create_dir_all(parent)?;
587        }
588        if !path.exists() {
589            let default_settings = Self {
590                config_file: Some(path),
591                ..Self::default()
592            };
593            default_settings.save_to_disk()?;
594            return Ok(default_settings);
595        }
596        let mut toml_str = String::new();
597        File::open(&path)?.read_to_string(&mut toml_str)?;
598        match toml::from_str::<AppSettings>(&toml_str) {
599            Ok(mut setting) => {
600                setting.config_file = Some(path.clone());
601
602                // Resolve ~ and relative paths against the config file's directory.
603                let config_dir = path.parent().unwrap_or(std::path::Path::new("."));
604                setting.resolve_paths(config_dir);
605
606                // Run config migrations (e.g. Phase 1 → Phase 2 workspace_dir).
607                if config_migration::ConfigMigration::run(&mut setting)? {
608                    setting.save_to_disk()?;
609                }
610
611                setting.merge_missing_default_bindings();
612                Ok(setting)
613            }
614            Err(e) => {
615                tracing::warn!(
616                    "Config file at {:?} could not be parsed ({}). \
617                     Renaming to .corrupt and starting with defaults.",
618                    path,
619                    e
620                );
621                let corrupt_path = path.with_extension("toml.corrupt");
622                let _ = fs::rename(&path, &corrupt_path);
623                let defaults = Self {
624                    config_file: Some(path),
625                    ..Self::default()
626                };
627                defaults.save_to_disk()?;
628                Ok(defaults)
629            }
630        }
631    }
632
633    /// Fills in defaults from `default_keybindings()` that are absent in the
634    /// loaded config: actions with no binding at all, plus default combos
635    /// added in newer versions (e.g. Ctrl-B for the drawer toggle) — as long
636    /// as the combo is not already bound to *any* action. Existing
637    /// user-customised bindings are never overwritten.
638    fn merge_missing_default_bindings(&mut self) {
639        let defaults = default_keybindings().to_hashmap();
640        let mut current = self.key_bindings.to_hashmap();
641        let mut bound: std::collections::HashSet<_> = current.values().flatten().cloned().collect();
642        for (action, combos) in defaults {
643            match current.entry(action) {
644                std::collections::hash_map::Entry::Vacant(e) => {
645                    // Never steal a combo the user has bound to something
646                    // else — insert only the free ones, and claim them so a
647                    // later default in this pass cannot double-bind.
648                    let free: Vec<_> = combos.into_iter().filter(|c| !bound.contains(c)).collect();
649                    if !free.is_empty() {
650                        bound.extend(free.iter().copied());
651                        e.insert(free);
652                    }
653                }
654                std::collections::hash_map::Entry::Occupied(mut e) => {
655                    for combo in combos {
656                        if !bound.contains(&combo) && !e.get().contains(&combo) {
657                            bound.insert(combo);
658                            e.get_mut().push(combo);
659                        }
660                    }
661                }
662            }
663        }
664        self.key_bindings = KeyBindings::from_hashmap(current);
665    }
666
667    // We set a new workspace to work with, remember to save the data
668    // to persist it in disk
669    pub fn set_workspace(&mut self, workspace_path: &PathBuf) {
670        if let Some(current_workspace_dir) = &self.workspace_dir
671            && workspace_path != current_workspace_dir
672        {
673            self.needs_indexing = true;
674        }
675
676        self.workspace_dir = Some(workspace_path.to_owned());
677    }
678
679    /// Removes the active workspace path so the user is prompted to choose a new one.
680    /// Handles both Phase 1 (workspace_dir) and Phase 2 (workspace_config) config formats.
681    ///
682    /// For Phase 2: only the currently active workspace entry is removed; other workspace
683    /// entries in the config are preserved. After this call, `workspace_config` remains
684    /// `Some` but `get_current_workspace()` returns `None`.
685    pub fn clear_workspace(&mut self) {
686        // Phase 1
687        if self.workspace_dir.is_some() {
688            self.workspace_dir = None;
689            self.needs_indexing = true;
690        }
691        // Phase 2
692        if let Some(wc) = &mut self.workspace_config {
693            let key = wc.global.current_workspace.clone();
694            if !key.is_empty() {
695                wc.workspaces.remove(&key);
696            }
697            wc.global.current_workspace = String::new();
698        }
699    }
700
701    /// Resolve the active workspace path from Phase 2 (workspace_config) or
702    /// Phase 1 (workspace_dir). Returns `None` if no workspace is configured.
703    pub fn resolve_workspace_path(&self) -> Option<PathBuf> {
704        self.workspace_config
705            .as_ref()
706            .and_then(|wc| wc.get_current_workspace())
707            .map(|entry| entry.effective_path().clone())
708            .or_else(|| self.workspace_dir.clone())
709    }
710
711    /// Resolve `~` and relative paths in workspace entries.
712    /// Relative paths are resolved against `base` (typically the config file's
713    /// parent directory). Called once after deserialization.
714    fn resolve_paths(&mut self, base: &std::path::Path) {
715        // Legacy workspace_dir — resolve in place (it's a legacy field that
716        // gets consumed by migration anyway).
717        if let Some(ref mut p) = self.workspace_dir {
718            *p = Self::expand_path(p, base);
719        }
720        // Phase 2 workspace entries — populate resolved_path, keep original path intact.
721        if let Some(ref mut wc) = self.workspace_config {
722            for entry in wc.workspaces.values_mut() {
723                let resolved = Self::expand_path(&entry.path, base);
724                if resolved != entry.path {
725                    entry.resolved_path = Some(resolved);
726                }
727            }
728        }
729        self.cache_dir_resolved = Some(Self::expand_path(&self.cache_dir, base));
730        self.history_dir_resolved = Some(Self::expand_path(&self.history_dir, base));
731    }
732
733    /// Expand `~` to the home directory and resolve relative paths against `base`.
734    /// Returns an absolute path. If the resolved path exists on disk, it is
735    /// canonicalized to remove `.` and `..` components.
736    fn expand_path(path: &std::path::Path, base: &std::path::Path) -> PathBuf {
737        let s = path.to_string_lossy();
738        let expanded = if s.starts_with("~/") || s == "~" {
739            if let Ok(home) = config_dir::get_home_dir() {
740                home.join(s.strip_prefix("~/").unwrap_or(""))
741            } else {
742                path.to_path_buf()
743            }
744        } else {
745            path.to_path_buf()
746        };
747        let absolute = if expanded.is_relative() {
748            base.join(expanded)
749        } else {
750            expanded
751        };
752        // Canonicalize if the path exists, otherwise return as-is.
753        absolute.canonicalize().unwrap_or(absolute)
754    }
755
756    pub fn set_theme(&mut self, theme: String) {
757        self.theme = theme;
758    }
759
760    pub fn report_indexed(&mut self) {
761        self.needs_indexing = false;
762    }
763
764    pub fn needs_indexing(&self) -> bool {
765        self.needs_indexing
766    }
767
768    pub fn add_path_history(&mut self, note_path: &VaultPath) {
769        if !note_path.is_note() {
770            return;
771        }
772        let Some(workspace_name) = self.current_workspace_name() else {
773            return;
774        };
775        let file_path = self.history_path_for(&workspace_name);
776        if let Err(e) = history::push_history(&file_path, note_path) {
777            tracing::warn!("failed to write history {:?}: {}", file_path, e);
778        }
779    }
780
781    pub fn current_workspace_name(&self) -> Option<String> {
782        self.workspace_config
783            .as_ref()
784            .map(|wc| wc.global.current_workspace.clone())
785            .filter(|s| !s.is_empty())
786    }
787
788    pub fn cache_dir_resolved(&self) -> Option<&Path> {
789        self.cache_dir_resolved.as_deref()
790    }
791
792    pub fn history_dir_resolved(&self) -> Option<&Path> {
793        self.history_dir_resolved.as_deref()
794    }
795
796    /// Path to the SQLite cache file for the named workspace.
797    /// Caller must have already validated `workspace_name` via
798    /// `kimun_core::nfs::filename::validate_filename`.
799    pub fn cache_path_for(&self, workspace_name: &str) -> PathBuf {
800        Self::workspace_file(
801            self.cache_dir_resolved.as_ref().unwrap_or(&self.cache_dir),
802            workspace_name,
803            CACHE_FILE_EXT,
804        )
805    }
806
807    /// Path to the history file for the named workspace.
808    /// Caller must have already validated `workspace_name`.
809    pub fn history_path_for(&self, workspace_name: &str) -> PathBuf {
810        Self::workspace_file(
811            self.history_dir_resolved
812                .as_ref()
813                .unwrap_or(&self.history_dir),
814            workspace_name,
815            HISTORY_FILE_EXT,
816        )
817    }
818
819    fn workspace_file(dir: &Path, workspace_name: &str, ext: &str) -> PathBuf {
820        dir.join(format!("{workspace_name}.{ext}"))
821    }
822
823    /// Returns the last-visited paths for the current workspace.
824    pub fn current_last_paths(&self) -> Vec<VaultPath> {
825        let Some(name) = self.current_workspace_name() else {
826            return Vec::new();
827        };
828        let file_path = self.history_path_for(&name);
829        history::load_history(&file_path)
830    }
831
832    /// Build the icon set for the current `use_nerd_fonts` setting.
833    pub fn icons(&self) -> icons::Icons {
834        icons::Icons::new(self.use_nerd_fonts)
835    }
836
837    /// The chords bound to [`ActionShortcuts::YankRow`], for handing to a
838    /// [`SearchList`](crate::components::search_list::SearchList) so a rebinding
839    /// reaches the list surfaces. Empty when the user unbound it.
840    pub fn yank_combos(&self) -> Vec<crate::keys::key_combo::KeyCombo> {
841        self.key_bindings.combos_for(&ActionShortcuts::YankRow)
842    }
843
844    /// Name of the theme the app is effectively using: the configured name,
845    /// or the default theme's name when none is configured. Single owner of
846    /// the empty-name fallback rule — use this instead of re-deriving it.
847    pub fn effective_theme_name(&self) -> String {
848        if self.theme.is_empty() {
849            Theme::default().name
850        } else {
851            self.theme.clone()
852        }
853    }
854
855    /// Resolve the active theme by name, falling back to the default.
856    ///
857    /// The resolved theme is adapted to the terminal's color depth (truecolor
858    /// themes are quantized on 256-color terminals and mapped to role-semantic
859    /// ANSI slots on 16-color terminals).
860    pub fn get_theme(&self) -> Theme {
861        let theme = if self.theme.is_empty() {
862            Theme::default()
863        } else {
864            self.theme_list()
865                .into_iter()
866                .find(|t| t.name == self.theme)
867                .unwrap_or_default()
868        };
869        theme.adapt_to_terminal()
870    }
871}
872
873#[cfg(test)]
874#[allow(clippy::field_reassign_with_default)]
875mod tests {
876    use super::*;
877
878    #[test]
879    fn default_workspace_suggestion_is_under_home() {
880        let suggestion = AppSettings::default_workspace_suggestion();
881        if let Some(p) = suggestion {
882            assert!(p.ends_with("kimun-notes"));
883            assert!(p.is_absolute());
884        }
885        // None is acceptable only when the platform has no home dir.
886    }
887
888    #[test]
889    fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
890        // RED: fails to compile because load_theme_from_path doesn't exist.
891        // GREEN: method exists, returns Err, and does NOT create the file.
892        let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
893        let _ = std::fs::remove_file(&path); // ensure clean state
894
895        let result = AppSettings::load_theme_from_path(&path);
896
897        assert!(result.is_err(), "should return Err when file is absent");
898        assert!(!path.exists(), "must not create the file as a side effect");
899    }
900
901    #[test]
902    fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
903        // After a corrupt file is removed, no replacement must be written.
904        let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
905        std::fs::write(&path, b"not valid toml {{{{").unwrap();
906
907        let result = AppSettings::load_theme_from_path(&path);
908
909        assert!(result.is_err(), "should return Err for corrupt TOML");
910        // The user's file must SURVIVE a parse error (a typo must never
911        // delete a hand-authored theme).
912        assert!(path.exists(), "corrupt theme file must not be deleted");
913        std::fs::remove_file(&path).ok();
914    }
915
916    #[test]
917    fn default_keybindings_quit_matches_canonical_combo() {
918        let kb = default_keybindings();
919        let combo = crate::keys::default_quit_combo();
920        assert_eq!(
921            kb.get_action(&combo),
922            Some(ActionShortcuts::Quit),
923            "default_keybindings() must bind default_quit_combo() to Quit so the \
924             deserialize safety net can recover an unreachable app"
925        );
926    }
927
928    #[test]
929    fn autosave_interval_defaults_to_five() {
930        let settings = AppSettings::default();
931        assert_eq!(settings.autosave_interval_secs, 5);
932    }
933
934    #[test]
935    fn autosave_interval_deserializes_from_toml() {
936        let toml = "autosave_interval_secs = 30\n";
937        let settings: AppSettings = toml::from_str(toml).unwrap();
938        assert_eq!(settings.autosave_interval_secs, 30);
939    }
940
941    #[test]
942    fn autosave_interval_defaults_when_missing_from_toml() {
943        let toml = ""; // no autosave_interval_secs key
944        let settings: AppSettings = toml::from_str(toml).unwrap();
945        assert_eq!(settings.autosave_interval_secs, 5);
946    }
947
948    /// Verify the full load path: TOML with FileOperations = ["F2"] → keybinding lookup.
949    #[test]
950    fn f2_file_operations_survives_toml_deserialize() {
951        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
952        use crate::keys::key_strike::KeyStrike;
953
954        let toml = r#"
955[key_bindings]
956FileOperations = ["F2"]
957"#;
958        let settings: AppSettings = toml::from_str(toml).unwrap();
959        let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
960        let action = settings.key_bindings.get_action(&f2);
961        assert_eq!(
962            action,
963            Some(ActionShortcuts::FileOperations),
964            "F2 should survive deserialization and map to FileOperations"
965        );
966    }
967
968    /// Verify merge_missing_default_bindings adds F2 when absent from config.
969    #[test]
970    fn merge_adds_f2_when_absent() {
971        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
972        use crate::keys::key_strike::KeyStrike;
973
974        // Settings with no FileOperations binding
975        let toml = r#"
976[key_bindings]
977Quit = ["ctrl&Q"]
978"#;
979        let mut settings: AppSettings = toml::from_str(toml).unwrap();
980        settings.merge_missing_default_bindings();
981
982        let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
983        let action = settings.key_bindings.get_action(&f2);
984        assert_eq!(
985            action,
986            Some(ActionShortcuts::FileOperations),
987            "merge_missing_default_bindings should add F2 → FileOperations"
988        );
989    }
990
991    #[test]
992    fn clear_workspace_phase1_clears_workspace_dir() {
993        let mut settings = AppSettings::default();
994        settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
995        settings.needs_indexing = false;
996        settings.clear_workspace();
997        assert!(
998            settings.workspace_dir.is_none(),
999            "workspace_dir should be None"
1000        );
1001        assert!(
1002            settings.needs_indexing,
1003            "needs_indexing should be reset to true"
1004        );
1005    }
1006
1007    #[test]
1008    fn clear_workspace_phase2_removes_current_workspace_entry() {
1009        let mut settings = AppSettings::default();
1010        let mut wc = WorkspaceConfig::new_empty();
1011        wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
1012            .unwrap();
1013        settings.workspace_config = Some(wc);
1014        // Assert precondition: add_workspace auto-selects the first workspace
1015        assert_eq!(
1016            settings
1017                .workspace_config
1018                .as_ref()
1019                .unwrap()
1020                .global
1021                .current_workspace,
1022            "vault1"
1023        );
1024        settings.clear_workspace();
1025        let wc = settings.workspace_config.as_ref().unwrap();
1026        assert!(
1027            wc.workspaces.is_empty(),
1028            "workspace entry should be removed"
1029        );
1030        assert!(
1031            wc.global.current_workspace.is_empty(),
1032            "current_workspace should be empty"
1033        );
1034    }
1035
1036    #[test]
1037    fn clear_workspace_both_phases_active() {
1038        // When Phase 1 and Phase 2 fields are both populated (e.g. during migration),
1039        // clear_workspace must clear both independently.
1040        let mut settings = AppSettings::default();
1041        settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
1042        let mut wc = WorkspaceConfig::new_empty();
1043        wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
1044            .unwrap();
1045        settings.workspace_config = Some(wc);
1046        settings.clear_workspace();
1047        assert!(
1048            settings.workspace_dir.is_none(),
1049            "phase1 workspace_dir should be cleared"
1050        );
1051        let wc = settings.workspace_config.as_ref().unwrap();
1052        assert!(
1053            wc.workspaces.is_empty(),
1054            "phase2 workspace entry should be removed"
1055        );
1056        assert!(
1057            wc.global.current_workspace.is_empty(),
1058            "phase2 current_workspace should be empty"
1059        );
1060    }
1061
1062    #[test]
1063    fn clear_workspace_phase2_preserves_other_workspaces() {
1064        let mut settings = AppSettings::default();
1065        let mut wc = WorkspaceConfig::new_empty();
1066        wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
1067            .unwrap();
1068        wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
1069            .unwrap();
1070        wc.global.current_workspace = "vault1".to_string();
1071        settings.workspace_config = Some(wc);
1072        settings.clear_workspace();
1073        let wc = settings.workspace_config.as_ref().unwrap();
1074        assert!(
1075            !wc.workspaces.contains_key("vault1"),
1076            "active workspace should be removed"
1077        );
1078        assert!(
1079            wc.workspaces.contains_key("vault2"),
1080            "other workspaces should be preserved"
1081        );
1082        assert!(
1083            wc.global.current_workspace.is_empty(),
1084            "current_workspace should be empty"
1085        );
1086    }
1087}
1088
1089#[cfg(test)]
1090mod backend_tests {
1091    use super::*;
1092
1093    #[test]
1094    fn a_config_still_saying_textarea_loads() {
1095        // The value was renamed; a config written by an older version must keep
1096        // working. This has to be an alias rather than a migration entry, because
1097        // migrations run after deserialisation — an unknown variant fails first.
1098        #[derive(serde::Deserialize)]
1099        struct Holder {
1100            editor_backend: EditorBackendSetting,
1101        }
1102        let old: Holder = toml::from_str("editor_backend = \"textarea\"").expect("still loads");
1103        assert_eq!(old.editor_backend, EditorBackendSetting::Plain);
1104    }
1105
1106    #[test]
1107    fn the_backend_is_written_back_as_plain() {
1108        let written = toml::to_string(&AppSettings::default()).expect("serialises");
1109        assert!(
1110            written.contains("editor_backend = \"plain\""),
1111            "a saved config should name the value as it is now: {written}"
1112        );
1113    }
1114
1115    #[test]
1116    fn default_backend_is_plain() {
1117        let settings = AppSettings::default();
1118        assert!(matches!(
1119            settings.editor_backend,
1120            EditorBackendSetting::Plain
1121        ));
1122    }
1123
1124    #[test]
1125    fn nvim_backend_round_trips_toml() {
1126        let toml = "editor_backend = \"nvim\"\n";
1127        let parsed: AppSettings = toml::from_str(toml).unwrap();
1128        assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
1129    }
1130
1131    #[test]
1132    fn editor_backend_vim_roundtrips_through_toml() {
1133        #[derive(serde::Serialize, serde::Deserialize)]
1134        struct W {
1135            editor_backend: EditorBackendSetting,
1136        }
1137        let w = W {
1138            editor_backend: EditorBackendSetting::Vim,
1139        };
1140        let s = toml::to_string(&w).unwrap();
1141        assert!(s.contains("editor_backend = \"vim\""), "serialized: {s}");
1142        let back: W = toml::from_str(&s).unwrap();
1143        assert_eq!(back.editor_backend, EditorBackendSetting::Vim);
1144    }
1145
1146    // ── expand_path tests ──────────────────────────────────────────────
1147
1148    #[test]
1149    fn expand_path_absolute_unchanged() {
1150        let base = PathBuf::from("/config/dir");
1151        let result = AppSettings::expand_path(std::path::Path::new("/absolute/path/notes"), &base);
1152        assert!(result.is_absolute());
1153        assert!(result.to_string_lossy().contains("absolute"));
1154    }
1155
1156    #[test]
1157    fn expand_path_relative_resolved_against_base() {
1158        let base = tempfile::TempDir::new().unwrap();
1159        let notes = base.path().join("notes");
1160        std::fs::create_dir_all(&notes).unwrap();
1161
1162        let result = AppSettings::expand_path(std::path::Path::new("notes"), base.path());
1163        assert!(result.is_absolute());
1164        assert_eq!(result, notes.canonicalize().unwrap());
1165    }
1166
1167    #[test]
1168    fn expand_path_relative_with_dotdot() {
1169        let base = tempfile::TempDir::new().unwrap();
1170        let sibling = base.path().join("sibling");
1171        std::fs::create_dir_all(&sibling).unwrap();
1172        let sub = base.path().join("sub");
1173        std::fs::create_dir_all(&sub).unwrap();
1174
1175        let result = AppSettings::expand_path(std::path::Path::new("../sibling"), &sub);
1176        assert!(result.is_absolute());
1177        assert_eq!(result, sibling.canonicalize().unwrap());
1178    }
1179
1180    #[test]
1181    fn expand_path_nonexistent_relative_still_absolute() {
1182        let base = PathBuf::from("/some/config/dir");
1183        let result = AppSettings::expand_path(std::path::Path::new("my-notes"), &base);
1184        assert!(result.is_absolute());
1185        assert_eq!(result, PathBuf::from("/some/config/dir/my-notes"));
1186    }
1187
1188    #[test]
1189    #[cfg(unix)]
1190    fn expand_path_tilde_uses_home_unix() {
1191        // `expand_path` canonicalizes when the target exists and returns the
1192        // plain join when it does not, so which form comes back depends on
1193        // whether this machine happens to have `~/Documents/notes` — and, if it
1194        // does, on whether any component of `$HOME` is a symlink. macOS reaches
1195        // both branches routinely (`/var`, `/tmp` and `/etc` are symlinks into
1196        // `/private`, and relocated or network homes are ordinary), where Linux
1197        // usually reaches neither. Accept either rooting rather than pin the one
1198        // this machine happens to produce.
1199        let home = PathBuf::from(std::env::var("HOME").expect("HOME must be set on Unix"));
1200        let canonical_home = home.canonicalize().unwrap_or_else(|_| home.clone());
1201        let base = PathBuf::from("/irrelevant");
1202        let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
1203        assert!(result.is_absolute());
1204        assert!(
1205            result.starts_with(&home) || result.starts_with(&canonical_home),
1206            "expected path under HOME={home:?} (canonically {canonical_home:?}), got {result:?}"
1207        );
1208        assert!(result.to_string_lossy().contains("Documents/notes"));
1209    }
1210
1211    /// The expansion itself, with the canonicalization taken out of the picture:
1212    /// a target that cannot exist always takes `expand_path`'s plain-join
1213    /// branch, so this pins `~/` → `$HOME` on every machine.
1214    #[test]
1215    #[cfg(unix)]
1216    fn expand_path_tilde_expands_to_home_even_when_the_target_is_missing_unix() {
1217        let home = PathBuf::from(std::env::var("HOME").expect("HOME must be set on Unix"));
1218        let base = PathBuf::from("/irrelevant");
1219        let result = AppSettings::expand_path(
1220            std::path::Path::new("~/kimun-no-such-directory-2f8a1c/notes"),
1221            &base,
1222        );
1223        assert_eq!(result, home.join("kimun-no-such-directory-2f8a1c/notes"));
1224    }
1225
1226    #[test]
1227    #[cfg(unix)]
1228    fn expand_path_tilde_alone_is_home_unix() {
1229        let home = std::env::var("HOME").expect("HOME must be set on Unix");
1230        let base = PathBuf::from("/irrelevant");
1231        let result = AppSettings::expand_path(std::path::Path::new("~"), &base);
1232        assert!(result.is_absolute());
1233        // canonicalize may resolve symlinks, so compare canonicalized forms
1234        let expected = PathBuf::from(&home)
1235            .canonicalize()
1236            .unwrap_or(PathBuf::from(&home));
1237        assert_eq!(result, expected);
1238    }
1239
1240    #[test]
1241    #[cfg(windows)]
1242    fn expand_path_tilde_uses_userprofile_windows() {
1243        let home = std::env::var("USERPROFILE").expect("USERPROFILE must be set on Windows");
1244        let base = PathBuf::from("C:\\irrelevant");
1245        let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
1246        assert!(result.is_absolute());
1247        assert!(
1248            result.starts_with(&home),
1249            "expected path to start with USERPROFILE={}, got {:?}",
1250            home,
1251            result
1252        );
1253    }
1254
1255    #[test]
1256    fn resolve_paths_populates_resolved_path() {
1257        let base = tempfile::TempDir::new().unwrap();
1258        let notes = base.path().join("notes");
1259        std::fs::create_dir_all(&notes).unwrap();
1260
1261        let toml = r#"
1262config_version = 2
1263[global]
1264current_workspace = "test"
1265[workspaces.test]
1266path = "notes"
1267last_paths = []
1268created = "2026-01-01T00:00:00Z"
1269"#
1270        .to_string();
1271        let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1272        settings.resolve_paths(base.path());
1273
1274        let wc = settings.workspace_config.as_ref().unwrap();
1275        let entry = wc.workspaces.get("test").unwrap();
1276        // Original path preserved
1277        assert_eq!(entry.path, PathBuf::from("notes"));
1278        // Resolved path is absolute
1279        assert!(entry.resolved_path.is_some());
1280        assert!(entry.effective_path().is_absolute());
1281    }
1282
1283    #[test]
1284    fn resolve_paths_absolute_no_resolved_path() {
1285        let toml = r#"
1286config_version = 2
1287[global]
1288current_workspace = "test"
1289[workspaces.test]
1290path = "/absolute/notes"
1291last_paths = []
1292created = "2026-01-01T00:00:00Z"
1293"#;
1294        let mut settings: AppSettings = toml::from_str(toml).unwrap();
1295        settings.resolve_paths(std::path::Path::new("/config"));
1296
1297        let wc = settings.workspace_config.as_ref().unwrap();
1298        let entry = wc.workspaces.get("test").unwrap();
1299        // No resolved_path needed for already-absolute paths
1300        assert!(entry.resolved_path.is_none());
1301        assert_eq!(*entry.effective_path(), PathBuf::from("/absolute/notes"));
1302    }
1303}
1304
1305#[cfg(test)]
1306mod sort_settings_tests {
1307    use super::*;
1308
1309    #[test]
1310    fn group_directories_defaults_off() {
1311        let s = AppSettings::default();
1312        assert!(!s.group_directories);
1313    }
1314
1315    #[test]
1316    fn open_sort_dialog_is_bound_by_default() {
1317        let s = AppSettings::default();
1318        let map = s.key_bindings.to_hashmap();
1319        assert!(
1320            map.contains_key(&ActionShortcuts::OpenSortDialog),
1321            "OpenSortDialog must have a default binding"
1322        );
1323    }
1324}