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(skip)]
141 pub config_file: Option<PathBuf>,
142}
143
144fn default_keybindings() -> KeyBindings {
145 let mut kb = KeyBindings::empty();
146 kb.batch_add()
147 .with_ctrl()
148 .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
149 .add(KeyStrike::KeyO, ActionShortcuts::OpenNote)
150 .add(KeyStrike::KeyY, ActionShortcuts::TogglePreview)
151 .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
152 .add(KeyStrike::KeyI, ActionShortcuts::Text(TextAction::Italic))
153 .add(
154 KeyStrike::KeyU,
155 ActionShortcuts::Text(TextAction::Underline),
156 )
157 .add(
158 KeyStrike::KeyS,
159 ActionShortcuts::Text(TextAction::Strikethrough),
160 )
161 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Link))
162 .add(
163 KeyStrike::KeyT,
164 ActionShortcuts::Text(TextAction::ToggleHeader),
165 )
166 .with_shift()
170 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Image));
171
172 kb.batch_add()
176 .with_ctrl()
177 .add(KeyStrike::KeyP, ActionShortcuts::OpenSettings)
178 .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
179 .add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
180 .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
181 .add(KeyStrike::KeyN, ActionShortcuts::CycleSortField)
182 .add(KeyStrike::KeyG, ActionShortcuts::FollowLink)
183 .add(KeyStrike::KeyR, ActionShortcuts::SortReverseOrder)
184 .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
185 .add(KeyStrike::KeyL, ActionShortcuts::FocusEditor)
186 .add(KeyStrike::KeyW, ActionShortcuts::QuickNote)
187 .add(KeyStrike::KeyE, ActionShortcuts::ToggleBacklinks)
188 .add(KeyStrike::KeyF, ActionShortcuts::FindInBuffer);
189
190 kb.batch_add()
192 .add(KeyStrike::F2, ActionShortcuts::FileOperations);
193
194 kb.batch_add()
195 .add(KeyStrike::F4, ActionShortcuts::SwitchWorkspace);
196
197 kb
198}
199
200fn yes() -> bool {
201 true
202}
203
204fn default_autosave_interval() -> u64 {
205 5
206}
207
208fn default_cache_dir() -> PathBuf {
209 PathBuf::from(".")
210}
211
212fn default_history_dir() -> PathBuf {
213 PathBuf::from("history")
214}
215
216fn default_use_nerd_fonts() -> bool {
217 false
218}
219
220fn default_sort_field() -> SortFieldSetting {
221 SortFieldSetting::Name
222}
223
224fn default_sort_order() -> SortOrderSetting {
225 SortOrderSetting::Ascending
226}
227
228fn default_journal_sort_field() -> SortFieldSetting {
229 SortFieldSetting::Name
230}
231
232fn default_journal_sort_order() -> SortOrderSetting {
233 SortOrderSetting::Descending
234}
235
236impl Default for AppSettings {
237 fn default() -> Self {
238 Self {
239 config_version: 0,
240 workspace_config: None,
241 last_paths: vec![],
242 workspace_dir: None,
243 theme: Default::default(),
244 cache_dir: default_cache_dir(),
245 cache_dir_resolved: None,
246 history_dir: default_history_dir(),
247 history_dir_resolved: None,
248 needs_indexing: true,
249 key_bindings: default_keybindings(),
250 autosave_interval_secs: default_autosave_interval(),
251 use_nerd_fonts: false,
252 editor_backend: EditorBackendSetting::Textarea,
253 nvim_path: None,
254 default_sort_field: default_sort_field(),
255 default_sort_order: default_sort_order(),
256 journal_sort_field: default_journal_sort_field(),
257 journal_sort_order: default_journal_sort_order(),
258 config_file: None,
259 }
260 }
261}
262
263impl AppSettings {
264 pub fn theme_list(&self) -> Vec<Theme> {
265 let mut list = vec![
266 Theme::gruvbox_dark(),
267 Theme::gruvbox_light(),
268 Theme::catppuccin_mocha(),
269 Theme::catppuccin_latte(),
270 Theme::tokyo_night(),
271 Theme::tokyo_night_storm(),
272 Theme::solarized_dark(),
273 Theme::solarized_light(),
274 Theme::nord(),
275 Theme::ansi(),
276 ];
277 list.append(&mut Self::load_custom_themes());
278 if let Ok(custom_default) = Self::load_default_theme() {
280 list.push(custom_default);
281 }
282 list.sort_by(|a, b| a.name.cmp(&b.name));
283 list
284 }
285
286 fn default_config_file_path() -> eyre::Result<PathBuf> {
287 let config_home = get_or_create_config_dir(CONFIG_DIR)?;
288 Ok(config_home.join(BASE_CONFIG_FILE))
289 }
290
291 fn get_config_file_path(&self) -> eyre::Result<PathBuf> {
292 if let Some(ref path) = self.config_file {
293 Ok(path.clone())
294 } else {
295 Self::default_config_file_path()
296 }
297 }
298
299 fn get_themes_path() -> eyre::Result<PathBuf> {
300 let config_home = get_or_create_config_dir(CONFIG_DIR)?;
301 Ok(config_home.join(THEMES_DIR))
302 }
303
304 fn load_theme_from_path(path: &std::path::Path) -> eyre::Result<Theme> {
305 let theme_string = fs::read_to_string(path)?;
306 match toml::from_str::<Theme>(&theme_string) {
307 Ok(theme) => Ok(theme),
308 Err(e) => {
309 tracing::debug!(
310 "Failed to deserialize theme file {:?}: {}. Removing.",
311 path,
312 e
313 );
314 let _ = fs::remove_file(path);
315 Err(eyre::eyre!("corrupt theme file: {}", e))
316 }
317 }
318 }
319
320 fn load_default_theme() -> eyre::Result<Theme> {
321 let theme_path = AppSettings::get_themes_path()?.join("default.toml");
322 Self::load_theme_from_path(&theme_path)
323 }
324
325 fn load_custom_themes() -> Vec<Theme> {
326 let mut themes = Vec::new();
327
328 let themes_path = match Self::get_themes_path() {
330 Ok(path) => path,
331 Err(_) => return themes,
332 };
333
334 let entries = match fs::read_dir(&themes_path) {
336 Ok(entries) => entries,
337 Err(_) => return themes,
338 };
339
340 for entry in entries.flatten() {
342 let path = entry.path();
343
344 if !path.is_file() {
346 continue;
347 }
348
349 if path.extension().and_then(|s| s.to_str()) != Some("toml") {
351 continue;
352 }
353
354 if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
356 continue;
357 }
358
359 match fs::read_to_string(&path)
361 .and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
362 {
363 Ok(theme) => themes.push(theme),
364 Err(e) => tracing::warn!("Skipping theme file {:?}: {}", path, e),
365 }
366 }
367
368 themes
369 }
370
371 pub fn save_to_disk(&self) -> eyre::Result<()> {
372 tracing::debug!("Saving settings to disk");
373 let settings_file_path = self.get_config_file_path()?;
374 let mut file = File::create(settings_file_path)?;
375 file.write_all(CONFIG_HEADER.as_bytes())?;
376 let toml = toml::to_string(&self)?;
377 file.write_all(toml.as_bytes())?;
378 Ok(())
379 }
380
381 pub fn load_from_disk() -> eyre::Result<Self> {
382 let settings_file_path = Self::default_config_file_path()?;
383
384 if !settings_file_path.exists() {
385 let default_settings = Self::default();
386 default_settings.save_to_disk()?;
387 Ok(default_settings)
388 } else {
389 let mut settings_file = File::open(&settings_file_path)?;
390
391 let mut toml = String::new();
392 settings_file.read_to_string(&mut toml)?;
393
394 match toml::from_str::<AppSettings>(toml.as_ref()) {
395 Ok(mut setting) => {
396 setting.config_file = Some(settings_file_path.clone());
397 let config_dir = settings_file_path
398 .parent()
399 .unwrap_or(std::path::Path::new("."));
400 setting.resolve_paths(config_dir);
401 if config_migration::ConfigMigration::run(&mut setting)? {
402 setting.save_to_disk()?;
403 }
404 setting.merge_missing_default_bindings();
405 Ok(setting)
406 }
407 Err(e) => {
408 tracing::warn!(
409 "Config file at {:?} could not be parsed ({}). \
410 Renaming to .corrupt and starting with defaults.",
411 settings_file_path,
412 e
413 );
414 let corrupt_path = settings_file_path.with_extension("toml.corrupt");
415 let _ = fs::rename(&settings_file_path, &corrupt_path);
416 let defaults = Self::default();
417 defaults.save_to_disk()?;
418 Ok(defaults)
419 }
420 }
421 }
422 }
423
424 pub fn load_from_file(path: PathBuf) -> eyre::Result<Self> {
425 if let Some(parent) = path.parent() {
426 fs::create_dir_all(parent)?;
427 }
428 if !path.exists() {
429 let default_settings = Self {
430 config_file: Some(path),
431 ..Self::default()
432 };
433 default_settings.save_to_disk()?;
434 return Ok(default_settings);
435 }
436 let mut toml_str = String::new();
437 File::open(&path)?.read_to_string(&mut toml_str)?;
438 match toml::from_str::<AppSettings>(&toml_str) {
439 Ok(mut setting) => {
440 setting.config_file = Some(path.clone());
441
442 let config_dir = path.parent().unwrap_or(std::path::Path::new("."));
444 setting.resolve_paths(config_dir);
445
446 if config_migration::ConfigMigration::run(&mut setting)? {
448 setting.save_to_disk()?;
449 }
450
451 setting.merge_missing_default_bindings();
452 Ok(setting)
453 }
454 Err(e) => {
455 tracing::warn!(
456 "Config file at {:?} could not be parsed ({}). \
457 Renaming to .corrupt and starting with defaults.",
458 path,
459 e
460 );
461 let corrupt_path = path.with_extension("toml.corrupt");
462 let _ = fs::rename(&path, &corrupt_path);
463 let defaults = Self {
464 config_file: Some(path),
465 ..Self::default()
466 };
467 defaults.save_to_disk()?;
468 Ok(defaults)
469 }
470 }
471 }
472
473 fn merge_missing_default_bindings(&mut self) {
476 let defaults = default_keybindings().to_hashmap();
477 let mut current = self.key_bindings.to_hashmap();
478 for (action, combos) in defaults {
479 current.entry(action).or_insert(combos);
480 }
481 self.key_bindings = KeyBindings::from_hashmap(current);
482 }
483
484 pub fn set_workspace(&mut self, workspace_path: &PathBuf) {
487 if let Some(current_workspace_dir) = &self.workspace_dir
488 && workspace_path != current_workspace_dir
489 {
490 self.needs_indexing = true;
491 }
492
493 self.workspace_dir = Some(workspace_path.to_owned());
494 }
495
496 pub fn clear_workspace(&mut self) {
503 if self.workspace_dir.is_some() {
505 self.workspace_dir = None;
506 self.needs_indexing = true;
507 }
508 if let Some(wc) = &mut self.workspace_config {
510 let key = wc.global.current_workspace.clone();
511 if !key.is_empty() {
512 wc.workspaces.remove(&key);
513 }
514 wc.global.current_workspace = String::new();
515 }
516 }
517
518 pub fn resolve_workspace_path(&self) -> Option<PathBuf> {
521 self.workspace_config
522 .as_ref()
523 .and_then(|wc| wc.get_current_workspace())
524 .map(|entry| entry.effective_path().clone())
525 .or_else(|| self.workspace_dir.clone())
526 }
527
528 fn resolve_paths(&mut self, base: &std::path::Path) {
532 if let Some(ref mut p) = self.workspace_dir {
535 *p = Self::expand_path(p, base);
536 }
537 if let Some(ref mut wc) = self.workspace_config {
539 for entry in wc.workspaces.values_mut() {
540 let resolved = Self::expand_path(&entry.path, base);
541 if resolved != entry.path {
542 entry.resolved_path = Some(resolved);
543 }
544 }
545 }
546 self.cache_dir_resolved = Some(Self::expand_path(&self.cache_dir, base));
547 self.history_dir_resolved = Some(Self::expand_path(&self.history_dir, base));
548 }
549
550 fn expand_path(path: &std::path::Path, base: &std::path::Path) -> PathBuf {
554 let s = path.to_string_lossy();
555 let expanded = if s.starts_with("~/") || s == "~" {
556 if let Ok(home) = config_dir::get_home_dir() {
557 home.join(s.strip_prefix("~/").unwrap_or(""))
558 } else {
559 path.to_path_buf()
560 }
561 } else {
562 path.to_path_buf()
563 };
564 let absolute = if expanded.is_relative() {
565 base.join(expanded)
566 } else {
567 expanded
568 };
569 absolute.canonicalize().unwrap_or(absolute)
571 }
572
573 pub fn set_theme(&mut self, theme: String) {
574 self.theme = theme;
575 }
576
577 pub fn report_indexed(&mut self) {
578 self.needs_indexing = false;
579 }
580
581 pub fn needs_indexing(&self) -> bool {
582 self.needs_indexing
583 }
584
585 pub fn add_path_history(&mut self, note_path: &VaultPath) {
586 if !note_path.is_note() {
587 return;
588 }
589 let Some(workspace_name) = self.current_workspace_name() else {
590 return;
591 };
592 let file_path = self.history_path_for(&workspace_name);
593 if let Err(e) = history::push_history(&file_path, note_path) {
594 tracing::warn!("failed to write history {:?}: {}", file_path, e);
595 }
596 }
597
598 pub fn current_workspace_name(&self) -> Option<String> {
599 self.workspace_config
600 .as_ref()
601 .map(|wc| wc.global.current_workspace.clone())
602 .filter(|s| !s.is_empty())
603 }
604
605 pub fn cache_dir_resolved(&self) -> Option<&Path> {
606 self.cache_dir_resolved.as_deref()
607 }
608
609 pub fn history_dir_resolved(&self) -> Option<&Path> {
610 self.history_dir_resolved.as_deref()
611 }
612
613 pub fn cache_path_for(&self, workspace_name: &str) -> PathBuf {
617 Self::workspace_file(
618 self.cache_dir_resolved.as_ref().unwrap_or(&self.cache_dir),
619 workspace_name,
620 CACHE_FILE_EXT,
621 )
622 }
623
624 pub fn history_path_for(&self, workspace_name: &str) -> PathBuf {
627 Self::workspace_file(
628 self.history_dir_resolved
629 .as_ref()
630 .unwrap_or(&self.history_dir),
631 workspace_name,
632 HISTORY_FILE_EXT,
633 )
634 }
635
636 fn workspace_file(dir: &Path, workspace_name: &str, ext: &str) -> PathBuf {
637 dir.join(format!("{workspace_name}.{ext}"))
638 }
639
640 pub fn current_last_paths(&self) -> Vec<VaultPath> {
642 let Some(name) = self.current_workspace_name() else {
643 return Vec::new();
644 };
645 let file_path = self.history_path_for(&name);
646 history::load_history(&file_path)
647 }
648
649 pub fn icons(&self) -> icons::Icons {
651 icons::Icons::new(self.use_nerd_fonts)
652 }
653
654 pub fn get_theme(&self) -> Theme {
656 if self.theme.is_empty() {
657 return Theme::default();
658 }
659 self.theme_list()
660 .into_iter()
661 .find(|t| t.name == self.theme)
662 .unwrap_or_default()
663 }
664}
665
666#[cfg(test)]
667#[allow(clippy::field_reassign_with_default)]
668mod tests {
669 use super::*;
670
671 #[test]
672 fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
673 let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
676 let _ = std::fs::remove_file(&path); let result = AppSettings::load_theme_from_path(&path);
679
680 assert!(result.is_err(), "should return Err when file is absent");
681 assert!(!path.exists(), "must not create the file as a side effect");
682 }
683
684 #[test]
685 fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
686 let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
688 std::fs::write(&path, b"not valid toml {{{{").unwrap();
689
690 let result = AppSettings::load_theme_from_path(&path);
691
692 assert!(result.is_err(), "should return Err for corrupt TOML");
693 assert!(
694 !path.exists(),
695 "corrupt file must be removed, not recreated"
696 );
697 }
698
699 #[test]
700 fn default_keybindings_quit_matches_canonical_combo() {
701 let kb = default_keybindings();
702 let combo = crate::keys::default_quit_combo();
703 assert_eq!(
704 kb.get_action(&combo),
705 Some(ActionShortcuts::Quit),
706 "default_keybindings() must bind default_quit_combo() to Quit so the \
707 deserialize safety net can recover an unreachable app"
708 );
709 }
710
711 #[test]
712 fn autosave_interval_defaults_to_five() {
713 let settings = AppSettings::default();
714 assert_eq!(settings.autosave_interval_secs, 5);
715 }
716
717 #[test]
718 fn autosave_interval_deserializes_from_toml() {
719 let toml = "autosave_interval_secs = 30\n";
720 let settings: AppSettings = toml::from_str(toml).unwrap();
721 assert_eq!(settings.autosave_interval_secs, 30);
722 }
723
724 #[test]
725 fn autosave_interval_defaults_when_missing_from_toml() {
726 let toml = ""; let settings: AppSettings = toml::from_str(toml).unwrap();
728 assert_eq!(settings.autosave_interval_secs, 5);
729 }
730
731 #[test]
733 fn f2_file_operations_survives_toml_deserialize() {
734 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
735 use crate::keys::key_strike::KeyStrike;
736
737 let toml = r#"
738[key_bindings]
739FileOperations = ["F2"]
740"#;
741 let settings: AppSettings = toml::from_str(toml).unwrap();
742 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
743 let action = settings.key_bindings.get_action(&f2);
744 assert_eq!(
745 action,
746 Some(ActionShortcuts::FileOperations),
747 "F2 should survive deserialization and map to FileOperations"
748 );
749 }
750
751 #[test]
753 fn merge_adds_f2_when_absent() {
754 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
755 use crate::keys::key_strike::KeyStrike;
756
757 let toml = r#"
759[key_bindings]
760Quit = ["ctrl&Q"]
761"#;
762 let mut settings: AppSettings = toml::from_str(toml).unwrap();
763 settings.merge_missing_default_bindings();
764
765 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
766 let action = settings.key_bindings.get_action(&f2);
767 assert_eq!(
768 action,
769 Some(ActionShortcuts::FileOperations),
770 "merge_missing_default_bindings should add F2 → FileOperations"
771 );
772 }
773
774 #[test]
775 fn clear_workspace_phase1_clears_workspace_dir() {
776 let mut settings = AppSettings::default();
777 settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
778 settings.needs_indexing = false;
779 settings.clear_workspace();
780 assert!(
781 settings.workspace_dir.is_none(),
782 "workspace_dir should be None"
783 );
784 assert!(
785 settings.needs_indexing,
786 "needs_indexing should be reset to true"
787 );
788 }
789
790 #[test]
791 fn clear_workspace_phase2_removes_current_workspace_entry() {
792 let mut settings = AppSettings::default();
793 let mut wc = WorkspaceConfig::new_empty();
794 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
795 .unwrap();
796 settings.workspace_config = Some(wc);
797 assert_eq!(
799 settings
800 .workspace_config
801 .as_ref()
802 .unwrap()
803 .global
804 .current_workspace,
805 "vault1"
806 );
807 settings.clear_workspace();
808 let wc = settings.workspace_config.as_ref().unwrap();
809 assert!(
810 wc.workspaces.is_empty(),
811 "workspace entry should be removed"
812 );
813 assert!(
814 wc.global.current_workspace.is_empty(),
815 "current_workspace should be empty"
816 );
817 }
818
819 #[test]
820 fn clear_workspace_both_phases_active() {
821 let mut settings = AppSettings::default();
824 settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
825 let mut wc = WorkspaceConfig::new_empty();
826 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
827 .unwrap();
828 settings.workspace_config = Some(wc);
829 settings.clear_workspace();
830 assert!(
831 settings.workspace_dir.is_none(),
832 "phase1 workspace_dir should be cleared"
833 );
834 let wc = settings.workspace_config.as_ref().unwrap();
835 assert!(
836 wc.workspaces.is_empty(),
837 "phase2 workspace entry should be removed"
838 );
839 assert!(
840 wc.global.current_workspace.is_empty(),
841 "phase2 current_workspace should be empty"
842 );
843 }
844
845 #[test]
846 fn clear_workspace_phase2_preserves_other_workspaces() {
847 let mut settings = AppSettings::default();
848 let mut wc = WorkspaceConfig::new_empty();
849 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
850 .unwrap();
851 wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
852 .unwrap();
853 wc.global.current_workspace = "vault1".to_string();
854 settings.workspace_config = Some(wc);
855 settings.clear_workspace();
856 let wc = settings.workspace_config.as_ref().unwrap();
857 assert!(
858 !wc.workspaces.contains_key("vault1"),
859 "active workspace should be removed"
860 );
861 assert!(
862 wc.workspaces.contains_key("vault2"),
863 "other workspaces should be preserved"
864 );
865 assert!(
866 wc.global.current_workspace.is_empty(),
867 "current_workspace should be empty"
868 );
869 }
870}
871
872#[cfg(test)]
873mod backend_tests {
874 use super::*;
875
876 #[test]
877 fn default_backend_is_textarea() {
878 let settings = AppSettings::default();
879 assert!(matches!(
880 settings.editor_backend,
881 EditorBackendSetting::Textarea
882 ));
883 }
884
885 #[test]
886 fn nvim_backend_round_trips_toml() {
887 let toml = "editor_backend = \"nvim\"\n";
888 let parsed: AppSettings = toml::from_str(toml).unwrap();
889 assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
890 }
891
892 #[test]
895 fn expand_path_absolute_unchanged() {
896 let base = PathBuf::from("/config/dir");
897 let result = AppSettings::expand_path(std::path::Path::new("/absolute/path/notes"), &base);
898 assert!(result.is_absolute());
899 assert!(result.to_string_lossy().contains("absolute"));
900 }
901
902 #[test]
903 fn expand_path_relative_resolved_against_base() {
904 let base = tempfile::TempDir::new().unwrap();
905 let notes = base.path().join("notes");
906 std::fs::create_dir_all(¬es).unwrap();
907
908 let result = AppSettings::expand_path(std::path::Path::new("notes"), base.path());
909 assert!(result.is_absolute());
910 assert_eq!(result, notes.canonicalize().unwrap());
911 }
912
913 #[test]
914 fn expand_path_relative_with_dotdot() {
915 let base = tempfile::TempDir::new().unwrap();
916 let sibling = base.path().join("sibling");
917 std::fs::create_dir_all(&sibling).unwrap();
918 let sub = base.path().join("sub");
919 std::fs::create_dir_all(&sub).unwrap();
920
921 let result = AppSettings::expand_path(std::path::Path::new("../sibling"), &sub);
922 assert!(result.is_absolute());
923 assert_eq!(result, sibling.canonicalize().unwrap());
924 }
925
926 #[test]
927 fn expand_path_nonexistent_relative_still_absolute() {
928 let base = PathBuf::from("/some/config/dir");
929 let result = AppSettings::expand_path(std::path::Path::new("my-notes"), &base);
930 assert!(result.is_absolute());
931 assert_eq!(result, PathBuf::from("/some/config/dir/my-notes"));
932 }
933
934 #[test]
935 #[cfg(unix)]
936 fn expand_path_tilde_uses_home_unix() {
937 let home = std::env::var("HOME").expect("HOME must be set on Unix");
938 let base = PathBuf::from("/irrelevant");
939 let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
940 assert!(result.is_absolute());
941 assert!(
942 result.starts_with(&home),
943 "expected path to start with HOME={}, got {:?}",
944 home,
945 result
946 );
947 assert!(result.to_string_lossy().contains("Documents/notes"));
948 }
949
950 #[test]
951 #[cfg(unix)]
952 fn expand_path_tilde_alone_is_home_unix() {
953 let home = std::env::var("HOME").expect("HOME must be set on Unix");
954 let base = PathBuf::from("/irrelevant");
955 let result = AppSettings::expand_path(std::path::Path::new("~"), &base);
956 assert!(result.is_absolute());
957 let expected = PathBuf::from(&home)
959 .canonicalize()
960 .unwrap_or(PathBuf::from(&home));
961 assert_eq!(result, expected);
962 }
963
964 #[test]
965 #[cfg(windows)]
966 fn expand_path_tilde_uses_userprofile_windows() {
967 let home = std::env::var("USERPROFILE").expect("USERPROFILE must be set on Windows");
968 let base = PathBuf::from("C:\\irrelevant");
969 let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
970 assert!(result.is_absolute());
971 assert!(
972 result.starts_with(&home),
973 "expected path to start with USERPROFILE={}, got {:?}",
974 home,
975 result
976 );
977 }
978
979 #[test]
980 fn resolve_paths_populates_resolved_path() {
981 let base = tempfile::TempDir::new().unwrap();
982 let notes = base.path().join("notes");
983 std::fs::create_dir_all(¬es).unwrap();
984
985 let toml = r#"
986config_version = 2
987[global]
988current_workspace = "test"
989[workspaces.test]
990path = "notes"
991last_paths = []
992created = "2026-01-01T00:00:00Z"
993"#
994 .to_string();
995 let mut settings: AppSettings = toml::from_str(&toml).unwrap();
996 settings.resolve_paths(base.path());
997
998 let wc = settings.workspace_config.as_ref().unwrap();
999 let entry = wc.workspaces.get("test").unwrap();
1000 assert_eq!(entry.path, PathBuf::from("notes"));
1002 assert!(entry.resolved_path.is_some());
1004 assert!(entry.effective_path().is_absolute());
1005 }
1006
1007 #[test]
1008 fn resolve_paths_absolute_no_resolved_path() {
1009 let toml = r#"
1010config_version = 2
1011[global]
1012current_workspace = "test"
1013[workspaces.test]
1014path = "/absolute/notes"
1015last_paths = []
1016created = "2026-01-01T00:00:00Z"
1017"#;
1018 let mut settings: AppSettings = toml::from_str(toml).unwrap();
1019 settings.resolve_paths(std::path::Path::new("/config"));
1020
1021 let wc = settings.workspace_config.as_ref().unwrap();
1022 let entry = wc.workspaces.get("test").unwrap();
1023 assert!(entry.resolved_path.is_none());
1025 assert_eq!(*entry.effective_path(), PathBuf::from("/absolute/notes"));
1026 }
1027}