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