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