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+shift&P\"]      # Ctrl+Shift+P
84#   NewJournal   = [\"ctrl&J\"]            # Ctrl+J
85#   FileOperations = [\"F2\"]              # F2  (open file-ops menu: delete/rename/move)
86#
87# ─────────────────────────────────────────────────────────────────────────────
88";
89
90#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
91pub struct AppSettings {
92    // Phase 2 config
93    #[serde(default)]
94    pub config_version: u32,
95    #[serde(flatten, skip_serializing_if = "Option::is_none")]
96    pub workspace_config: Option<WorkspaceConfig>,
97
98    // Legacy Phase 1 fields — only kept for migration detection/deserialization.
99    // Never written back: workspace_dir is taken by migration, last_paths is
100    // moved into workspace_config entries.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub workspace_dir: Option<PathBuf>,
103    #[serde(default, skip_serializing)]
104    pub last_paths: Vec<VaultPath>,
105
106    // Preserved fields
107    #[serde(default)]
108    pub theme: String,
109    #[serde(default = "default_cache_dir")]
110    pub cache_dir: PathBuf,
111    #[serde(skip)]
112    cache_dir_resolved: Option<PathBuf>,
113
114    #[serde(default = "default_history_dir")]
115    pub history_dir: PathBuf,
116    #[serde(skip)]
117    history_dir_resolved: Option<PathBuf>,
118    #[serde(skip, default = "yes")]
119    needs_indexing: bool,
120    #[serde(default = "default_keybindings")]
121    pub key_bindings: KeyBindings,
122    #[serde(default = "default_autosave_interval")]
123    pub autosave_interval_secs: u64,
124    #[serde(default = "default_use_nerd_fonts")]
125    pub use_nerd_fonts: bool,
126    #[serde(default)]
127    pub editor_backend: EditorBackendSetting,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub nvim_path: Option<std::path::PathBuf>,
130    #[serde(default = "default_sort_field")]
131    pub default_sort_field: SortFieldSetting,
132    #[serde(default = "default_sort_order")]
133    pub default_sort_order: SortOrderSetting,
134    #[serde(default = "default_journal_sort_field")]
135    pub journal_sort_field: SortFieldSetting,
136    #[serde(default = "default_journal_sort_order")]
137    pub journal_sort_order: SortOrderSetting,
138    /// Custom config file path. `None` means use the default location.
139    /// Not serialized — it's a runtime-only override.
140    #[serde(skip)]
141    pub config_file: Option<PathBuf>,
142}
143
144fn default_keybindings() -> KeyBindings {
145    let mut kb = KeyBindings::empty();
146    kb.batch_add()
147        .with_ctrl()
148        .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
149        .add(KeyStrike::KeyO, ActionShortcuts::OpenNote)
150        .add(KeyStrike::KeyY, ActionShortcuts::TogglePreview)
151        .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
152        .add(KeyStrike::KeyI, ActionShortcuts::Text(TextAction::Italic))
153        .add(
154            KeyStrike::KeyU,
155            ActionShortcuts::Text(TextAction::Underline),
156        )
157        .add(
158            KeyStrike::KeyS,
159            ActionShortcuts::Text(TextAction::Strikethrough),
160        )
161        .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Link))
162        .add(
163            KeyStrike::KeyT,
164            ActionShortcuts::Text(TextAction::ToggleHeader),
165        )
166        // =============================
167        // We add shift to the modifiers
168        // =============================
169        .with_shift()
170        .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Image));
171
172    // TUI navigation shortcuts (always Ctrl — terminal apps don't use Cmd/Meta).
173    // NOTE: the `Quit` entry must match `crate::keys::default_quit_combo()`,
174    // which the deserialize safety net uses to recover an unreachable app.
175    kb.batch_add()
176        .with_ctrl()
177        .add(KeyStrike::KeyP, ActionShortcuts::OpenSettings)
178        .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
179        .add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
180        .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
181        .add(KeyStrike::KeyN, ActionShortcuts::CycleSortField)
182        .add(KeyStrike::KeyG, ActionShortcuts::FollowLink)
183        .add(KeyStrike::KeyR, ActionShortcuts::SortReverseOrder)
184        .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
185        .add(KeyStrike::KeyL, ActionShortcuts::FocusEditor)
186        .add(KeyStrike::KeyW, ActionShortcuts::QuickNote)
187        .add(KeyStrike::KeyE, ActionShortcuts::ToggleBacklinks)
188        .add(KeyStrike::KeyF, ActionShortcuts::FindInBuffer);
189
190    // File operations menu (F2 — no modifier, reliable in all terminals).
191    kb.batch_add()
192        .add(KeyStrike::F2, ActionShortcuts::FileOperations);
193
194    kb.batch_add()
195        .add(KeyStrike::F4, ActionShortcuts::SwitchWorkspace);
196
197    kb
198}
199
200fn yes() -> bool {
201    true
202}
203
204fn default_autosave_interval() -> u64 {
205    5
206}
207
208fn default_cache_dir() -> PathBuf {
209    PathBuf::from(".")
210}
211
212fn default_history_dir() -> PathBuf {
213    PathBuf::from("history")
214}
215
216fn default_use_nerd_fonts() -> bool {
217    false
218}
219
220fn default_sort_field() -> SortFieldSetting {
221    SortFieldSetting::Name
222}
223
224fn default_sort_order() -> SortOrderSetting {
225    SortOrderSetting::Ascending
226}
227
228fn default_journal_sort_field() -> SortFieldSetting {
229    SortFieldSetting::Name
230}
231
232fn default_journal_sort_order() -> SortOrderSetting {
233    SortOrderSetting::Descending
234}
235
236impl Default for AppSettings {
237    fn default() -> Self {
238        Self {
239            config_version: 0,
240            workspace_config: None,
241            last_paths: vec![],
242            workspace_dir: None,
243            theme: Default::default(),
244            cache_dir: default_cache_dir(),
245            cache_dir_resolved: None,
246            history_dir: default_history_dir(),
247            history_dir_resolved: None,
248            needs_indexing: true,
249            key_bindings: default_keybindings(),
250            autosave_interval_secs: default_autosave_interval(),
251            use_nerd_fonts: false,
252            editor_backend: EditorBackendSetting::Textarea,
253            nvim_path: None,
254            default_sort_field: default_sort_field(),
255            default_sort_order: default_sort_order(),
256            journal_sort_field: default_journal_sort_field(),
257            journal_sort_order: default_journal_sort_order(),
258            config_file: None,
259        }
260    }
261}
262
263impl AppSettings {
264    pub fn theme_list(&self) -> Vec<Theme> {
265        let mut list = vec![
266            Theme::gruvbox_dark(),
267            Theme::gruvbox_light(),
268            Theme::catppuccin_mocha(),
269            Theme::catppuccin_latte(),
270            Theme::tokyo_night(),
271            Theme::tokyo_night_storm(),
272            Theme::solarized_dark(),
273            Theme::solarized_light(),
274            Theme::nord(),
275            Theme::ansi(),
276        ];
277        list.append(&mut Self::load_custom_themes());
278        // Merge the user's default.toml override if present.
279        if let Ok(custom_default) = Self::load_default_theme() {
280            list.push(custom_default);
281        }
282        list.sort_by(|a, b| a.name.cmp(&b.name));
283        list
284    }
285
286    fn default_config_file_path() -> eyre::Result<PathBuf> {
287        let config_home = get_or_create_config_dir(CONFIG_DIR)?;
288        Ok(config_home.join(BASE_CONFIG_FILE))
289    }
290
291    fn get_config_file_path(&self) -> eyre::Result<PathBuf> {
292        if let Some(ref path) = self.config_file {
293            Ok(path.clone())
294        } else {
295            Self::default_config_file_path()
296        }
297    }
298
299    fn get_themes_path() -> eyre::Result<PathBuf> {
300        let config_home = get_or_create_config_dir(CONFIG_DIR)?;
301        Ok(config_home.join(THEMES_DIR))
302    }
303
304    fn load_theme_from_path(path: &std::path::Path) -> eyre::Result<Theme> {
305        let theme_string = fs::read_to_string(path)?;
306        match toml::from_str::<Theme>(&theme_string) {
307            Ok(theme) => Ok(theme),
308            Err(e) => {
309                tracing::debug!(
310                    "Failed to deserialize theme file {:?}: {}. Removing.",
311                    path,
312                    e
313                );
314                let _ = fs::remove_file(path);
315                Err(eyre::eyre!("corrupt theme file: {}", e))
316            }
317        }
318    }
319
320    fn load_default_theme() -> eyre::Result<Theme> {
321        let theme_path = AppSettings::get_themes_path()?.join("default.toml");
322        Self::load_theme_from_path(&theme_path)
323    }
324
325    fn load_custom_themes() -> Vec<Theme> {
326        let mut themes = Vec::new();
327
328        // Get themes directory, return empty vec if it fails
329        let themes_path = match Self::get_themes_path() {
330            Ok(path) => path,
331            Err(_) => return themes,
332        };
333
334        // Read directory entries, return empty vec if it fails
335        let entries = match fs::read_dir(&themes_path) {
336            Ok(entries) => entries,
337            Err(_) => return themes,
338        };
339
340        // Iterate through all entries in the themes directory
341        for entry in entries.flatten() {
342            let path = entry.path();
343
344            // Skip if not a file
345            if !path.is_file() {
346                continue;
347            }
348
349            // Skip if not a .toml file
350            if path.extension().and_then(|s| s.to_str()) != Some("toml") {
351                continue;
352            }
353
354            // Skip default.toml
355            if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
356                continue;
357            }
358
359            // Try to read and deserialize the theme file
360            match fs::read_to_string(&path)
361                .and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
362            {
363                Ok(theme) => themes.push(theme),
364                Err(e) => tracing::warn!("Skipping theme file {:?}: {}", path, e),
365            }
366        }
367
368        themes
369    }
370
371    pub fn save_to_disk(&self) -> eyre::Result<()> {
372        tracing::debug!("Saving settings to disk");
373        let settings_file_path = self.get_config_file_path()?;
374        let mut file = File::create(settings_file_path)?;
375        file.write_all(CONFIG_HEADER.as_bytes())?;
376        let toml = toml::to_string(&self)?;
377        file.write_all(toml.as_bytes())?;
378        Ok(())
379    }
380
381    pub fn load_from_disk() -> eyre::Result<Self> {
382        let settings_file_path = Self::default_config_file_path()?;
383
384        if !settings_file_path.exists() {
385            let default_settings = Self::default();
386            default_settings.save_to_disk()?;
387            Ok(default_settings)
388        } else {
389            let mut settings_file = File::open(&settings_file_path)?;
390
391            let mut toml = String::new();
392            settings_file.read_to_string(&mut toml)?;
393
394            match toml::from_str::<AppSettings>(toml.as_ref()) {
395                Ok(mut setting) => {
396                    setting.config_file = Some(settings_file_path.clone());
397                    let config_dir = settings_file_path
398                        .parent()
399                        .unwrap_or(std::path::Path::new("."));
400                    setting.resolve_paths(config_dir);
401                    if config_migration::ConfigMigration::run(&mut setting)? {
402                        setting.save_to_disk()?;
403                    }
404                    setting.merge_missing_default_bindings();
405                    Ok(setting)
406                }
407                Err(e) => {
408                    tracing::warn!(
409                        "Config file at {:?} could not be parsed ({}). \
410                         Renaming to .corrupt and starting with defaults.",
411                        settings_file_path,
412                        e
413                    );
414                    let corrupt_path = settings_file_path.with_extension("toml.corrupt");
415                    let _ = fs::rename(&settings_file_path, &corrupt_path);
416                    let defaults = Self::default();
417                    defaults.save_to_disk()?;
418                    Ok(defaults)
419                }
420            }
421        }
422    }
423
424    pub fn load_from_file(path: PathBuf) -> eyre::Result<Self> {
425        if let Some(parent) = path.parent() {
426            fs::create_dir_all(parent)?;
427        }
428        if !path.exists() {
429            let default_settings = Self {
430                config_file: Some(path),
431                ..Self::default()
432            };
433            default_settings.save_to_disk()?;
434            return Ok(default_settings);
435        }
436        let mut toml_str = String::new();
437        File::open(&path)?.read_to_string(&mut toml_str)?;
438        match toml::from_str::<AppSettings>(&toml_str) {
439            Ok(mut setting) => {
440                setting.config_file = Some(path.clone());
441
442                // Resolve ~ and relative paths against the config file's directory.
443                let config_dir = path.parent().unwrap_or(std::path::Path::new("."));
444                setting.resolve_paths(config_dir);
445
446                // Run config migrations (e.g. Phase 1 → Phase 2 workspace_dir).
447                if config_migration::ConfigMigration::run(&mut setting)? {
448                    setting.save_to_disk()?;
449                }
450
451                setting.merge_missing_default_bindings();
452                Ok(setting)
453            }
454            Err(e) => {
455                tracing::warn!(
456                    "Config file at {:?} could not be parsed ({}). \
457                     Renaming to .corrupt and starting with defaults.",
458                    path,
459                    e
460                );
461                let corrupt_path = path.with_extension("toml.corrupt");
462                let _ = fs::rename(&path, &corrupt_path);
463                let defaults = Self {
464                    config_file: Some(path),
465                    ..Self::default()
466                };
467                defaults.save_to_disk()?;
468                Ok(defaults)
469            }
470        }
471    }
472
473    /// Fills in any actions from `default_keybindings()` that are absent in the loaded config.
474    /// Existing user-customised bindings are never overwritten.
475    fn merge_missing_default_bindings(&mut self) {
476        let defaults = default_keybindings().to_hashmap();
477        let mut current = self.key_bindings.to_hashmap();
478        for (action, combos) in defaults {
479            current.entry(action).or_insert(combos);
480        }
481        self.key_bindings = KeyBindings::from_hashmap(current);
482    }
483
484    // We set a new workspace to work with, remember to save the data
485    // to persist it in disk
486    pub fn set_workspace(&mut self, workspace_path: &PathBuf) {
487        if let Some(current_workspace_dir) = &self.workspace_dir
488            && workspace_path != current_workspace_dir
489        {
490            self.needs_indexing = true;
491        }
492
493        self.workspace_dir = Some(workspace_path.to_owned());
494    }
495
496    /// Removes the active workspace path so the user is prompted to choose a new one.
497    /// Handles both Phase 1 (workspace_dir) and Phase 2 (workspace_config) config formats.
498    ///
499    /// For Phase 2: only the currently active workspace entry is removed; other workspace
500    /// entries in the config are preserved. After this call, `workspace_config` remains
501    /// `Some` but `get_current_workspace()` returns `None`.
502    pub fn clear_workspace(&mut self) {
503        // Phase 1
504        if self.workspace_dir.is_some() {
505            self.workspace_dir = None;
506            self.needs_indexing = true;
507        }
508        // Phase 2
509        if let Some(wc) = &mut self.workspace_config {
510            let key = wc.global.current_workspace.clone();
511            if !key.is_empty() {
512                wc.workspaces.remove(&key);
513            }
514            wc.global.current_workspace = String::new();
515        }
516    }
517
518    /// Resolve the active workspace path from Phase 2 (workspace_config) or
519    /// Phase 1 (workspace_dir). Returns `None` if no workspace is configured.
520    pub fn resolve_workspace_path(&self) -> Option<PathBuf> {
521        self.workspace_config
522            .as_ref()
523            .and_then(|wc| wc.get_current_workspace())
524            .map(|entry| entry.effective_path().clone())
525            .or_else(|| self.workspace_dir.clone())
526    }
527
528    /// Resolve `~` and relative paths in workspace entries.
529    /// Relative paths are resolved against `base` (typically the config file's
530    /// parent directory). Called once after deserialization.
531    fn resolve_paths(&mut self, base: &std::path::Path) {
532        // Legacy workspace_dir — resolve in place (it's a legacy field that
533        // gets consumed by migration anyway).
534        if let Some(ref mut p) = self.workspace_dir {
535            *p = Self::expand_path(p, base);
536        }
537        // Phase 2 workspace entries — populate resolved_path, keep original path intact.
538        if let Some(ref mut wc) = self.workspace_config {
539            for entry in wc.workspaces.values_mut() {
540                let resolved = Self::expand_path(&entry.path, base);
541                if resolved != entry.path {
542                    entry.resolved_path = Some(resolved);
543                }
544            }
545        }
546        self.cache_dir_resolved = Some(Self::expand_path(&self.cache_dir, base));
547        self.history_dir_resolved = Some(Self::expand_path(&self.history_dir, base));
548    }
549
550    /// Expand `~` to the home directory and resolve relative paths against `base`.
551    /// Returns an absolute path. If the resolved path exists on disk, it is
552    /// canonicalized to remove `.` and `..` components.
553    fn expand_path(path: &std::path::Path, base: &std::path::Path) -> PathBuf {
554        let s = path.to_string_lossy();
555        let expanded = if s.starts_with("~/") || s == "~" {
556            if let Ok(home) = config_dir::get_home_dir() {
557                home.join(s.strip_prefix("~/").unwrap_or(""))
558            } else {
559                path.to_path_buf()
560            }
561        } else {
562            path.to_path_buf()
563        };
564        let absolute = if expanded.is_relative() {
565            base.join(expanded)
566        } else {
567            expanded
568        };
569        // Canonicalize if the path exists, otherwise return as-is.
570        absolute.canonicalize().unwrap_or(absolute)
571    }
572
573    pub fn set_theme(&mut self, theme: String) {
574        self.theme = theme;
575    }
576
577    pub fn report_indexed(&mut self) {
578        self.needs_indexing = false;
579    }
580
581    pub fn needs_indexing(&self) -> bool {
582        self.needs_indexing
583    }
584
585    pub fn add_path_history(&mut self, note_path: &VaultPath) {
586        if !note_path.is_note() {
587            return;
588        }
589        let Some(workspace_name) = self.current_workspace_name() else {
590            return;
591        };
592        let file_path = self.history_path_for(&workspace_name);
593        if let Err(e) = history::push_history(&file_path, note_path) {
594            tracing::warn!("failed to write history {:?}: {}", file_path, e);
595        }
596    }
597
598    pub fn current_workspace_name(&self) -> Option<String> {
599        self.workspace_config
600            .as_ref()
601            .map(|wc| wc.global.current_workspace.clone())
602            .filter(|s| !s.is_empty())
603    }
604
605    pub fn cache_dir_resolved(&self) -> Option<&Path> {
606        self.cache_dir_resolved.as_deref()
607    }
608
609    pub fn history_dir_resolved(&self) -> Option<&Path> {
610        self.history_dir_resolved.as_deref()
611    }
612
613    /// Path to the SQLite cache file for the named workspace.
614    /// Caller must have already validated `workspace_name` via
615    /// `kimun_core::nfs::filename::validate_filename`.
616    pub fn cache_path_for(&self, workspace_name: &str) -> PathBuf {
617        Self::workspace_file(
618            self.cache_dir_resolved.as_ref().unwrap_or(&self.cache_dir),
619            workspace_name,
620            CACHE_FILE_EXT,
621        )
622    }
623
624    /// Path to the history file for the named workspace.
625    /// Caller must have already validated `workspace_name`.
626    pub fn history_path_for(&self, workspace_name: &str) -> PathBuf {
627        Self::workspace_file(
628            self.history_dir_resolved
629                .as_ref()
630                .unwrap_or(&self.history_dir),
631            workspace_name,
632            HISTORY_FILE_EXT,
633        )
634    }
635
636    fn workspace_file(dir: &Path, workspace_name: &str, ext: &str) -> PathBuf {
637        dir.join(format!("{workspace_name}.{ext}"))
638    }
639
640    /// Returns the last-visited paths for the current workspace.
641    pub fn current_last_paths(&self) -> Vec<VaultPath> {
642        let Some(name) = self.current_workspace_name() else {
643            return Vec::new();
644        };
645        let file_path = self.history_path_for(&name);
646        history::load_history(&file_path)
647    }
648
649    /// Build the icon set for the current `use_nerd_fonts` setting.
650    pub fn icons(&self) -> icons::Icons {
651        icons::Icons::new(self.use_nerd_fonts)
652    }
653
654    /// Resolve the active theme by name, falling back to the default.
655    pub fn get_theme(&self) -> Theme {
656        if self.theme.is_empty() {
657            return Theme::default();
658        }
659        self.theme_list()
660            .into_iter()
661            .find(|t| t.name == self.theme)
662            .unwrap_or_default()
663    }
664}
665
666#[cfg(test)]
667#[allow(clippy::field_reassign_with_default)]
668mod tests {
669    use super::*;
670
671    #[test]
672    fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
673        // RED: fails to compile because load_theme_from_path doesn't exist.
674        // GREEN: method exists, returns Err, and does NOT create the file.
675        let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
676        let _ = std::fs::remove_file(&path); // ensure clean state
677
678        let result = AppSettings::load_theme_from_path(&path);
679
680        assert!(result.is_err(), "should return Err when file is absent");
681        assert!(!path.exists(), "must not create the file as a side effect");
682    }
683
684    #[test]
685    fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
686        // After a corrupt file is removed, no replacement must be written.
687        let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
688        std::fs::write(&path, b"not valid toml {{{{").unwrap();
689
690        let result = AppSettings::load_theme_from_path(&path);
691
692        assert!(result.is_err(), "should return Err for corrupt TOML");
693        assert!(
694            !path.exists(),
695            "corrupt file must be removed, not recreated"
696        );
697    }
698
699    #[test]
700    fn default_keybindings_quit_matches_canonical_combo() {
701        let kb = default_keybindings();
702        let combo = crate::keys::default_quit_combo();
703        assert_eq!(
704            kb.get_action(&combo),
705            Some(ActionShortcuts::Quit),
706            "default_keybindings() must bind default_quit_combo() to Quit so the \
707             deserialize safety net can recover an unreachable app"
708        );
709    }
710
711    #[test]
712    fn autosave_interval_defaults_to_five() {
713        let settings = AppSettings::default();
714        assert_eq!(settings.autosave_interval_secs, 5);
715    }
716
717    #[test]
718    fn autosave_interval_deserializes_from_toml() {
719        let toml = "autosave_interval_secs = 30\n";
720        let settings: AppSettings = toml::from_str(toml).unwrap();
721        assert_eq!(settings.autosave_interval_secs, 30);
722    }
723
724    #[test]
725    fn autosave_interval_defaults_when_missing_from_toml() {
726        let toml = ""; // no autosave_interval_secs key
727        let settings: AppSettings = toml::from_str(toml).unwrap();
728        assert_eq!(settings.autosave_interval_secs, 5);
729    }
730
731    /// Verify the full load path: TOML with FileOperations = ["F2"] → keybinding lookup.
732    #[test]
733    fn f2_file_operations_survives_toml_deserialize() {
734        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
735        use crate::keys::key_strike::KeyStrike;
736
737        let toml = r#"
738[key_bindings]
739FileOperations = ["F2"]
740"#;
741        let settings: AppSettings = toml::from_str(toml).unwrap();
742        let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
743        let action = settings.key_bindings.get_action(&f2);
744        assert_eq!(
745            action,
746            Some(ActionShortcuts::FileOperations),
747            "F2 should survive deserialization and map to FileOperations"
748        );
749    }
750
751    /// Verify merge_missing_default_bindings adds F2 when absent from config.
752    #[test]
753    fn merge_adds_f2_when_absent() {
754        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
755        use crate::keys::key_strike::KeyStrike;
756
757        // Settings with no FileOperations binding
758        let toml = r#"
759[key_bindings]
760Quit = ["ctrl&Q"]
761"#;
762        let mut settings: AppSettings = toml::from_str(toml).unwrap();
763        settings.merge_missing_default_bindings();
764
765        let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
766        let action = settings.key_bindings.get_action(&f2);
767        assert_eq!(
768            action,
769            Some(ActionShortcuts::FileOperations),
770            "merge_missing_default_bindings should add F2 → FileOperations"
771        );
772    }
773
774    #[test]
775    fn clear_workspace_phase1_clears_workspace_dir() {
776        let mut settings = AppSettings::default();
777        settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
778        settings.needs_indexing = false;
779        settings.clear_workspace();
780        assert!(
781            settings.workspace_dir.is_none(),
782            "workspace_dir should be None"
783        );
784        assert!(
785            settings.needs_indexing,
786            "needs_indexing should be reset to true"
787        );
788    }
789
790    #[test]
791    fn clear_workspace_phase2_removes_current_workspace_entry() {
792        let mut settings = AppSettings::default();
793        let mut wc = WorkspaceConfig::new_empty();
794        wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
795            .unwrap();
796        settings.workspace_config = Some(wc);
797        // Assert precondition: add_workspace auto-selects the first workspace
798        assert_eq!(
799            settings
800                .workspace_config
801                .as_ref()
802                .unwrap()
803                .global
804                .current_workspace,
805            "vault1"
806        );
807        settings.clear_workspace();
808        let wc = settings.workspace_config.as_ref().unwrap();
809        assert!(
810            wc.workspaces.is_empty(),
811            "workspace entry should be removed"
812        );
813        assert!(
814            wc.global.current_workspace.is_empty(),
815            "current_workspace should be empty"
816        );
817    }
818
819    #[test]
820    fn clear_workspace_both_phases_active() {
821        // When Phase 1 and Phase 2 fields are both populated (e.g. during migration),
822        // clear_workspace must clear both independently.
823        let mut settings = AppSettings::default();
824        settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
825        let mut wc = WorkspaceConfig::new_empty();
826        wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
827            .unwrap();
828        settings.workspace_config = Some(wc);
829        settings.clear_workspace();
830        assert!(
831            settings.workspace_dir.is_none(),
832            "phase1 workspace_dir should be cleared"
833        );
834        let wc = settings.workspace_config.as_ref().unwrap();
835        assert!(
836            wc.workspaces.is_empty(),
837            "phase2 workspace entry should be removed"
838        );
839        assert!(
840            wc.global.current_workspace.is_empty(),
841            "phase2 current_workspace should be empty"
842        );
843    }
844
845    #[test]
846    fn clear_workspace_phase2_preserves_other_workspaces() {
847        let mut settings = AppSettings::default();
848        let mut wc = WorkspaceConfig::new_empty();
849        wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
850            .unwrap();
851        wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
852            .unwrap();
853        wc.global.current_workspace = "vault1".to_string();
854        settings.workspace_config = Some(wc);
855        settings.clear_workspace();
856        let wc = settings.workspace_config.as_ref().unwrap();
857        assert!(
858            !wc.workspaces.contains_key("vault1"),
859            "active workspace should be removed"
860        );
861        assert!(
862            wc.workspaces.contains_key("vault2"),
863            "other workspaces should be preserved"
864        );
865        assert!(
866            wc.global.current_workspace.is_empty(),
867            "current_workspace should be empty"
868        );
869    }
870}
871
872#[cfg(test)]
873mod backend_tests {
874    use super::*;
875
876    #[test]
877    fn default_backend_is_textarea() {
878        let settings = AppSettings::default();
879        assert!(matches!(
880            settings.editor_backend,
881            EditorBackendSetting::Textarea
882        ));
883    }
884
885    #[test]
886    fn nvim_backend_round_trips_toml() {
887        let toml = "editor_backend = \"nvim\"\n";
888        let parsed: AppSettings = toml::from_str(toml).unwrap();
889        assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
890    }
891
892    // ── expand_path tests ──────────────────────────────────────────────
893
894    #[test]
895    fn expand_path_absolute_unchanged() {
896        let base = PathBuf::from("/config/dir");
897        let result = AppSettings::expand_path(std::path::Path::new("/absolute/path/notes"), &base);
898        assert!(result.is_absolute());
899        assert!(result.to_string_lossy().contains("absolute"));
900    }
901
902    #[test]
903    fn expand_path_relative_resolved_against_base() {
904        let base = tempfile::TempDir::new().unwrap();
905        let notes = base.path().join("notes");
906        std::fs::create_dir_all(&notes).unwrap();
907
908        let result = AppSettings::expand_path(std::path::Path::new("notes"), base.path());
909        assert!(result.is_absolute());
910        assert_eq!(result, notes.canonicalize().unwrap());
911    }
912
913    #[test]
914    fn expand_path_relative_with_dotdot() {
915        let base = tempfile::TempDir::new().unwrap();
916        let sibling = base.path().join("sibling");
917        std::fs::create_dir_all(&sibling).unwrap();
918        let sub = base.path().join("sub");
919        std::fs::create_dir_all(&sub).unwrap();
920
921        let result = AppSettings::expand_path(std::path::Path::new("../sibling"), &sub);
922        assert!(result.is_absolute());
923        assert_eq!(result, sibling.canonicalize().unwrap());
924    }
925
926    #[test]
927    fn expand_path_nonexistent_relative_still_absolute() {
928        let base = PathBuf::from("/some/config/dir");
929        let result = AppSettings::expand_path(std::path::Path::new("my-notes"), &base);
930        assert!(result.is_absolute());
931        assert_eq!(result, PathBuf::from("/some/config/dir/my-notes"));
932    }
933
934    #[test]
935    #[cfg(unix)]
936    fn expand_path_tilde_uses_home_unix() {
937        let home = std::env::var("HOME").expect("HOME must be set on Unix");
938        let base = PathBuf::from("/irrelevant");
939        let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
940        assert!(result.is_absolute());
941        assert!(
942            result.starts_with(&home),
943            "expected path to start with HOME={}, got {:?}",
944            home,
945            result
946        );
947        assert!(result.to_string_lossy().contains("Documents/notes"));
948    }
949
950    #[test]
951    #[cfg(unix)]
952    fn expand_path_tilde_alone_is_home_unix() {
953        let home = std::env::var("HOME").expect("HOME must be set on Unix");
954        let base = PathBuf::from("/irrelevant");
955        let result = AppSettings::expand_path(std::path::Path::new("~"), &base);
956        assert!(result.is_absolute());
957        // canonicalize may resolve symlinks, so compare canonicalized forms
958        let expected = PathBuf::from(&home)
959            .canonicalize()
960            .unwrap_or(PathBuf::from(&home));
961        assert_eq!(result, expected);
962    }
963
964    #[test]
965    #[cfg(windows)]
966    fn expand_path_tilde_uses_userprofile_windows() {
967        let home = std::env::var("USERPROFILE").expect("USERPROFILE must be set on Windows");
968        let base = PathBuf::from("C:\\irrelevant");
969        let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
970        assert!(result.is_absolute());
971        assert!(
972            result.starts_with(&home),
973            "expected path to start with USERPROFILE={}, got {:?}",
974            home,
975            result
976        );
977    }
978
979    #[test]
980    fn resolve_paths_populates_resolved_path() {
981        let base = tempfile::TempDir::new().unwrap();
982        let notes = base.path().join("notes");
983        std::fs::create_dir_all(&notes).unwrap();
984
985        let toml = r#"
986config_version = 2
987[global]
988current_workspace = "test"
989[workspaces.test]
990path = "notes"
991last_paths = []
992created = "2026-01-01T00:00:00Z"
993"#
994        .to_string();
995        let mut settings: AppSettings = toml::from_str(&toml).unwrap();
996        settings.resolve_paths(base.path());
997
998        let wc = settings.workspace_config.as_ref().unwrap();
999        let entry = wc.workspaces.get("test").unwrap();
1000        // Original path preserved
1001        assert_eq!(entry.path, PathBuf::from("notes"));
1002        // Resolved path is absolute
1003        assert!(entry.resolved_path.is_some());
1004        assert!(entry.effective_path().is_absolute());
1005    }
1006
1007    #[test]
1008    fn resolve_paths_absolute_no_resolved_path() {
1009        let toml = r#"
1010config_version = 2
1011[global]
1012current_workspace = "test"
1013[workspaces.test]
1014path = "/absolute/notes"
1015last_paths = []
1016created = "2026-01-01T00:00:00Z"
1017"#;
1018        let mut settings: AppSettings = toml::from_str(toml).unwrap();
1019        settings.resolve_paths(std::path::Path::new("/config"));
1020
1021        let wc = settings.workspace_config.as_ref().unwrap();
1022        let entry = wc.workspaces.get("test").unwrap();
1023        // No resolved_path needed for already-absolute paths
1024        assert!(entry.resolved_path.is_none());
1025        assert_eq!(*entry.effective_path(), PathBuf::from("/absolute/notes"));
1026    }
1027}