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#[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#[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 #[serde(default)]
89 pub config_version: u32,
90 #[serde(flatten, skip_serializing_if = "Option::is_none")]
91 pub workspace_config: Option<WorkspaceConfig>,
92
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub workspace_dir: Option<PathBuf>,
96 #[serde(default)]
97 pub last_paths: Vec<VaultPath>,
98
99 #[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 #[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 .with_shift()
154 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Image));
155
156 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 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 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 let themes_path = match Self::get_themes_path() {
292 Ok(path) => path,
293 Err(_) => return themes,
294 };
295
296 let entries = match fs::read_dir(&themes_path) {
298 Ok(entries) => entries,
299 Err(_) => return themes,
300 };
301
302 for entry in entries.flatten() {
304 let path = entry.path();
305
306 if !path.is_file() {
308 continue;
309 }
310
311 if path.extension().and_then(|s| s.to_str()) != Some("toml") {
313 continue;
314 }
315
316 if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
318 continue;
319 }
320
321 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 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 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(); 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 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 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 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 self.last_paths.retain(|path| !path.eq(note_path));
498 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 pub fn icons(&self) -> icons::Icons {
510 icons::Icons::new(self.use_nerd_fonts)
511 }
512
513 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 let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
534 let _ = std::fs::remove_file(&path); 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 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 = ""; let settings: AppSettings = toml::from_str(toml).unwrap();
574 assert_eq!(settings.autosave_interval_secs, 5);
575 }
576
577 #[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 #[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 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}