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