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