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;
8
9use std::fs::{self, File};
10
11use color_eyre::eyre;
12use kimun_core::nfs::VaultPath;
13use log::debug;
14
15use crate::keys::KeyBindings;
16mod config_dir;
17pub mod icons;
18pub mod themes;
19pub mod workspace_config;
20
21// ---------------------------------------------------------------------------
22// Sort settings types (shared between AppSettings and sorting UI)
23// ---------------------------------------------------------------------------
24
25#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum SortFieldSetting {
28    Name,
29    Title,
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum SortOrderSetting {
35    Ascending,
36    Descending,
37}
38
39#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum EditorBackendSetting {
42    #[default]
43    Textarea,
44    Nvim,
45}
46
47// pub mod theme;
48
49#[cfg(debug_assertions)]
50const CONFIG_DIR: &str = "kimun_debug";
51#[cfg(not(debug_assertions))]
52const CONFIG_DIR: &str = "kimun";
53
54const BASE_CONFIG_FILE: &str = "config.toml";
55const THEMES_DIR: &str = "themes";
56
57const LAST_PATH_HISTORY_SIZE: usize = 20;
58
59const CONFIG_HEADER: &str = "\
60# ─── Kimün configuration ────────────────────────────────────────────────────
61#
62# KEY BINDINGS
63# ────────────
64# Supported combinations:
65#   - ctrl and/or alt (with optional shift) + a letter (a-z)
66#   - bare F-key (F1–F12, no modifier required)
67# Any combo that does not follow these rules is silently ignored when loaded.
68#
69# Format per action:
70#   ActionName = [\"<modifiers> & <letter>\", ...]
71#
72# Available modifiers (combine with +):  ctrl   alt   shift
73#
74# Examples:
75#   Quit         = [\"ctrl&Q\"]            # Ctrl+Q
76#   SearchNotes  = [\"ctrl&E\"]            # Ctrl+E
77#   OpenNote     = [\"ctrl&O\"]            # Ctrl+O  (fuzzy file finder)
78#   OpenSettings = [\"ctrl+shift&P\"]      # Ctrl+Shift+P
79#   NewJournal   = [\"ctrl&J\"]            # Ctrl+J
80#   FileOperations = [\"F2\"]              # F2  (open file-ops menu: delete/rename/move)
81#
82# ─────────────────────────────────────────────────────────────────────────────
83";
84
85#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
86pub struct AppSettings {
87    // Phase 2 config
88    #[serde(default)]
89    pub config_version: u32,
90    #[serde(flatten, skip_serializing_if = "Option::is_none")]
91    pub workspace_config: Option<WorkspaceConfig>,
92
93    // Legacy Phase 1 fields (for migration detection)
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub workspace_dir: Option<PathBuf>,
96    #[serde(default)]
97    pub last_paths: Vec<VaultPath>,
98
99    // Preserved fields
100    #[serde(default)]
101    pub theme: String,
102    #[serde(skip, default = "yes")]
103    needs_indexing: bool,
104    #[serde(default = "default_keybindings")]
105    pub key_bindings: KeyBindings,
106    #[serde(default = "default_autosave_interval")]
107    pub autosave_interval_secs: u64,
108    #[serde(default = "default_use_nerd_fonts")]
109    pub use_nerd_fonts: bool,
110    #[serde(default)]
111    pub editor_backend: EditorBackendSetting,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub nvim_path: Option<std::path::PathBuf>,
114    #[serde(default = "default_sort_field")]
115    pub default_sort_field: SortFieldSetting,
116    #[serde(default = "default_sort_order")]
117    pub default_sort_order: SortOrderSetting,
118    #[serde(default = "default_journal_sort_field")]
119    pub journal_sort_field: SortFieldSetting,
120    #[serde(default = "default_journal_sort_order")]
121    pub journal_sort_order: SortOrderSetting,
122    /// Custom config file path. `None` means use the default location.
123    /// Not serialized — it's a runtime-only override.
124    #[serde(skip)]
125    pub config_file: Option<PathBuf>,
126}
127
128fn default_keybindings() -> KeyBindings {
129    let mut kb = KeyBindings::empty();
130    kb.batch_add().with_ctrl()
131        .add(KeyStrike::KeyF, ActionShortcuts::ToggleNoteBrowser)
132        .add(KeyStrike::KeyE, ActionShortcuts::SearchNotes)
133        .add(KeyStrike::KeyO, ActionShortcuts::OpenNote)
134        .add(KeyStrike::KeyY, ActionShortcuts::TogglePreview)
135        .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
136        .add(KeyStrike::KeyI, ActionShortcuts::Text(TextAction::Italic))
137        .add(
138            KeyStrike::KeyU,
139            ActionShortcuts::Text(TextAction::Underline),
140        )
141        .add(
142            KeyStrike::KeyS,
143            ActionShortcuts::Text(TextAction::Strikethrough),
144        )
145        .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Link))
146        .add(
147            KeyStrike::KeyT,
148            ActionShortcuts::Text(TextAction::ToggleHeader),
149        )
150        // =============================
151        // We add shift to the modifiers
152        // =============================
153        .with_shift()
154        .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Image));
155
156    // TUI navigation shortcuts (always Ctrl — terminal apps don't use Cmd/Meta).
157    kb.batch_add()
158        .with_ctrl()
159        .add(KeyStrike::KeyP, ActionShortcuts::OpenSettings)
160        .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
161        .add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
162        .add(KeyStrike::KeyB, ActionShortcuts::ToggleSidebar)
163        .add(KeyStrike::KeyN, ActionShortcuts::CycleSortField)
164        .add(KeyStrike::KeyG, ActionShortcuts::FollowLink)
165        .add(KeyStrike::KeyR, ActionShortcuts::SortReverseOrder)
166        .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
167        .add(KeyStrike::KeyL, ActionShortcuts::FocusEditor);
168
169    // File operations menu (F2 — no modifier, reliable in all terminals).
170    kb.batch_add()
171        .add(KeyStrike::F2, ActionShortcuts::FileOperations);
172
173    kb
174}
175
176fn yes() -> bool {
177    true
178}
179
180fn default_autosave_interval() -> u64 {
181    5
182}
183
184fn default_use_nerd_fonts() -> bool {
185    false
186}
187
188fn default_sort_field() -> SortFieldSetting {
189    SortFieldSetting::Name
190}
191
192fn default_sort_order() -> SortOrderSetting {
193    SortOrderSetting::Ascending
194}
195
196fn default_journal_sort_field() -> SortFieldSetting {
197    SortFieldSetting::Name
198}
199
200fn default_journal_sort_order() -> SortOrderSetting {
201    SortOrderSetting::Descending
202}
203
204impl Default for AppSettings {
205    fn default() -> Self {
206        Self {
207            config_version: 0,
208            workspace_config: None,
209            last_paths: vec![],
210            workspace_dir: None,
211            theme: Default::default(),
212            needs_indexing: true,
213            key_bindings: default_keybindings(),
214            autosave_interval_secs: default_autosave_interval(),
215            use_nerd_fonts: false,
216            editor_backend: EditorBackendSetting::Textarea,
217            nvim_path: None,
218            default_sort_field: default_sort_field(),
219            default_sort_order: default_sort_order(),
220            journal_sort_field: default_journal_sort_field(),
221            journal_sort_order: default_journal_sort_order(),
222            config_file: None,
223        }
224    }
225}
226
227impl AppSettings {
228    pub fn theme_list(&self) -> Vec<Theme> {
229        let mut list = vec![
230            Theme::gruvbox_dark(),
231            Theme::gruvbox_light(),
232            Theme::catppuccin_mocha(),
233            Theme::catppuccin_latte(),
234            Theme::tokyo_night(),
235            Theme::tokyo_night_storm(),
236            Theme::solarized_dark(),
237            Theme::solarized_light(),
238            Theme::nord(),
239        ];
240        list.append(&mut Self::load_custom_themes());
241        // Merge the user's default.toml override if present.
242        if let Ok(custom_default) = Self::load_default_theme() {
243            list.push(custom_default);
244        }
245        list.sort_by(|a, b| a.name.cmp(&b.name));
246        list
247    }
248
249    fn default_config_file_path() -> eyre::Result<PathBuf> {
250        let config_home = get_or_create_config_dir(CONFIG_DIR)?;
251        Ok(config_home.join(BASE_CONFIG_FILE))
252    }
253
254    fn get_config_file_path(&self) -> eyre::Result<PathBuf> {
255        if let Some(ref path) = self.config_file {
256            Ok(path.clone())
257        } else {
258            Self::default_config_file_path()
259        }
260    }
261
262    fn get_themes_path() -> eyre::Result<PathBuf> {
263        let config_home = get_or_create_config_dir(CONFIG_DIR)?;
264        Ok(config_home.join(THEMES_DIR))
265    }
266
267    fn load_theme_from_path(path: &std::path::Path) -> eyre::Result<Theme> {
268        let theme_string = fs::read_to_string(path)?;
269        match toml::from_str::<Theme>(&theme_string) {
270            Ok(theme) => Ok(theme),
271            Err(e) => {
272                debug!(
273                    "Failed to deserialize theme file {:?}: {}. Removing.",
274                    path, e
275                );
276                let _ = fs::remove_file(path);
277                Err(eyre::eyre!("corrupt theme file: {}", e))
278            }
279        }
280    }
281
282    fn load_default_theme() -> eyre::Result<Theme> {
283        let theme_path = AppSettings::get_themes_path()?.join("default.toml");
284        Self::load_theme_from_path(&theme_path)
285    }
286
287    fn load_custom_themes() -> Vec<Theme> {
288        let mut themes = Vec::new();
289
290        // Get themes directory, return empty vec if it fails
291        let themes_path = match Self::get_themes_path() {
292            Ok(path) => path,
293            Err(_) => return themes,
294        };
295
296        // Read directory entries, return empty vec if it fails
297        let entries = match fs::read_dir(&themes_path) {
298            Ok(entries) => entries,
299            Err(_) => return themes,
300        };
301
302        // Iterate through all entries in the themes directory
303        for entry in entries.flatten() {
304            let path = entry.path();
305
306            // Skip if not a file
307            if !path.is_file() {
308                continue;
309            }
310
311            // Skip if not a .toml file
312            if path.extension().and_then(|s| s.to_str()) != Some("toml") {
313                continue;
314            }
315
316            // Skip default.toml
317            if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
318                continue;
319            }
320
321            // Try to read and deserialize the theme file
322            match fs::read_to_string(&path)
323                .and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
324            {
325                Ok(theme) => themes.push(theme),
326                Err(e) => log::warn!("Skipping theme file {:?}: {}", path, e),
327            }
328        }
329
330        themes
331    }
332
333    pub fn save_to_disk(&self) -> eyre::Result<()> {
334        log::debug!("Saving settings to disk");
335        let settings_file_path = self.get_config_file_path()?;
336        let mut file = File::create(settings_file_path)?;
337        file.write_all(CONFIG_HEADER.as_bytes())?;
338        let toml = toml::to_string(&self)?;
339        file.write_all(toml.as_bytes())?;
340        Ok(())
341    }
342
343    pub fn load_from_disk() -> eyre::Result<Self> {
344        let settings_file_path = Self::default_config_file_path()?;
345
346        if !settings_file_path.exists() {
347            let default_settings = Self::default();
348            default_settings.save_to_disk()?;
349            Ok(default_settings)
350        } else {
351            let mut settings_file = File::open(&settings_file_path)?;
352
353            let mut toml = String::new();
354            settings_file.read_to_string(&mut toml)?;
355
356            match toml::from_str::<AppSettings>(toml.as_ref()) {
357                Ok(mut setting) => {
358                    setting.merge_missing_default_bindings();
359                    Ok(setting)
360                }
361                Err(e) => {
362                    log::warn!(
363                        "Config file at {:?} could not be parsed ({}). \
364                         Renaming to .corrupt and starting with defaults.",
365                        settings_file_path,
366                        e
367                    );
368                    let corrupt_path = settings_file_path.with_extension("toml.corrupt");
369                    let _ = fs::rename(&settings_file_path, &corrupt_path);
370                    let defaults = Self::default();
371                    defaults.save_to_disk()?;
372                    Ok(defaults)
373                }
374            }
375        }
376    }
377
378    pub fn load_from_file(path: PathBuf) -> eyre::Result<Self> {
379        if let Some(parent) = path.parent() {
380            fs::create_dir_all(parent)?;
381        }
382        if !path.exists() {
383            let mut default_settings = Self::default();
384            default_settings.config_file = Some(path);
385            default_settings.save_to_disk()?;
386            return Ok(default_settings);
387        }
388        let mut toml_str = String::new();
389        File::open(&path)?.read_to_string(&mut toml_str)?;
390        match toml::from_str::<AppSettings>(&toml_str) {
391            Ok(mut setting) => {
392                setting.config_file = Some(path.clone());
393
394                // Check if migration is needed (Phase 1 -> Phase 2)
395                if setting.workspace_dir.is_some() && setting.workspace_config.is_none() {
396                    log::info!("Migrating Phase 1 config to Phase 2 format");
397
398                    let workspace_dir = setting.workspace_dir.take().unwrap();
399                    let theme = if setting.theme.is_empty() {
400                        "dark".to_string()
401                    } else {
402                        setting.theme.clone()
403                    };
404                    let last_paths: Vec<String> = setting
405                        .last_paths
406                        .iter()
407                        .map(|p| p.to_string())
408                        .collect();
409
410                    // Validate workspace directory still exists
411                    if !workspace_dir.exists() {
412                        return Err(eyre::eyre!(
413                            "Cannot migrate: workspace directory {} no longer exists",
414                            workspace_dir.display()
415                        ));
416                    }
417
418                    setting.workspace_config = Some(WorkspaceConfig::from_phase1_migration(
419                        workspace_dir,
420                        theme,
421                        last_paths,
422                    ));
423                    setting.config_version = 2;
424                    setting.last_paths.clear();
425                    setting.theme.clear(); // Will use theme from workspace_config.global
426
427                    // Save migrated config
428                    setting.save_to_disk()?;
429                }
430
431                setting.merge_missing_default_bindings();
432                Ok(setting)
433            }
434            Err(e) => {
435                log::warn!(
436                    "Config file at {:?} could not be parsed ({}). \
437                     Renaming to .corrupt and starting with defaults.",
438                    path,
439                    e
440                );
441                let corrupt_path = path.with_extension("toml.corrupt");
442                let _ = fs::rename(&path, &corrupt_path);
443                let mut defaults = Self::default();
444                defaults.config_file = Some(path);
445                defaults.save_to_disk()?;
446                Ok(defaults)
447            }
448        }
449    }
450
451    /// Fills in any actions from `default_keybindings()` that are absent in the loaded config.
452    /// Existing user-customised bindings are never overwritten.
453    fn merge_missing_default_bindings(&mut self) {
454        let defaults = default_keybindings().to_hashmap();
455        let mut current = self.key_bindings.to_hashmap();
456        for (action, combos) in defaults {
457            current.entry(action).or_insert(combos);
458        }
459        self.key_bindings = KeyBindings::from_hashmap(current);
460    }
461
462    pub fn get_workspace_string(&self) -> String {
463        self.workspace_dir.as_ref().map_or_else(
464            || "<NONE>".to_string(),
465            |dir| dir.to_string_lossy().to_string(),
466        )
467    }
468
469    // We set a new workspace to work with, remember to save the data
470    // to persist it in disk
471    pub fn set_workspace(&mut self, workspace_path: &PathBuf) {
472        if let Some(current_workspace_dir) = &self.workspace_dir
473            && workspace_path != current_workspace_dir {
474                // We clean up the data related with the workspace
475                self.last_paths = vec![];
476                self.needs_indexing = true;
477            }
478
479        self.workspace_dir = Some(workspace_path.to_owned());
480    }
481
482    pub fn set_theme(&mut self, theme: String) {
483        self.theme = theme;
484    }
485
486    pub fn report_indexed(&mut self) {
487        self.needs_indexing = false;
488    }
489
490    pub fn needs_indexing(&self) -> bool {
491        self.needs_indexing
492    }
493
494    pub fn add_path_history(&mut self, note_path: &VaultPath) {
495        if note_path.is_note() {
496            // If the path already is in the history, we remove it
497            self.last_paths.retain(|path| !path.eq(note_path));
498            // Maximum size of the path list
499            // removing an element at a position is not very efficient
500            // but since is a short list, shouldn't be a major problem
501            while self.last_paths.len() >= LAST_PATH_HISTORY_SIZE {
502                self.last_paths.remove(0);
503            }
504            self.last_paths.push(note_path.to_owned());
505        }
506    }
507
508    /// Build the icon set for the current `use_nerd_fonts` setting.
509    pub fn icons(&self) -> icons::Icons {
510        icons::Icons::new(self.use_nerd_fonts)
511    }
512
513    /// Resolve the active theme by name, falling back to the default.
514    pub fn get_theme(&self) -> Theme {
515        if self.theme.is_empty() {
516            return Theme::default();
517        }
518        self.theme_list()
519            .into_iter()
520            .find(|t| t.name == self.theme)
521            .unwrap_or_default()
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
531        // RED: fails to compile because load_theme_from_path doesn't exist.
532        // GREEN: method exists, returns Err, and does NOT create the file.
533        let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
534        let _ = std::fs::remove_file(&path); // ensure clean state
535
536        let result = AppSettings::load_theme_from_path(&path);
537
538        assert!(result.is_err(), "should return Err when file is absent");
539        assert!(!path.exists(), "must not create the file as a side effect");
540    }
541
542    #[test]
543    fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
544        // After a corrupt file is removed, no replacement must be written.
545        let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
546        std::fs::write(&path, b"not valid toml {{{{").unwrap();
547
548        let result = AppSettings::load_theme_from_path(&path);
549
550        assert!(result.is_err(), "should return Err for corrupt TOML");
551        assert!(
552            !path.exists(),
553            "corrupt file must be removed, not recreated"
554        );
555    }
556
557    #[test]
558    fn autosave_interval_defaults_to_five() {
559        let settings = AppSettings::default();
560        assert_eq!(settings.autosave_interval_secs, 5);
561    }
562
563    #[test]
564    fn autosave_interval_deserializes_from_toml() {
565        let toml = "autosave_interval_secs = 30\n";
566        let settings: AppSettings = toml::from_str(toml).unwrap();
567        assert_eq!(settings.autosave_interval_secs, 30);
568    }
569
570    #[test]
571    fn autosave_interval_defaults_when_missing_from_toml() {
572        let toml = ""; // no autosave_interval_secs key
573        let settings: AppSettings = toml::from_str(toml).unwrap();
574        assert_eq!(settings.autosave_interval_secs, 5);
575    }
576
577    /// Verify the full load path: TOML with FileOperations = ["F2"] → keybinding lookup.
578    #[test]
579    fn f2_file_operations_survives_toml_deserialize() {
580        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
581        use crate::keys::key_strike::KeyStrike;
582
583        let toml = r#"
584[key_bindings]
585FileOperations = ["F2"]
586"#;
587        let settings: AppSettings = toml::from_str(toml).unwrap();
588        let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
589        let action = settings.key_bindings.get_action(&f2);
590        assert_eq!(action, Some(ActionShortcuts::FileOperations),
591            "F2 should survive deserialization and map to FileOperations");
592    }
593
594    /// Verify merge_missing_default_bindings adds F2 when absent from config.
595    #[test]
596    fn merge_adds_f2_when_absent() {
597        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
598        use crate::keys::key_strike::KeyStrike;
599
600        // Settings with no FileOperations binding
601        let toml = r#"
602[key_bindings]
603Quit = ["ctrl&Q"]
604"#;
605        let mut settings: AppSettings = toml::from_str(toml).unwrap();
606        settings.merge_missing_default_bindings();
607
608        let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
609        let action = settings.key_bindings.get_action(&f2);
610        assert_eq!(action, Some(ActionShortcuts::FileOperations),
611            "merge_missing_default_bindings should add F2 → FileOperations");
612    }
613}
614
615#[cfg(test)]
616mod backend_tests {
617    use super::*;
618
619    #[test]
620    fn default_backend_is_textarea() {
621        let settings = AppSettings::default();
622        assert!(matches!(settings.editor_backend, EditorBackendSetting::Textarea));
623    }
624
625    #[test]
626    fn nvim_backend_round_trips_toml() {
627        let toml = "editor_backend = \"nvim\"\n";
628        let parsed: AppSettings = toml::from_str(toml).unwrap();
629        assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
630    }
631}