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