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#[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 #[serde(default)]
81 pub config_version: u32,
82 #[serde(flatten, skip_serializing_if = "Option::is_none")]
83 pub workspace_config: Option<WorkspaceConfig>,
84
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub workspace_dir: Option<PathBuf>,
88 #[serde(default)]
89 pub last_paths: Vec<VaultPath>,
90
91 #[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 #[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 .with_shift()
142 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Image));
143
144 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 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 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 let themes_path = match Self::get_themes_path() {
278 Ok(path) => path,
279 Err(_) => return themes,
280 };
281
282 let entries = match fs::read_dir(&themes_path) {
284 Ok(entries) => entries,
285 Err(_) => return themes,
286 };
287
288 for entry in entries.flatten() {
290 let path = entry.path();
291
292 if !path.is_file() {
294 continue;
295 }
296
297 if path.extension().and_then(|s| s.to_str()) != Some("toml") {
299 continue;
300 }
301
302 if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
304 continue;
305 }
306
307 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 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 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(); 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 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 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 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 self.last_paths.retain(|path| !path.eq(note_path));
485 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 pub fn icons(&self) -> icons::Icons {
497 icons::Icons::new(self.use_nerd_fonts)
498 }
499
500 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 let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
521 let _ = std::fs::remove_file(&path); 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 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 = ""; let settings: AppSettings = toml::from_str(toml).unwrap();
561 assert_eq!(settings.autosave_interval_secs, 5);
562 }
563
564 #[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 #[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 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}