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