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::{Path, PathBuf};
8use std::sync::{Arc, RwLock};
9
10use std::fs::{self, File};
11
12use color_eyre::eyre;
13
14pub type SharedSettings = Arc<RwLock<AppSettings>>;
16use kimun_core::nfs::VaultPath;
17
18use crate::keys::KeyBindings;
19mod config_dir;
20pub mod config_migration;
21pub mod history;
22pub mod icons;
23pub mod themes;
24pub mod workspace_config;
25
26#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum SortFieldSetting {
33 Name,
34 Title,
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
38#[serde(rename_all = "lowercase")]
39pub enum SortOrderSetting {
40 Ascending,
41 Descending,
42}
43
44#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum EditorBackendSetting {
47 #[default]
48 Textarea,
49 Nvim,
50}
51
52#[cfg(debug_assertions)]
55const CONFIG_DIR: &str = "kimun_debug";
56#[cfg(not(debug_assertions))]
57const CONFIG_DIR: &str = "kimun";
58
59const BASE_CONFIG_FILE: &str = "config.toml";
60const THEMES_DIR: &str = "themes";
61const CACHE_FILE_EXT: &str = "kimuncache";
62const HISTORY_FILE_EXT: &str = "txt";
63
64const CONFIG_HEADER: &str = "\
65# ─── Kimün configuration ────────────────────────────────────────────────────
66#
67# KEY BINDINGS
68# ────────────
69# Supported combinations:
70# - ctrl and/or alt (with optional shift) + a letter (a-z)
71# - bare F-key (F1–F12, no modifier required)
72# Any combo that does not follow these rules is silently ignored when loaded.
73#
74# Format per action:
75# ActionName = [\"<modifiers> & <letter>\", ...]
76#
77# Available modifiers (combine with +): ctrl alt shift
78#
79# Examples:
80# Quit = [\"ctrl&Q\"] # Ctrl+Q
81# SearchNotes = [\"ctrl&K\"] # Ctrl+K
82# OpenNote = [\"ctrl&O\"] # Ctrl+O (fuzzy file finder)
83# OpenSettings = [\"ctrl+shift&P\"] # Ctrl+Shift+P
84# NewJournal = [\"ctrl&J\"] # Ctrl+J
85# FileOperations = [\"F2\"] # F2 (open file-ops menu: delete/rename/move)
86#
87# ─────────────────────────────────────────────────────────────────────────────
88";
89
90#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
91pub struct AppSettings {
92 #[serde(default)]
94 pub config_version: u32,
95 #[serde(flatten, skip_serializing_if = "Option::is_none")]
96 pub workspace_config: Option<WorkspaceConfig>,
97
98 #[serde(skip_serializing_if = "Option::is_none")]
102 pub workspace_dir: Option<PathBuf>,
103 #[serde(default, skip_serializing)]
104 pub last_paths: Vec<VaultPath>,
105
106 #[serde(default)]
108 pub theme: String,
109 #[serde(default = "default_cache_dir")]
110 pub cache_dir: PathBuf,
111 #[serde(skip)]
112 cache_dir_resolved: Option<PathBuf>,
113
114 #[serde(default = "default_history_dir")]
115 pub history_dir: PathBuf,
116 #[serde(skip)]
117 history_dir_resolved: Option<PathBuf>,
118 #[serde(skip, default = "yes")]
119 needs_indexing: bool,
120 #[serde(default = "default_keybindings")]
121 pub key_bindings: KeyBindings,
122 #[serde(default = "default_autosave_interval")]
123 pub autosave_interval_secs: u64,
124 #[serde(default = "default_use_nerd_fonts")]
125 pub use_nerd_fonts: bool,
126 #[serde(default)]
127 pub editor_backend: EditorBackendSetting,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub nvim_path: Option<std::path::PathBuf>,
130 #[serde(default = "default_sort_field")]
131 pub default_sort_field: SortFieldSetting,
132 #[serde(default = "default_sort_order")]
133 pub default_sort_order: SortOrderSetting,
134 #[serde(default = "default_journal_sort_field")]
135 pub journal_sort_field: SortFieldSetting,
136 #[serde(default = "default_journal_sort_order")]
137 pub journal_sort_order: SortOrderSetting,
138 #[serde(default)]
139 pub group_directories: bool,
140 #[serde(skip)]
143 pub config_file: Option<PathBuf>,
144}
145
146fn default_keybindings() -> KeyBindings {
147 let mut kb = KeyBindings::empty();
148 kb.batch_add()
149 .with_ctrl()
150 .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
151 .add(KeyStrike::KeyO, ActionShortcuts::OpenNote)
152 .add(KeyStrike::KeyY, ActionShortcuts::TogglePreview)
153 .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
154 .add(KeyStrike::KeyI, ActionShortcuts::Text(TextAction::Italic))
155 .add(
156 KeyStrike::KeyU,
157 ActionShortcuts::Text(TextAction::Underline),
158 )
159 .add(
160 KeyStrike::KeyS,
161 ActionShortcuts::Text(TextAction::Strikethrough),
162 )
163 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Link))
164 .add(
165 KeyStrike::KeyT,
166 ActionShortcuts::Text(TextAction::ToggleHeader),
167 )
168 .with_shift()
172 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Image));
173
174 kb.batch_add()
178 .with_ctrl()
179 .add(KeyStrike::KeyP, ActionShortcuts::OpenSettings)
180 .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
181 .add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
182 .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
183 .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
184 .add(KeyStrike::KeyG, ActionShortcuts::FollowLink)
185 .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
186 .add(KeyStrike::KeyL, ActionShortcuts::FocusEditor)
187 .add(KeyStrike::KeyW, ActionShortcuts::QuickNote)
188 .add(KeyStrike::KeyE, ActionShortcuts::ToggleQueryPanel)
189 .add(KeyStrike::KeyF, ActionShortcuts::FindInBuffer);
190
191 kb.batch_add()
193 .add(KeyStrike::F2, ActionShortcuts::FileOperations);
194
195 kb.batch_add()
196 .add(KeyStrike::F3, ActionShortcuts::OpenSavedSearches);
197
198 kb.batch_add()
199 .add(KeyStrike::F4, ActionShortcuts::SwitchWorkspace);
200
201 kb.batch_add()
206 .with_ctrl()
207 .add(KeyStrike::KeyD, ActionShortcuts::SaveCurrentQuery);
208
209 kb
210}
211
212fn yes() -> bool {
213 true
214}
215
216fn default_autosave_interval() -> u64 {
217 5
218}
219
220fn default_cache_dir() -> PathBuf {
221 PathBuf::from(".")
222}
223
224fn default_history_dir() -> PathBuf {
225 PathBuf::from("history")
226}
227
228fn default_use_nerd_fonts() -> bool {
229 false
230}
231
232fn default_sort_field() -> SortFieldSetting {
233 SortFieldSetting::Name
234}
235
236fn default_sort_order() -> SortOrderSetting {
237 SortOrderSetting::Ascending
238}
239
240fn default_journal_sort_field() -> SortFieldSetting {
241 SortFieldSetting::Name
242}
243
244fn default_journal_sort_order() -> SortOrderSetting {
245 SortOrderSetting::Descending
246}
247
248impl Default for AppSettings {
249 fn default() -> Self {
250 Self {
251 config_version: 0,
252 workspace_config: None,
253 last_paths: vec![],
254 workspace_dir: None,
255 theme: Default::default(),
256 cache_dir: default_cache_dir(),
257 cache_dir_resolved: None,
258 history_dir: default_history_dir(),
259 history_dir_resolved: None,
260 needs_indexing: true,
261 key_bindings: default_keybindings(),
262 autosave_interval_secs: default_autosave_interval(),
263 use_nerd_fonts: false,
264 editor_backend: EditorBackendSetting::Textarea,
265 nvim_path: None,
266 default_sort_field: default_sort_field(),
267 default_sort_order: default_sort_order(),
268 journal_sort_field: default_journal_sort_field(),
269 journal_sort_order: default_journal_sort_order(),
270 group_directories: false,
271 config_file: None,
272 }
273 }
274}
275
276impl AppSettings {
277 pub fn theme_list(&self) -> Vec<Theme> {
278 let mut list = vec![
279 Theme::gruvbox_dark(),
280 Theme::gruvbox_light(),
281 Theme::catppuccin_mocha(),
282 Theme::catppuccin_latte(),
283 Theme::tokyo_night(),
284 Theme::tokyo_night_storm(),
285 Theme::solarized_dark(),
286 Theme::solarized_light(),
287 Theme::nord(),
288 Theme::ansi(),
289 ];
290 list.append(&mut Self::load_custom_themes());
291 if let Ok(custom_default) = Self::load_default_theme() {
293 list.push(custom_default);
294 }
295 list.sort_by(|a, b| a.name.cmp(&b.name));
296 list
297 }
298
299 fn default_config_file_path() -> eyre::Result<PathBuf> {
300 let config_home = get_or_create_config_dir(CONFIG_DIR)?;
301 Ok(config_home.join(BASE_CONFIG_FILE))
302 }
303
304 fn get_config_file_path(&self) -> eyre::Result<PathBuf> {
305 if let Some(ref path) = self.config_file {
306 Ok(path.clone())
307 } else {
308 Self::default_config_file_path()
309 }
310 }
311
312 fn get_themes_path() -> eyre::Result<PathBuf> {
313 let config_home = get_or_create_config_dir(CONFIG_DIR)?;
314 Ok(config_home.join(THEMES_DIR))
315 }
316
317 fn load_theme_from_path(path: &std::path::Path) -> eyre::Result<Theme> {
318 let theme_string = fs::read_to_string(path)?;
319 match toml::from_str::<Theme>(&theme_string) {
320 Ok(theme) => Ok(theme),
321 Err(e) => {
322 tracing::debug!(
323 "Failed to deserialize theme file {:?}: {}. Removing.",
324 path,
325 e
326 );
327 let _ = fs::remove_file(path);
328 Err(eyre::eyre!("corrupt theme file: {}", e))
329 }
330 }
331 }
332
333 fn load_default_theme() -> eyre::Result<Theme> {
334 let theme_path = AppSettings::get_themes_path()?.join("default.toml");
335 Self::load_theme_from_path(&theme_path)
336 }
337
338 fn load_custom_themes() -> Vec<Theme> {
339 let mut themes = Vec::new();
340
341 let themes_path = match Self::get_themes_path() {
343 Ok(path) => path,
344 Err(_) => return themes,
345 };
346
347 let entries = match fs::read_dir(&themes_path) {
349 Ok(entries) => entries,
350 Err(_) => return themes,
351 };
352
353 for entry in entries.flatten() {
355 let path = entry.path();
356
357 if !path.is_file() {
359 continue;
360 }
361
362 if path.extension().and_then(|s| s.to_str()) != Some("toml") {
364 continue;
365 }
366
367 if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
369 continue;
370 }
371
372 match fs::read_to_string(&path)
374 .and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
375 {
376 Ok(theme) => themes.push(theme),
377 Err(e) => tracing::warn!("Skipping theme file {:?}: {}", path, e),
378 }
379 }
380
381 themes
382 }
383
384 pub fn save_to_disk(&self) -> eyre::Result<()> {
385 tracing::debug!("Saving settings to disk");
386 let settings_file_path = self.get_config_file_path()?;
387 let mut file = File::create(settings_file_path)?;
388 file.write_all(CONFIG_HEADER.as_bytes())?;
389 let toml = toml::to_string(&self)?;
390 file.write_all(toml.as_bytes())?;
391 Ok(())
392 }
393
394 pub fn load_from_disk() -> eyre::Result<Self> {
395 let settings_file_path = Self::default_config_file_path()?;
396
397 if !settings_file_path.exists() {
398 let default_settings = Self::default();
399 default_settings.save_to_disk()?;
400 Ok(default_settings)
401 } else {
402 let mut settings_file = File::open(&settings_file_path)?;
403
404 let mut toml = String::new();
405 settings_file.read_to_string(&mut toml)?;
406
407 match toml::from_str::<AppSettings>(toml.as_ref()) {
408 Ok(mut setting) => {
409 setting.config_file = Some(settings_file_path.clone());
410 let config_dir = settings_file_path
411 .parent()
412 .unwrap_or(std::path::Path::new("."));
413 setting.resolve_paths(config_dir);
414 if config_migration::ConfigMigration::run(&mut setting)? {
415 setting.save_to_disk()?;
416 }
417 setting.merge_missing_default_bindings();
418 Ok(setting)
419 }
420 Err(e) => {
421 tracing::warn!(
422 "Config file at {:?} could not be parsed ({}). \
423 Renaming to .corrupt and starting with defaults.",
424 settings_file_path,
425 e
426 );
427 let corrupt_path = settings_file_path.with_extension("toml.corrupt");
428 let _ = fs::rename(&settings_file_path, &corrupt_path);
429 let defaults = Self::default();
430 defaults.save_to_disk()?;
431 Ok(defaults)
432 }
433 }
434 }
435 }
436
437 pub fn load_from_file(path: PathBuf) -> eyre::Result<Self> {
438 if let Some(parent) = path.parent() {
439 fs::create_dir_all(parent)?;
440 }
441 if !path.exists() {
442 let default_settings = Self {
443 config_file: Some(path),
444 ..Self::default()
445 };
446 default_settings.save_to_disk()?;
447 return Ok(default_settings);
448 }
449 let mut toml_str = String::new();
450 File::open(&path)?.read_to_string(&mut toml_str)?;
451 match toml::from_str::<AppSettings>(&toml_str) {
452 Ok(mut setting) => {
453 setting.config_file = Some(path.clone());
454
455 let config_dir = path.parent().unwrap_or(std::path::Path::new("."));
457 setting.resolve_paths(config_dir);
458
459 if config_migration::ConfigMigration::run(&mut setting)? {
461 setting.save_to_disk()?;
462 }
463
464 setting.merge_missing_default_bindings();
465 Ok(setting)
466 }
467 Err(e) => {
468 tracing::warn!(
469 "Config file at {:?} could not be parsed ({}). \
470 Renaming to .corrupt and starting with defaults.",
471 path,
472 e
473 );
474 let corrupt_path = path.with_extension("toml.corrupt");
475 let _ = fs::rename(&path, &corrupt_path);
476 let defaults = Self {
477 config_file: Some(path),
478 ..Self::default()
479 };
480 defaults.save_to_disk()?;
481 Ok(defaults)
482 }
483 }
484 }
485
486 fn merge_missing_default_bindings(&mut self) {
489 let defaults = default_keybindings().to_hashmap();
490 let mut current = self.key_bindings.to_hashmap();
491 for (action, combos) in defaults {
492 current.entry(action).or_insert(combos);
493 }
494 self.key_bindings = KeyBindings::from_hashmap(current);
495 }
496
497 pub fn set_workspace(&mut self, workspace_path: &PathBuf) {
500 if let Some(current_workspace_dir) = &self.workspace_dir
501 && workspace_path != current_workspace_dir
502 {
503 self.needs_indexing = true;
504 }
505
506 self.workspace_dir = Some(workspace_path.to_owned());
507 }
508
509 pub fn clear_workspace(&mut self) {
516 if self.workspace_dir.is_some() {
518 self.workspace_dir = None;
519 self.needs_indexing = true;
520 }
521 if let Some(wc) = &mut self.workspace_config {
523 let key = wc.global.current_workspace.clone();
524 if !key.is_empty() {
525 wc.workspaces.remove(&key);
526 }
527 wc.global.current_workspace = String::new();
528 }
529 }
530
531 pub fn resolve_workspace_path(&self) -> Option<PathBuf> {
534 self.workspace_config
535 .as_ref()
536 .and_then(|wc| wc.get_current_workspace())
537 .map(|entry| entry.effective_path().clone())
538 .or_else(|| self.workspace_dir.clone())
539 }
540
541 fn resolve_paths(&mut self, base: &std::path::Path) {
545 if let Some(ref mut p) = self.workspace_dir {
548 *p = Self::expand_path(p, base);
549 }
550 if let Some(ref mut wc) = self.workspace_config {
552 for entry in wc.workspaces.values_mut() {
553 let resolved = Self::expand_path(&entry.path, base);
554 if resolved != entry.path {
555 entry.resolved_path = Some(resolved);
556 }
557 }
558 }
559 self.cache_dir_resolved = Some(Self::expand_path(&self.cache_dir, base));
560 self.history_dir_resolved = Some(Self::expand_path(&self.history_dir, base));
561 }
562
563 fn expand_path(path: &std::path::Path, base: &std::path::Path) -> PathBuf {
567 let s = path.to_string_lossy();
568 let expanded = if s.starts_with("~/") || s == "~" {
569 if let Ok(home) = config_dir::get_home_dir() {
570 home.join(s.strip_prefix("~/").unwrap_or(""))
571 } else {
572 path.to_path_buf()
573 }
574 } else {
575 path.to_path_buf()
576 };
577 let absolute = if expanded.is_relative() {
578 base.join(expanded)
579 } else {
580 expanded
581 };
582 absolute.canonicalize().unwrap_or(absolute)
584 }
585
586 pub fn set_theme(&mut self, theme: String) {
587 self.theme = theme;
588 }
589
590 pub fn report_indexed(&mut self) {
591 self.needs_indexing = false;
592 }
593
594 pub fn needs_indexing(&self) -> bool {
595 self.needs_indexing
596 }
597
598 pub fn add_path_history(&mut self, note_path: &VaultPath) {
599 if !note_path.is_note() {
600 return;
601 }
602 let Some(workspace_name) = self.current_workspace_name() else {
603 return;
604 };
605 let file_path = self.history_path_for(&workspace_name);
606 if let Err(e) = history::push_history(&file_path, note_path) {
607 tracing::warn!("failed to write history {:?}: {}", file_path, e);
608 }
609 }
610
611 pub fn current_workspace_name(&self) -> Option<String> {
612 self.workspace_config
613 .as_ref()
614 .map(|wc| wc.global.current_workspace.clone())
615 .filter(|s| !s.is_empty())
616 }
617
618 pub fn cache_dir_resolved(&self) -> Option<&Path> {
619 self.cache_dir_resolved.as_deref()
620 }
621
622 pub fn history_dir_resolved(&self) -> Option<&Path> {
623 self.history_dir_resolved.as_deref()
624 }
625
626 pub fn cache_path_for(&self, workspace_name: &str) -> PathBuf {
630 Self::workspace_file(
631 self.cache_dir_resolved.as_ref().unwrap_or(&self.cache_dir),
632 workspace_name,
633 CACHE_FILE_EXT,
634 )
635 }
636
637 pub fn history_path_for(&self, workspace_name: &str) -> PathBuf {
640 Self::workspace_file(
641 self.history_dir_resolved
642 .as_ref()
643 .unwrap_or(&self.history_dir),
644 workspace_name,
645 HISTORY_FILE_EXT,
646 )
647 }
648
649 fn workspace_file(dir: &Path, workspace_name: &str, ext: &str) -> PathBuf {
650 dir.join(format!("{workspace_name}.{ext}"))
651 }
652
653 pub fn current_last_paths(&self) -> Vec<VaultPath> {
655 let Some(name) = self.current_workspace_name() else {
656 return Vec::new();
657 };
658 let file_path = self.history_path_for(&name);
659 history::load_history(&file_path)
660 }
661
662 pub fn icons(&self) -> icons::Icons {
664 icons::Icons::new(self.use_nerd_fonts)
665 }
666
667 pub fn get_theme(&self) -> Theme {
669 if self.theme.is_empty() {
670 return Theme::default();
671 }
672 self.theme_list()
673 .into_iter()
674 .find(|t| t.name == self.theme)
675 .unwrap_or_default()
676 }
677}
678
679#[cfg(test)]
680#[allow(clippy::field_reassign_with_default)]
681mod tests {
682 use super::*;
683
684 #[test]
685 fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
686 let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
689 let _ = std::fs::remove_file(&path); let result = AppSettings::load_theme_from_path(&path);
692
693 assert!(result.is_err(), "should return Err when file is absent");
694 assert!(!path.exists(), "must not create the file as a side effect");
695 }
696
697 #[test]
698 fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
699 let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
701 std::fs::write(&path, b"not valid toml {{{{").unwrap();
702
703 let result = AppSettings::load_theme_from_path(&path);
704
705 assert!(result.is_err(), "should return Err for corrupt TOML");
706 assert!(
707 !path.exists(),
708 "corrupt file must be removed, not recreated"
709 );
710 }
711
712 #[test]
713 fn default_keybindings_quit_matches_canonical_combo() {
714 let kb = default_keybindings();
715 let combo = crate::keys::default_quit_combo();
716 assert_eq!(
717 kb.get_action(&combo),
718 Some(ActionShortcuts::Quit),
719 "default_keybindings() must bind default_quit_combo() to Quit so the \
720 deserialize safety net can recover an unreachable app"
721 );
722 }
723
724 #[test]
725 fn autosave_interval_defaults_to_five() {
726 let settings = AppSettings::default();
727 assert_eq!(settings.autosave_interval_secs, 5);
728 }
729
730 #[test]
731 fn autosave_interval_deserializes_from_toml() {
732 let toml = "autosave_interval_secs = 30\n";
733 let settings: AppSettings = toml::from_str(toml).unwrap();
734 assert_eq!(settings.autosave_interval_secs, 30);
735 }
736
737 #[test]
738 fn autosave_interval_defaults_when_missing_from_toml() {
739 let toml = ""; let settings: AppSettings = toml::from_str(toml).unwrap();
741 assert_eq!(settings.autosave_interval_secs, 5);
742 }
743
744 #[test]
746 fn f2_file_operations_survives_toml_deserialize() {
747 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
748 use crate::keys::key_strike::KeyStrike;
749
750 let toml = r#"
751[key_bindings]
752FileOperations = ["F2"]
753"#;
754 let settings: AppSettings = toml::from_str(toml).unwrap();
755 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
756 let action = settings.key_bindings.get_action(&f2);
757 assert_eq!(
758 action,
759 Some(ActionShortcuts::FileOperations),
760 "F2 should survive deserialization and map to FileOperations"
761 );
762 }
763
764 #[test]
766 fn merge_adds_f2_when_absent() {
767 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
768 use crate::keys::key_strike::KeyStrike;
769
770 let toml = r#"
772[key_bindings]
773Quit = ["ctrl&Q"]
774"#;
775 let mut settings: AppSettings = toml::from_str(toml).unwrap();
776 settings.merge_missing_default_bindings();
777
778 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
779 let action = settings.key_bindings.get_action(&f2);
780 assert_eq!(
781 action,
782 Some(ActionShortcuts::FileOperations),
783 "merge_missing_default_bindings should add F2 → FileOperations"
784 );
785 }
786
787 #[test]
788 fn clear_workspace_phase1_clears_workspace_dir() {
789 let mut settings = AppSettings::default();
790 settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
791 settings.needs_indexing = false;
792 settings.clear_workspace();
793 assert!(
794 settings.workspace_dir.is_none(),
795 "workspace_dir should be None"
796 );
797 assert!(
798 settings.needs_indexing,
799 "needs_indexing should be reset to true"
800 );
801 }
802
803 #[test]
804 fn clear_workspace_phase2_removes_current_workspace_entry() {
805 let mut settings = AppSettings::default();
806 let mut wc = WorkspaceConfig::new_empty();
807 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
808 .unwrap();
809 settings.workspace_config = Some(wc);
810 assert_eq!(
812 settings
813 .workspace_config
814 .as_ref()
815 .unwrap()
816 .global
817 .current_workspace,
818 "vault1"
819 );
820 settings.clear_workspace();
821 let wc = settings.workspace_config.as_ref().unwrap();
822 assert!(
823 wc.workspaces.is_empty(),
824 "workspace entry should be removed"
825 );
826 assert!(
827 wc.global.current_workspace.is_empty(),
828 "current_workspace should be empty"
829 );
830 }
831
832 #[test]
833 fn clear_workspace_both_phases_active() {
834 let mut settings = AppSettings::default();
837 settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
838 let mut wc = WorkspaceConfig::new_empty();
839 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
840 .unwrap();
841 settings.workspace_config = Some(wc);
842 settings.clear_workspace();
843 assert!(
844 settings.workspace_dir.is_none(),
845 "phase1 workspace_dir should be cleared"
846 );
847 let wc = settings.workspace_config.as_ref().unwrap();
848 assert!(
849 wc.workspaces.is_empty(),
850 "phase2 workspace entry should be removed"
851 );
852 assert!(
853 wc.global.current_workspace.is_empty(),
854 "phase2 current_workspace should be empty"
855 );
856 }
857
858 #[test]
859 fn clear_workspace_phase2_preserves_other_workspaces() {
860 let mut settings = AppSettings::default();
861 let mut wc = WorkspaceConfig::new_empty();
862 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
863 .unwrap();
864 wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
865 .unwrap();
866 wc.global.current_workspace = "vault1".to_string();
867 settings.workspace_config = Some(wc);
868 settings.clear_workspace();
869 let wc = settings.workspace_config.as_ref().unwrap();
870 assert!(
871 !wc.workspaces.contains_key("vault1"),
872 "active workspace should be removed"
873 );
874 assert!(
875 wc.workspaces.contains_key("vault2"),
876 "other workspaces should be preserved"
877 );
878 assert!(
879 wc.global.current_workspace.is_empty(),
880 "current_workspace should be empty"
881 );
882 }
883}
884
885#[cfg(test)]
886mod backend_tests {
887 use super::*;
888
889 #[test]
890 fn default_backend_is_textarea() {
891 let settings = AppSettings::default();
892 assert!(matches!(
893 settings.editor_backend,
894 EditorBackendSetting::Textarea
895 ));
896 }
897
898 #[test]
899 fn nvim_backend_round_trips_toml() {
900 let toml = "editor_backend = \"nvim\"\n";
901 let parsed: AppSettings = toml::from_str(toml).unwrap();
902 assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
903 }
904
905 #[test]
908 fn expand_path_absolute_unchanged() {
909 let base = PathBuf::from("/config/dir");
910 let result = AppSettings::expand_path(std::path::Path::new("/absolute/path/notes"), &base);
911 assert!(result.is_absolute());
912 assert!(result.to_string_lossy().contains("absolute"));
913 }
914
915 #[test]
916 fn expand_path_relative_resolved_against_base() {
917 let base = tempfile::TempDir::new().unwrap();
918 let notes = base.path().join("notes");
919 std::fs::create_dir_all(¬es).unwrap();
920
921 let result = AppSettings::expand_path(std::path::Path::new("notes"), base.path());
922 assert!(result.is_absolute());
923 assert_eq!(result, notes.canonicalize().unwrap());
924 }
925
926 #[test]
927 fn expand_path_relative_with_dotdot() {
928 let base = tempfile::TempDir::new().unwrap();
929 let sibling = base.path().join("sibling");
930 std::fs::create_dir_all(&sibling).unwrap();
931 let sub = base.path().join("sub");
932 std::fs::create_dir_all(&sub).unwrap();
933
934 let result = AppSettings::expand_path(std::path::Path::new("../sibling"), &sub);
935 assert!(result.is_absolute());
936 assert_eq!(result, sibling.canonicalize().unwrap());
937 }
938
939 #[test]
940 fn expand_path_nonexistent_relative_still_absolute() {
941 let base = PathBuf::from("/some/config/dir");
942 let result = AppSettings::expand_path(std::path::Path::new("my-notes"), &base);
943 assert!(result.is_absolute());
944 assert_eq!(result, PathBuf::from("/some/config/dir/my-notes"));
945 }
946
947 #[test]
948 #[cfg(unix)]
949 fn expand_path_tilde_uses_home_unix() {
950 let home = std::env::var("HOME").expect("HOME must be set on Unix");
951 let base = PathBuf::from("/irrelevant");
952 let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
953 assert!(result.is_absolute());
954 assert!(
955 result.starts_with(&home),
956 "expected path to start with HOME={}, got {:?}",
957 home,
958 result
959 );
960 assert!(result.to_string_lossy().contains("Documents/notes"));
961 }
962
963 #[test]
964 #[cfg(unix)]
965 fn expand_path_tilde_alone_is_home_unix() {
966 let home = std::env::var("HOME").expect("HOME must be set on Unix");
967 let base = PathBuf::from("/irrelevant");
968 let result = AppSettings::expand_path(std::path::Path::new("~"), &base);
969 assert!(result.is_absolute());
970 let expected = PathBuf::from(&home)
972 .canonicalize()
973 .unwrap_or(PathBuf::from(&home));
974 assert_eq!(result, expected);
975 }
976
977 #[test]
978 #[cfg(windows)]
979 fn expand_path_tilde_uses_userprofile_windows() {
980 let home = std::env::var("USERPROFILE").expect("USERPROFILE must be set on Windows");
981 let base = PathBuf::from("C:\\irrelevant");
982 let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
983 assert!(result.is_absolute());
984 assert!(
985 result.starts_with(&home),
986 "expected path to start with USERPROFILE={}, got {:?}",
987 home,
988 result
989 );
990 }
991
992 #[test]
993 fn resolve_paths_populates_resolved_path() {
994 let base = tempfile::TempDir::new().unwrap();
995 let notes = base.path().join("notes");
996 std::fs::create_dir_all(¬es).unwrap();
997
998 let toml = r#"
999config_version = 2
1000[global]
1001current_workspace = "test"
1002[workspaces.test]
1003path = "notes"
1004last_paths = []
1005created = "2026-01-01T00:00:00Z"
1006"#
1007 .to_string();
1008 let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1009 settings.resolve_paths(base.path());
1010
1011 let wc = settings.workspace_config.as_ref().unwrap();
1012 let entry = wc.workspaces.get("test").unwrap();
1013 assert_eq!(entry.path, PathBuf::from("notes"));
1015 assert!(entry.resolved_path.is_some());
1017 assert!(entry.effective_path().is_absolute());
1018 }
1019
1020 #[test]
1021 fn resolve_paths_absolute_no_resolved_path() {
1022 let toml = r#"
1023config_version = 2
1024[global]
1025current_workspace = "test"
1026[workspaces.test]
1027path = "/absolute/notes"
1028last_paths = []
1029created = "2026-01-01T00:00:00Z"
1030"#;
1031 let mut settings: AppSettings = toml::from_str(toml).unwrap();
1032 settings.resolve_paths(std::path::Path::new("/config"));
1033
1034 let wc = settings.workspace_config.as_ref().unwrap();
1035 let entry = wc.workspaces.get("test").unwrap();
1036 assert!(entry.resolved_path.is_none());
1038 assert_eq!(*entry.effective_path(), PathBuf::from("/absolute/notes"));
1039 }
1040}
1041
1042#[cfg(test)]
1043mod sort_settings_tests {
1044 use super::*;
1045
1046 #[test]
1047 fn group_directories_defaults_off() {
1048 let s = AppSettings::default();
1049 assert!(!s.group_directories);
1050 }
1051
1052 #[test]
1053 fn open_sort_dialog_is_bound_by_default() {
1054 let s = AppSettings::default();
1055 let map = s.key_bindings.to_hashmap();
1056 assert!(
1057 map.contains_key(&ActionShortcuts::OpenSortDialog),
1058 "OpenSortDialog must have a default binding"
1059 );
1060 }
1061}