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