use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
use crate::keys::key_strike::KeyStrike;
use crate::settings::themes::Theme;
use crate::settings::workspace_config::WorkspaceConfig;
use std::io::Read;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::fs::{self, File};
#[derive(Debug, thiserror::Error)]
pub enum SettingsError {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("cannot serialize settings: {0}")]
Serialize(#[from] toml::ser::Error),
#[error("corrupt theme file: {0}")]
CorruptTheme(toml::de::Error),
#[error("config migration failed: {0}")]
Migration(String),
#[error(transparent)]
System(#[from] kimun_core::system::SystemError),
}
pub type SharedSettings = Arc<RwLock<AppSettings>>;
use kimun_core::IndexFile;
use self::history::HistoryFile;
use kimun_core::nfs::VaultPath;
use kimun_core::system::{self, SystemPath};
use crate::keys::KeyBindings;
pub mod config_migration;
pub mod history;
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, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EditorBackendSetting {
#[default]
#[serde(alias = "textarea")]
Plain,
Nvim,
Vim,
}
pub fn config_dir() -> Result<PathBuf, system::SystemError> {
system::ensure_app_dir().map(SystemPath::into_path_buf)
}
const BASE_CONFIG_FILE: &str = "config.toml";
const THEMES_DIR: &str = "themes";
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 = [\"F4\", \"ctrl&,\"] # F4 (Ctrl+, alias)
# NewJournal = [\"ctrl&J\"] # Ctrl+J
# FileOperations = [\"F2\"] # F2 (open file-ops menu: delete/rename/move)
# Leader = [\"ctrl&G\"] # Ctrl+G (leader gateway: Ctrl+G f f, ...)
# OpenCommandPalette = [\"ctrl&P\"] # Ctrl+P (every leader command, fuzzy)
#
# OTHER SETTINGS
# ──────────────
# theme = \"Gruvbox Dark\" # or any built-in / custom theme name
# leader_timeout_ms = 400 # hesitation before the which-key menu
#
# LEADER TREE OVERRIDES
# ─────────────────────
# Remap, add, or remove leader sequences ([leader.bind]) and rename group
# captions ([leader.labels]). Keys are the sequence AFTER the gateway;
# bind values are action ids (see the cheatsheet) or \"none\" to unbind.
# [leader.bind]
# \"o f\" = \"find.files\" # remap: leader o f now opens the file picker
# \"x\" = \"note.daily\" # add: leader x opens today's journal
# \"g p\" = \"none\" # remove the git-sync stub binding
# [leader.labels]
# \"f\" = \"+search\" # rename the +find group caption
#
# ─────────────────────────────────────────────────────────────────────────────
";
#[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(default)]
pub theme: String,
#[serde(default = "default_cache_dir")]
pub cache_dir: PathBuf,
#[serde(skip, default = "default_cache_dir_resolved")]
cache_dir_resolved: SystemPath,
#[serde(default = "default_history_dir")]
pub history_dir: PathBuf,
#[serde(skip, default = "default_history_dir_resolved")]
history_dir_resolved: SystemPath,
#[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_leader_timeout_ms")]
pub leader_timeout_ms: u64,
#[serde(default)]
pub leader: LeaderConfig,
#[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(default)]
pub group_directories: bool,
#[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::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::OpenCommandPalette)
.add(KeyStrike::KeyQ, ActionShortcuts::Quit)
.add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
.add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
.add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
.add(KeyStrike::KeyG, ActionShortcuts::Leader)
.add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
.add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
.add(KeyStrike::KeyL, ActionShortcuts::FocusEditor)
.add(KeyStrike::KeyW, ActionShortcuts::QuickNote)
.add(KeyStrike::KeyE, ActionShortcuts::OpenFileBrowser)
.add(KeyStrike::KeyF, ActionShortcuts::FindInBuffer)
.add(
crate::keys::default_yank_combo().key,
ActionShortcuts::YankRow,
);
kb.batch_add()
.add(KeyStrike::F4, ActionShortcuts::OpenPreferences);
kb.batch_add()
.with_ctrl()
.add(KeyStrike::Comma, ActionShortcuts::OpenPreferences);
kb.batch_add()
.add(KeyStrike::F2, ActionShortcuts::FileOperations);
kb.batch_add()
.add(KeyStrike::F3, ActionShortcuts::OpenSavedSearches);
kb.batch_add().add(KeyStrike::F6, ActionShortcuts::OpenAsk);
kb.batch_add()
.add(KeyStrike::F5, ActionShortcuts::SwitchWorkspace);
kb.batch_add()
.with_ctrl()
.add(KeyStrike::KeyD, ActionShortcuts::SaveCurrentQuery);
kb
}
pub fn delete_artifacts(index: &IndexFile, history: &HistoryFile) -> Vec<String> {
let mut leftovers = Vec::new();
let stuck = index.remove();
if stuck.is_empty() {
tracing::info!("removed index {}", index);
}
for (path, e) in stuck {
tracing::warn!("failed to remove {}: {}", path, e);
leftovers.push(format!(" {path}\n {e}"));
}
if let Err(e) = history.remove() {
tracing::warn!("failed to remove history {}: {}", history, e);
leftovers.push(format!(" {history}\n {e}"));
} else {
tracing::info!("removed history {}", history);
}
leftovers
}
fn yes() -> bool {
true
}
fn default_autosave_interval() -> u64 {
5
}
fn default_leader_timeout_ms() -> u64 {
400
}
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LeaderConfig {
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub bind: std::collections::BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub labels: std::collections::BTreeMap<String, String>,
}
impl AppSettings {
pub fn default_workspace_suggestion() -> Option<PathBuf> {
system::home()
.ok()
.map(|h| h.join("kimun-notes").into_path_buf())
}
pub fn leader_tree(&self) -> crate::keys::leader::LeaderNode {
let tree = crate::keys::leader::apply_overrides(
crate::keys::leader::leader_tree(),
self.leader
.bind
.iter()
.map(|(k, v)| (k.as_str(), v.as_str())),
);
crate::keys::leader::apply_labels(
tree,
self.leader
.labels
.iter()
.map(|(k, v)| (k.as_str(), v.as_str())),
)
}
}
fn default_cache_dir() -> PathBuf {
PathBuf::from(".")
}
fn default_history_dir() -> PathBuf {
PathBuf::from("history")
}
fn default_settings_base_dir() -> SystemPath {
system::app_dir().unwrap_or_else(|_| {
let fallback = std::env::temp_dir().join("kimun");
SystemPath::try_absolute(&fallback).unwrap_or_else(|_| system::log_dir())
})
}
fn default_cache_dir_resolved() -> SystemPath {
SystemPath::resolve(default_cache_dir(), &default_settings_base_dir())
}
fn default_history_dir_resolved() -> SystemPath {
SystemPath::resolve(default_history_dir(), &default_settings_base_dir())
}
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,
theme: Default::default(),
cache_dir: default_cache_dir(),
cache_dir_resolved: default_cache_dir_resolved(),
history_dir: default_history_dir(),
history_dir_resolved: default_history_dir_resolved(),
needs_indexing: true,
key_bindings: default_keybindings(),
autosave_interval_secs: default_autosave_interval(),
leader_timeout_ms: default_leader_timeout_ms(),
leader: LeaderConfig::default(),
use_nerd_fonts: false,
editor_backend: EditorBackendSetting::Plain,
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(),
group_directories: false,
config_file: None,
}
}
}
impl AppSettings {
pub fn theme_list(&self) -> Vec<Theme> {
let mut list = Theme::builtins();
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() -> Result<PathBuf, SettingsError> {
Ok(system::ensure_app_dir()?
.join(BASE_CONFIG_FILE)
.into_path_buf())
}
fn get_config_file_path(&self) -> Result<PathBuf, SettingsError> {
if let Some(ref path) = self.config_file {
Ok(path.clone())
} else {
Self::default_config_file_path()
}
}
fn get_themes_path() -> Result<PathBuf, SettingsError> {
Ok(system::ensure_app_dir()?.join(THEMES_DIR).into_path_buf())
}
fn load_theme_from_path(path: &std::path::Path) -> Result<Theme, SettingsError> {
let theme_string = fs::read_to_string(path)?;
match toml::from_str::<Theme>(&theme_string) {
Ok(theme) => Ok(theme),
Err(e) => {
tracing::warn!("Skipping unparsable theme file {:?}: {}", path, e);
Err(SettingsError::CorruptTheme(e))
}
}
}
fn load_default_theme() -> Result<Theme, SettingsError> {
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 SystemPath::try_absolute(&themes_path).and_then(|dir| system::read_dir(&dir)) {
Ok(entries) => entries,
Err(_) => return themes,
};
for entry in entries {
let path = entry.into_path_buf();
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 update_check(&self) -> bool {
self.workspace_config
.as_ref()
.map(|wc| wc.global.update_check)
.unwrap_or(true)
}
pub fn mouse(&self) -> bool {
self.workspace_config
.as_ref()
.map(|wc| wc.global.mouse)
.unwrap_or(true)
}
pub fn save_to_disk(&self) -> Result<(), SettingsError> {
tracing::debug!("Saving settings to disk");
let settings_file_path = self.get_config_file_path()?;
let body = format!("{CONFIG_HEADER}{}", toml::to_string(&self)?);
system::replace_atomically(&settings_file_path, body.as_bytes())?;
Ok(())
}
pub fn load_from_disk() -> Result<Self, SettingsError> {
let settings_file_path = Self::default_config_file_path()?;
if !settings_file_path.exists() {
let default_settings = Self::defaults_for_config_file(settings_file_path);
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());
setting.resolve_paths(&Self::config_base_dir(&settings_file_path));
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 _ = system::move_file(&settings_file_path, &corrupt_path);
let defaults = Self::defaults_for_config_file(settings_file_path);
defaults.save_to_disk()?;
Ok(defaults)
}
}
}
}
pub fn load_from_file(path: PathBuf) -> Result<Self, SettingsError> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
system::create_dir(parent)?;
}
if !path.exists() {
let default_settings = Self::defaults_for_config_file(path);
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());
setting.resolve_paths(&Self::config_base_dir(&path));
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 _ = system::move_file(&path, &corrupt_path);
let defaults = Self::defaults_for_config_file(path);
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();
let mut bound: std::collections::HashSet<_> = current.values().flatten().cloned().collect();
for (action, combos) in defaults {
match current.entry(action) {
std::collections::hash_map::Entry::Vacant(e) => {
let free: Vec<_> = combos.into_iter().filter(|c| !bound.contains(c)).collect();
if !free.is_empty() {
bound.extend(free.iter().copied());
e.insert(free);
}
}
std::collections::hash_map::Entry::Occupied(mut e) => {
for combo in combos {
if !bound.contains(&combo) && !e.get().contains(&combo) {
bound.insert(combo);
e.get_mut().push(combo);
}
}
}
}
}
self.key_bindings = KeyBindings::from_hashmap(current);
}
pub fn set_workspace_path(&mut self, name: &str, workspace_path: PathBuf) {
let Some(entry) = self
.workspace_config
.as_mut()
.and_then(|wc| wc.workspaces.get_mut(name))
else {
return;
};
if *entry.effective_path() != workspace_path {
self.needs_indexing = true;
}
entry.path = workspace_path;
entry.resolved_path = None;
}
pub fn clear_workspace(&mut self) {
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<SystemPath> {
let raw = self
.workspace_config
.as_ref()
.and_then(|wc| wc.get_current_workspace())
.map(|entry| entry.effective_path().clone())?;
match SystemPath::try_absolute(&raw) {
Ok(path) => Some(path),
Err(e) => {
tracing::warn!("ignoring unusable workspace path {raw:?}: {e}");
None
}
}
}
fn resolve_paths(&mut self, base: &SystemPath) {
if let Some(ref mut wc) = self.workspace_config {
for entry in wc.workspaces.values_mut() {
let resolved = SystemPath::resolve(&entry.path, base).into_path_buf();
if resolved != entry.path {
entry.resolved_path = Some(resolved);
}
}
}
self.cache_dir_resolved = SystemPath::resolve(&self.cache_dir, base);
self.history_dir_resolved = SystemPath::resolve(&self.history_dir, base);
}
fn defaults_for_config_file(path: PathBuf) -> Self {
let base = Self::config_base_dir(&path);
let mut settings = Self {
config_file: Some(path),
..Self::default()
};
settings.resolve_paths(&base);
settings
}
fn config_base_dir(config_file: &std::path::Path) -> SystemPath {
let dir = config_file
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(std::path::Path::new("."));
SystemPath::canonical(dir)
.or_else(|_| {
let cwd = std::env::current_dir().unwrap_or_default();
SystemPath::try_absolute(cwd.join(dir))
})
.unwrap_or_else(|_| default_settings_base_dir())
}
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 Some(workspace_name) = self.current_workspace_name() else {
return;
};
let history = self.history_for(&workspace_name);
if let Err(e) = history.push(note_path) {
tracing::warn!("failed to write history {history}: {e}");
}
}
pub fn current_workspace_name(&self) -> Option<String> {
self.workspace_config
.as_ref()
.map(|wc| wc.global.current_workspace.clone())
.filter(|s| !s.is_empty())
}
pub fn cache_dir_resolved(&self) -> &SystemPath {
&self.cache_dir_resolved
}
pub fn history_dir_resolved(&self) -> &SystemPath {
&self.history_dir_resolved
}
fn file_key_for(&self, workspace_name: &str) -> String {
self.workspace_config
.as_ref()
.and_then(|wc| wc.get_workspace(workspace_name))
.map(|entry| entry.file_key_or(workspace_name))
.unwrap_or_else(|| workspace_name.to_string())
}
pub fn index_for(&self, workspace_name: &str) -> IndexFile {
IndexFile::in_dir(&self.cache_dir_resolved, &self.file_key_for(workspace_name))
}
pub fn history_for(&self, workspace_name: &str) -> HistoryFile {
HistoryFile::in_dir(
&self.history_dir_resolved,
&self.file_key_for(workspace_name),
)
}
pub fn workspace_artifacts(&self, workspace_name: &str) -> (IndexFile, HistoryFile) {
(
self.index_for(workspace_name),
self.history_for(workspace_name),
)
}
pub fn current_last_paths(&self) -> Vec<VaultPath> {
let Some(name) = self.current_workspace_name() else {
return Vec::new();
};
self.history_for(&name).load()
}
pub fn icons(&self) -> icons::Icons {
icons::Icons::new(self.use_nerd_fonts)
}
pub fn yank_combos(&self) -> Vec<crate::keys::key_combo::KeyCombo> {
self.key_bindings.combos_for(&ActionShortcuts::YankRow)
}
pub fn effective_theme_name(&self) -> String {
if self.theme.is_empty() {
Theme::default().name
} else {
self.theme.clone()
}
}
pub fn get_theme(&self) -> Theme {
let theme = if self.theme.is_empty() {
Theme::default()
} else {
self.theme_list()
.into_iter()
.find(|t| t.name == self.theme)
.unwrap_or_default()
};
theme.adapt_to_terminal()
}
}
#[cfg(test)]
mod test_paths {
use std::path::PathBuf;
pub(super) fn absolute(unix_style: &str) -> PathBuf {
let trimmed = unix_style.trim_start_matches('/');
if cfg!(windows) {
PathBuf::from(format!("C:\\{}", trimmed.replace('/', "\\")))
} else {
PathBuf::from(format!("/{trimmed}"))
}
}
pub(super) fn absolute_toml(unix_style: &str) -> String {
absolute(unix_style).to_string_lossy().replace('\\', "\\\\")
}
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::test_paths::absolute;
use super::*;
#[test]
fn default_workspace_suggestion_is_under_home() {
let suggestion = AppSettings::default_workspace_suggestion();
if let Some(p) = suggestion {
assert!(p.ends_with("kimun-notes"));
assert!(p.is_absolute());
}
}
#[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 theme file must not be deleted");
std::fs::remove_file(&path).ok();
}
#[test]
fn default_keybindings_quit_matches_canonical_combo() {
let kb = default_keybindings();
let combo = crate::keys::default_quit_combo();
assert_eq!(
kb.get_action(&combo),
Some(ActionShortcuts::Quit),
"default_keybindings() must bind default_quit_combo() to Quit so the \
deserialize safety net can recover an unreachable app"
);
}
#[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_removes_the_current_workspace_entry() {
let mut settings = AppSettings::default();
let mut wc = WorkspaceConfig::new_empty();
wc.add_workspace("vault1".to_string(), absolute("/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_preserves_other_workspaces() {
let mut settings = AppSettings::default();
let mut wc = WorkspaceConfig::new_empty();
wc.add_workspace("vault1".to_string(), absolute("/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_paths::{absolute, absolute_toml};
use super::*;
#[test]
fn a_config_still_saying_textarea_loads() {
#[derive(serde::Deserialize)]
struct Holder {
editor_backend: EditorBackendSetting,
}
let old: Holder = toml::from_str("editor_backend = \"textarea\"").expect("still loads");
assert_eq!(old.editor_backend, EditorBackendSetting::Plain);
}
#[test]
fn the_backend_is_written_back_as_plain() {
let written = toml::to_string(&AppSettings::default()).expect("serialises");
assert!(
written.contains("editor_backend = \"plain\""),
"a saved config should name the value as it is now: {written}"
);
}
#[test]
fn default_backend_is_plain() {
let settings = AppSettings::default();
assert!(matches!(
settings.editor_backend,
EditorBackendSetting::Plain
));
}
#[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 editor_backend_vim_roundtrips_through_toml() {
#[derive(serde::Serialize, serde::Deserialize)]
struct W {
editor_backend: EditorBackendSetting,
}
let w = W {
editor_backend: EditorBackendSetting::Vim,
};
let s = toml::to_string(&w).unwrap();
assert!(s.contains("editor_backend = \"vim\""), "serialized: {s}");
let back: W = toml::from_str(&s).unwrap();
assert_eq!(back.editor_backend, EditorBackendSetting::Vim);
}
#[test]
fn workspace_paths_are_absolute_without_resolve_paths() {
for settings in [
AppSettings::default(),
toml::from_str::<AppSettings>("theme = \"gruvbox_dark\"\n").unwrap(),
] {
let cache = settings.index_for("w");
let history = settings.history_for("w");
assert!(
cache.path().as_path().is_absolute(),
"cache path not absolute: {cache}"
);
assert!(
history.path().as_path().is_absolute(),
"history path not absolute: {history}"
);
}
}
#[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 = r#"
config_version = 2
[global]
current_workspace = "test"
[workspaces.test]
path = "notes"
last_paths = []
created = "2026-01-01T00:00:00Z"
"#
.to_string();
let mut settings: AppSettings = toml::from_str(&toml).unwrap();
settings.resolve_paths(&SystemPath::try_absolute(base.path()).unwrap());
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 load_from_file_resolves_paths_for_a_config_that_does_not_exist_yet() {
let dir = tempfile::TempDir::new().unwrap();
let config_dir = dir.path().canonicalize().unwrap();
let settings = AppSettings::load_from_file(config_dir.join("config.toml")).unwrap();
let cache = settings.index_for("work");
let history = settings.history_for("work");
assert!(
cache.path().as_path().starts_with(&config_dir),
"cache path must sit next to the config file, got {cache}"
);
assert!(
history.path().as_path().starts_with(&config_dir),
"history path must sit next to the config file, got {history}"
);
}
#[test]
fn load_from_file_resolves_paths_when_the_config_is_corrupt() {
let dir = tempfile::TempDir::new().unwrap();
let config_dir = dir.path().canonicalize().unwrap();
let config_path = config_dir.join("config.toml");
std::fs::write(&config_path, "not = valid toml [[[").unwrap();
let settings = AppSettings::load_from_file(config_path).unwrap();
let cache = settings.index_for("work");
assert!(
cache.path().as_path().starts_with(&config_dir),
"cache path must sit next to the config file, got {cache}"
);
}
#[test]
fn resolve_paths_absolute_no_resolved_path() {
let toml = format!(
r#"
config_version = 2
[global]
current_workspace = "test"
[workspaces.test]
path = "{}"
last_paths = []
created = "2026-01-01T00:00:00Z"
"#,
absolute_toml("/absolute/notes")
);
let mut settings: AppSettings = toml::from_str(&toml).unwrap();
settings.resolve_paths(&SystemPath::try_absolute(absolute("/config")).unwrap());
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(), absolute("/absolute/notes"));
}
}
#[cfg(test)]
mod sort_settings_tests {
use super::*;
#[test]
fn group_directories_defaults_off() {
let s = AppSettings::default();
assert!(!s.group_directories);
}
#[test]
fn open_sort_dialog_is_bound_by_default() {
let s = AppSettings::default();
let map = s.key_bindings.to_hashmap();
assert!(
map.contains_key(&ActionShortcuts::OpenSortDialog),
"OpenSortDialog must have a default binding"
);
}
}