use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
use crate::keys::key_strike::KeyStrike;
use crate::settings::config_dir::get_or_create_config_dir;
use crate::settings::themes::Theme;
use crate::settings::workspace_config::WorkspaceConfig;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::fs::{self, File};
use color_eyre::eyre;
pub type SharedSettings = Arc<RwLock<AppSettings>>;
use kimun_core::nfs::VaultPath;
use crate::keys::KeyBindings;
mod config_dir;
pub mod config_migration;
pub mod icons;
pub mod themes;
pub mod workspace_config;
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SortFieldSetting {
Name,
Title,
}
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SortOrderSetting {
Ascending,
Descending,
}
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EditorBackendSetting {
#[default]
Textarea,
Nvim,
}
#[cfg(debug_assertions)]
const CONFIG_DIR: &str = "kimun_debug";
#[cfg(not(debug_assertions))]
const CONFIG_DIR: &str = "kimun";
const BASE_CONFIG_FILE: &str = "config.toml";
const THEMES_DIR: &str = "themes";
const LAST_PATH_HISTORY_SIZE: usize = 20;
const CONFIG_HEADER: &str = "\
# ─── Kimün configuration ────────────────────────────────────────────────────
#
# KEY BINDINGS
# ────────────
# Supported combinations:
# - ctrl and/or alt (with optional shift) + a letter (a-z)
# - bare F-key (F1–F12, no modifier required)
# Any combo that does not follow these rules is silently ignored when loaded.
#
# Format per action:
# ActionName = [\"<modifiers> & <letter>\", ...]
#
# Available modifiers (combine with +): ctrl alt shift
#
# Examples:
# Quit = [\"ctrl&Q\"] # Ctrl+Q
# SearchNotes = [\"ctrl&K\"] # Ctrl+K
# OpenNote = [\"ctrl&O\"] # Ctrl+O (fuzzy file finder)
# OpenSettings = [\"ctrl+shift&P\"] # Ctrl+Shift+P
# NewJournal = [\"ctrl&J\"] # Ctrl+J
# FileOperations = [\"F2\"] # F2 (open file-ops menu: delete/rename/move)
#
# ─────────────────────────────────────────────────────────────────────────────
";
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct AppSettings {
#[serde(default)]
pub config_version: u32,
#[serde(flatten, skip_serializing_if = "Option::is_none")]
pub workspace_config: Option<WorkspaceConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_dir: Option<PathBuf>,
#[serde(default, skip_serializing)]
pub last_paths: Vec<VaultPath>,
#[serde(default)]
pub theme: String,
#[serde(skip, default = "yes")]
needs_indexing: bool,
#[serde(default = "default_keybindings")]
pub key_bindings: KeyBindings,
#[serde(default = "default_autosave_interval")]
pub autosave_interval_secs: u64,
#[serde(default = "default_use_nerd_fonts")]
pub use_nerd_fonts: bool,
#[serde(default)]
pub editor_backend: EditorBackendSetting,
#[serde(skip_serializing_if = "Option::is_none")]
pub nvim_path: Option<std::path::PathBuf>,
#[serde(default = "default_sort_field")]
pub default_sort_field: SortFieldSetting,
#[serde(default = "default_sort_order")]
pub default_sort_order: SortOrderSetting,
#[serde(default = "default_journal_sort_field")]
pub journal_sort_field: SortFieldSetting,
#[serde(default = "default_journal_sort_order")]
pub journal_sort_order: SortOrderSetting,
#[serde(skip)]
pub config_file: Option<PathBuf>,
}
fn default_keybindings() -> KeyBindings {
let mut kb = KeyBindings::empty();
kb.batch_add()
.with_ctrl()
.add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
.add(KeyStrike::KeyO, ActionShortcuts::OpenNote)
.add(KeyStrike::KeyY, ActionShortcuts::TogglePreview)
.add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
.add(KeyStrike::KeyI, ActionShortcuts::Text(TextAction::Italic))
.add(
KeyStrike::KeyU,
ActionShortcuts::Text(TextAction::Underline),
)
.add(
KeyStrike::KeyS,
ActionShortcuts::Text(TextAction::Strikethrough),
)
.add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Link))
.add(
KeyStrike::KeyT,
ActionShortcuts::Text(TextAction::ToggleHeader),
)
.with_shift()
.add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Image));
kb.batch_add()
.with_ctrl()
.add(KeyStrike::KeyP, ActionShortcuts::OpenSettings)
.add(KeyStrike::KeyQ, ActionShortcuts::Quit)
.add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
.add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
.add(KeyStrike::KeyN, ActionShortcuts::CycleSortField)
.add(KeyStrike::KeyG, ActionShortcuts::FollowLink)
.add(KeyStrike::KeyR, ActionShortcuts::SortReverseOrder)
.add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
.add(KeyStrike::KeyL, ActionShortcuts::FocusEditor)
.add(KeyStrike::KeyW, ActionShortcuts::QuickNote)
.add(KeyStrike::KeyE, ActionShortcuts::ToggleBacklinks);
kb.batch_add()
.add(KeyStrike::F2, ActionShortcuts::FileOperations);
kb.batch_add()
.add(KeyStrike::F4, ActionShortcuts::SwitchWorkspace);
kb
}
fn yes() -> bool {
true
}
fn default_autosave_interval() -> u64 {
5
}
fn default_use_nerd_fonts() -> bool {
false
}
fn default_sort_field() -> SortFieldSetting {
SortFieldSetting::Name
}
fn default_sort_order() -> SortOrderSetting {
SortOrderSetting::Ascending
}
fn default_journal_sort_field() -> SortFieldSetting {
SortFieldSetting::Name
}
fn default_journal_sort_order() -> SortOrderSetting {
SortOrderSetting::Descending
}
impl Default for AppSettings {
fn default() -> Self {
Self {
config_version: 0,
workspace_config: None,
last_paths: vec![],
workspace_dir: None,
theme: Default::default(),
needs_indexing: true,
key_bindings: default_keybindings(),
autosave_interval_secs: default_autosave_interval(),
use_nerd_fonts: false,
editor_backend: EditorBackendSetting::Textarea,
nvim_path: None,
default_sort_field: default_sort_field(),
default_sort_order: default_sort_order(),
journal_sort_field: default_journal_sort_field(),
journal_sort_order: default_journal_sort_order(),
config_file: None,
}
}
}
impl AppSettings {
pub fn theme_list(&self) -> Vec<Theme> {
let mut list = vec![
Theme::gruvbox_dark(),
Theme::gruvbox_light(),
Theme::catppuccin_mocha(),
Theme::catppuccin_latte(),
Theme::tokyo_night(),
Theme::tokyo_night_storm(),
Theme::solarized_dark(),
Theme::solarized_light(),
Theme::nord(),
];
list.append(&mut Self::load_custom_themes());
if let Ok(custom_default) = Self::load_default_theme() {
list.push(custom_default);
}
list.sort_by(|a, b| a.name.cmp(&b.name));
list
}
fn default_config_file_path() -> eyre::Result<PathBuf> {
let config_home = get_or_create_config_dir(CONFIG_DIR)?;
Ok(config_home.join(BASE_CONFIG_FILE))
}
fn get_config_file_path(&self) -> eyre::Result<PathBuf> {
if let Some(ref path) = self.config_file {
Ok(path.clone())
} else {
Self::default_config_file_path()
}
}
fn get_themes_path() -> eyre::Result<PathBuf> {
let config_home = get_or_create_config_dir(CONFIG_DIR)?;
Ok(config_home.join(THEMES_DIR))
}
fn load_theme_from_path(path: &std::path::Path) -> eyre::Result<Theme> {
let theme_string = fs::read_to_string(path)?;
match toml::from_str::<Theme>(&theme_string) {
Ok(theme) => Ok(theme),
Err(e) => {
tracing::debug!(
"Failed to deserialize theme file {:?}: {}. Removing.",
path,
e
);
let _ = fs::remove_file(path);
Err(eyre::eyre!("corrupt theme file: {}", e))
}
}
}
fn load_default_theme() -> eyre::Result<Theme> {
let theme_path = AppSettings::get_themes_path()?.join("default.toml");
Self::load_theme_from_path(&theme_path)
}
fn load_custom_themes() -> Vec<Theme> {
let mut themes = Vec::new();
let themes_path = match Self::get_themes_path() {
Ok(path) => path,
Err(_) => return themes,
};
let entries = match fs::read_dir(&themes_path) {
Ok(entries) => entries,
Err(_) => return themes,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().and_then(|s| s.to_str()) != Some("toml") {
continue;
}
if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
continue;
}
match fs::read_to_string(&path)
.and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
{
Ok(theme) => themes.push(theme),
Err(e) => tracing::warn!("Skipping theme file {:?}: {}", path, e),
}
}
themes
}
pub fn save_to_disk(&self) -> eyre::Result<()> {
tracing::debug!("Saving settings to disk");
let settings_file_path = self.get_config_file_path()?;
let mut file = File::create(settings_file_path)?;
file.write_all(CONFIG_HEADER.as_bytes())?;
let toml = toml::to_string(&self)?;
file.write_all(toml.as_bytes())?;
Ok(())
}
pub fn load_from_disk() -> eyre::Result<Self> {
let settings_file_path = Self::default_config_file_path()?;
if !settings_file_path.exists() {
let default_settings = Self::default();
default_settings.save_to_disk()?;
Ok(default_settings)
} else {
let mut settings_file = File::open(&settings_file_path)?;
let mut toml = String::new();
settings_file.read_to_string(&mut toml)?;
match toml::from_str::<AppSettings>(toml.as_ref()) {
Ok(mut setting) => {
setting.config_file = Some(settings_file_path.clone());
let config_dir = settings_file_path.parent().unwrap_or(std::path::Path::new("."));
setting.resolve_paths(config_dir);
if config_migration::ConfigMigration::run(&mut setting)? {
setting.save_to_disk()?;
}
setting.merge_missing_default_bindings();
Ok(setting)
}
Err(e) => {
tracing::warn!(
"Config file at {:?} could not be parsed ({}). \
Renaming to .corrupt and starting with defaults.",
settings_file_path,
e
);
let corrupt_path = settings_file_path.with_extension("toml.corrupt");
let _ = fs::rename(&settings_file_path, &corrupt_path);
let defaults = Self::default();
defaults.save_to_disk()?;
Ok(defaults)
}
}
}
}
pub fn load_from_file(path: PathBuf) -> eyre::Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
if !path.exists() {
let default_settings = Self {
config_file: Some(path),
..Self::default()
};
default_settings.save_to_disk()?;
return Ok(default_settings);
}
let mut toml_str = String::new();
File::open(&path)?.read_to_string(&mut toml_str)?;
match toml::from_str::<AppSettings>(&toml_str) {
Ok(mut setting) => {
setting.config_file = Some(path.clone());
let config_dir = path.parent().unwrap_or(std::path::Path::new("."));
setting.resolve_paths(config_dir);
if config_migration::ConfigMigration::run(&mut setting)? {
setting.save_to_disk()?;
}
setting.merge_missing_default_bindings();
Ok(setting)
}
Err(e) => {
tracing::warn!(
"Config file at {:?} could not be parsed ({}). \
Renaming to .corrupt and starting with defaults.",
path,
e
);
let corrupt_path = path.with_extension("toml.corrupt");
let _ = fs::rename(&path, &corrupt_path);
let defaults = Self {
config_file: Some(path),
..Self::default()
};
defaults.save_to_disk()?;
Ok(defaults)
}
}
}
fn merge_missing_default_bindings(&mut self) {
let defaults = default_keybindings().to_hashmap();
let mut current = self.key_bindings.to_hashmap();
for (action, combos) in defaults {
current.entry(action).or_insert(combos);
}
self.key_bindings = KeyBindings::from_hashmap(current);
}
pub fn set_workspace(&mut self, workspace_path: &PathBuf) {
if let Some(current_workspace_dir) = &self.workspace_dir
&& workspace_path != current_workspace_dir
{
self.needs_indexing = true;
}
self.workspace_dir = Some(workspace_path.to_owned());
}
pub fn clear_workspace(&mut self) {
if self.workspace_dir.is_some() {
self.workspace_dir = None;
self.needs_indexing = true;
}
if let Some(wc) = &mut self.workspace_config {
let key = wc.global.current_workspace.clone();
if !key.is_empty() {
wc.workspaces.remove(&key);
}
wc.global.current_workspace = String::new();
}
}
pub fn resolve_workspace_path(&self) -> Option<PathBuf> {
self.workspace_config
.as_ref()
.and_then(|wc| wc.get_current_workspace())
.map(|entry| entry.effective_path().clone())
.or_else(|| self.workspace_dir.clone())
}
fn resolve_paths(&mut self, base: &std::path::Path) {
if let Some(ref mut p) = self.workspace_dir {
*p = Self::expand_path(p, base);
}
if let Some(ref mut wc) = self.workspace_config {
for entry in wc.workspaces.values_mut() {
let resolved = Self::expand_path(&entry.path, base);
if resolved != entry.path {
entry.resolved_path = Some(resolved);
}
}
}
}
fn expand_path(path: &std::path::Path, base: &std::path::Path) -> PathBuf {
let s = path.to_string_lossy();
let expanded = if s.starts_with("~/") || s == "~" {
if let Ok(home) = config_dir::get_home_dir() {
home.join(s.strip_prefix("~/").unwrap_or(""))
} else {
path.to_path_buf()
}
} else {
path.to_path_buf()
};
let absolute = if expanded.is_relative() {
base.join(expanded)
} else {
expanded
};
absolute.canonicalize().unwrap_or(absolute)
}
pub fn set_theme(&mut self, theme: String) {
self.theme = theme;
}
pub fn report_indexed(&mut self) {
self.needs_indexing = false;
}
pub fn needs_indexing(&self) -> bool {
self.needs_indexing
}
pub fn add_path_history(&mut self, note_path: &VaultPath) {
if !note_path.is_note() {
return;
}
let path_str = note_path.to_string();
if let Some(ref mut wc) = self.workspace_config
&& let Some(entry) = wc.workspaces.get_mut(&wc.global.current_workspace)
{
entry.last_paths.retain(|p| p != &path_str);
while entry.last_paths.len() >= LAST_PATH_HISTORY_SIZE {
entry.last_paths.remove(0);
}
entry.last_paths.push(path_str);
}
}
pub fn current_last_paths(&self) -> Vec<VaultPath> {
if let Some(ref wc) = self.workspace_config
&& let Some(entry) = wc.get_current_workspace()
{
return entry
.last_paths
.iter()
.map(VaultPath::new)
.collect();
}
self.last_paths.clone()
}
pub fn icons(&self) -> icons::Icons {
icons::Icons::new(self.use_nerd_fonts)
}
pub fn get_theme(&self) -> Theme {
if self.theme.is_empty() {
return Theme::default();
}
self.theme_list()
.into_iter()
.find(|t| t.name == self.theme)
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
let _ = std::fs::remove_file(&path);
let result = AppSettings::load_theme_from_path(&path);
assert!(result.is_err(), "should return Err when file is absent");
assert!(!path.exists(), "must not create the file as a side effect");
}
#[test]
fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
std::fs::write(&path, b"not valid toml {{{{").unwrap();
let result = AppSettings::load_theme_from_path(&path);
assert!(result.is_err(), "should return Err for corrupt TOML");
assert!(
!path.exists(),
"corrupt file must be removed, not recreated"
);
}
#[test]
fn autosave_interval_defaults_to_five() {
let settings = AppSettings::default();
assert_eq!(settings.autosave_interval_secs, 5);
}
#[test]
fn autosave_interval_deserializes_from_toml() {
let toml = "autosave_interval_secs = 30\n";
let settings: AppSettings = toml::from_str(toml).unwrap();
assert_eq!(settings.autosave_interval_secs, 30);
}
#[test]
fn autosave_interval_defaults_when_missing_from_toml() {
let toml = ""; let settings: AppSettings = toml::from_str(toml).unwrap();
assert_eq!(settings.autosave_interval_secs, 5);
}
#[test]
fn f2_file_operations_survives_toml_deserialize() {
use crate::keys::key_combo::{KeyCombo, KeyModifiers};
use crate::keys::key_strike::KeyStrike;
let toml = r#"
[key_bindings]
FileOperations = ["F2"]
"#;
let settings: AppSettings = toml::from_str(toml).unwrap();
let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
let action = settings.key_bindings.get_action(&f2);
assert_eq!(
action,
Some(ActionShortcuts::FileOperations),
"F2 should survive deserialization and map to FileOperations"
);
}
#[test]
fn merge_adds_f2_when_absent() {
use crate::keys::key_combo::{KeyCombo, KeyModifiers};
use crate::keys::key_strike::KeyStrike;
let toml = r#"
[key_bindings]
Quit = ["ctrl&Q"]
"#;
let mut settings: AppSettings = toml::from_str(toml).unwrap();
settings.merge_missing_default_bindings();
let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
let action = settings.key_bindings.get_action(&f2);
assert_eq!(
action,
Some(ActionShortcuts::FileOperations),
"merge_missing_default_bindings should add F2 → FileOperations"
);
}
#[test]
fn clear_workspace_phase1_clears_workspace_dir() {
let mut settings = AppSettings::default();
settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
settings.needs_indexing = false;
settings.clear_workspace();
assert!(
settings.workspace_dir.is_none(),
"workspace_dir should be None"
);
assert!(
settings.needs_indexing,
"needs_indexing should be reset to true"
);
}
#[test]
fn clear_workspace_phase2_removes_current_workspace_entry() {
let mut settings = AppSettings::default();
let mut wc = WorkspaceConfig::new_empty();
wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
.unwrap();
settings.workspace_config = Some(wc);
assert_eq!(
settings
.workspace_config
.as_ref()
.unwrap()
.global
.current_workspace,
"vault1"
);
settings.clear_workspace();
let wc = settings.workspace_config.as_ref().unwrap();
assert!(
wc.workspaces.is_empty(),
"workspace entry should be removed"
);
assert!(
wc.global.current_workspace.is_empty(),
"current_workspace should be empty"
);
}
#[test]
fn clear_workspace_both_phases_active() {
let mut settings = AppSettings::default();
settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
let mut wc = WorkspaceConfig::new_empty();
wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
.unwrap();
settings.workspace_config = Some(wc);
settings.clear_workspace();
assert!(
settings.workspace_dir.is_none(),
"phase1 workspace_dir should be cleared"
);
let wc = settings.workspace_config.as_ref().unwrap();
assert!(
wc.workspaces.is_empty(),
"phase2 workspace entry should be removed"
);
assert!(
wc.global.current_workspace.is_empty(),
"phase2 current_workspace should be empty"
);
}
#[test]
fn clear_workspace_phase2_preserves_other_workspaces() {
let mut settings = AppSettings::default();
let mut wc = WorkspaceConfig::new_empty();
wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
.unwrap();
wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
.unwrap();
wc.global.current_workspace = "vault1".to_string();
settings.workspace_config = Some(wc);
settings.clear_workspace();
let wc = settings.workspace_config.as_ref().unwrap();
assert!(
!wc.workspaces.contains_key("vault1"),
"active workspace should be removed"
);
assert!(
wc.workspaces.contains_key("vault2"),
"other workspaces should be preserved"
);
assert!(
wc.global.current_workspace.is_empty(),
"current_workspace should be empty"
);
}
}
#[cfg(test)]
mod backend_tests {
use super::*;
#[test]
fn default_backend_is_textarea() {
let settings = AppSettings::default();
assert!(matches!(
settings.editor_backend,
EditorBackendSetting::Textarea
));
}
#[test]
fn nvim_backend_round_trips_toml() {
let toml = "editor_backend = \"nvim\"\n";
let parsed: AppSettings = toml::from_str(toml).unwrap();
assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
}
#[test]
fn expand_path_absolute_unchanged() {
let base = PathBuf::from("/config/dir");
let result = AppSettings::expand_path(
std::path::Path::new("/absolute/path/notes"),
&base,
);
assert!(result.is_absolute());
assert!(result.to_string_lossy().contains("absolute"));
}
#[test]
fn expand_path_relative_resolved_against_base() {
let base = tempfile::TempDir::new().unwrap();
let notes = base.path().join("notes");
std::fs::create_dir_all(¬es).unwrap();
let result = AppSettings::expand_path(
std::path::Path::new("notes"),
base.path(),
);
assert!(result.is_absolute());
assert_eq!(result, notes.canonicalize().unwrap());
}
#[test]
fn expand_path_relative_with_dotdot() {
let base = tempfile::TempDir::new().unwrap();
let sibling = base.path().join("sibling");
std::fs::create_dir_all(&sibling).unwrap();
let sub = base.path().join("sub");
std::fs::create_dir_all(&sub).unwrap();
let result = AppSettings::expand_path(
std::path::Path::new("../sibling"),
&sub,
);
assert!(result.is_absolute());
assert_eq!(result, sibling.canonicalize().unwrap());
}
#[test]
fn expand_path_nonexistent_relative_still_absolute() {
let base = PathBuf::from("/some/config/dir");
let result = AppSettings::expand_path(
std::path::Path::new("my-notes"),
&base,
);
assert!(result.is_absolute());
assert_eq!(result, PathBuf::from("/some/config/dir/my-notes"));
}
#[test]
#[cfg(unix)]
fn expand_path_tilde_uses_home_unix() {
let home = std::env::var("HOME").expect("HOME must be set on Unix");
let base = PathBuf::from("/irrelevant");
let result = AppSettings::expand_path(
std::path::Path::new("~/Documents/notes"),
&base,
);
assert!(result.is_absolute());
assert!(
result.starts_with(&home),
"expected path to start with HOME={}, got {:?}",
home,
result
);
assert!(result.to_string_lossy().contains("Documents/notes"));
}
#[test]
#[cfg(unix)]
fn expand_path_tilde_alone_is_home_unix() {
let home = std::env::var("HOME").expect("HOME must be set on Unix");
let base = PathBuf::from("/irrelevant");
let result = AppSettings::expand_path(
std::path::Path::new("~"),
&base,
);
assert!(result.is_absolute());
let expected = PathBuf::from(&home).canonicalize().unwrap_or(PathBuf::from(&home));
assert_eq!(result, expected);
}
#[test]
#[cfg(windows)]
fn expand_path_tilde_uses_userprofile_windows() {
let home = std::env::var("USERPROFILE").expect("USERPROFILE must be set on Windows");
let base = PathBuf::from("C:\\irrelevant");
let result = AppSettings::expand_path(
std::path::Path::new("~/Documents/notes"),
&base,
);
assert!(result.is_absolute());
assert!(
result.starts_with(&home),
"expected path to start with USERPROFILE={}, got {:?}",
home,
result
);
}
#[test]
fn resolve_paths_populates_resolved_path() {
let base = tempfile::TempDir::new().unwrap();
let notes = base.path().join("notes");
std::fs::create_dir_all(¬es).unwrap();
let toml = format!(
r#"
config_version = 2
[global]
current_workspace = "test"
[workspaces.test]
path = "notes"
last_paths = []
created = "2026-01-01T00:00:00Z"
"#
);
let mut settings: AppSettings = toml::from_str(&toml).unwrap();
settings.resolve_paths(base.path());
let wc = settings.workspace_config.as_ref().unwrap();
let entry = wc.workspaces.get("test").unwrap();
assert_eq!(entry.path, PathBuf::from("notes"));
assert!(entry.resolved_path.is_some());
assert!(entry.effective_path().is_absolute());
}
#[test]
fn resolve_paths_absolute_no_resolved_path() {
let toml = r#"
config_version = 2
[global]
current_workspace = "test"
[workspaces.test]
path = "/absolute/notes"
last_paths = []
created = "2026-01-01T00:00:00Z"
"#;
let mut settings: AppSettings = toml::from_str(toml).unwrap();
settings.resolve_paths(std::path::Path::new("/config"));
let wc = settings.workspace_config.as_ref().unwrap();
let entry = wc.workspaces.get("test").unwrap();
assert!(entry.resolved_path.is_none());
assert_eq!(*entry.effective_path(), PathBuf::from("/absolute/notes"));
}
}