use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::state::file::FileHandle;
use crate::state::tools::IO;
#[derive(Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct Config {
pub notes_dirpath: Option<PathBuf>,
#[serde(default)]
pub daily_review_limit: Option<usize>,
#[serde(default)]
pub auto_commit_state: bool,
}
impl Config {
pub fn load() -> Result<Self> {
Self::load_from(&Self::default_path()?)
}
pub fn load_from(path: &Path) -> Result<Self> {
let handle = FileHandle::from(path.to_path_buf());
match handle.read() {
Ok(content) => ron::from_str(&content)
.with_context(|| format!("Unable to parse config from {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(anyhow::Error::from(e))
.with_context(|| format!("Unable to read config from {}", path.display())),
}
}
pub fn save(&self) -> Result<()> {
self.save_to(&Self::default_path()?)
}
pub fn save_to(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Unable to create config directory {}", parent.display())
})?;
}
let content = ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
.context("Unable to serialise config")?;
FileHandle::from(path.to_path_buf())
.write(content)
.with_context(|| format!("Unable to write config to {}", path.display()))
}
pub fn default_path() -> Result<PathBuf> {
resolve_default_path(
std::env::var("XDG_CONFIG_HOME").ok(),
std::env::var("HOME").ok(),
)
}
}
fn resolve_default_path(xdg: Option<String>, home: Option<String>) -> Result<PathBuf> {
let base = xdg
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.or_else(|| home.map(|h| PathBuf::from(h).join(".config")));
match base {
Some(b) => Ok(b.join("vultan").join("config.ron")),
None => anyhow::bail!(
"Cannot resolve config path: neither $XDG_CONFIG_HOME nor $HOME is set. \
Set one of these env vars (typically `export HOME=$(pwd)` if you're in \
a sandboxed shell) before running vultan."
),
}
}
#[cfg(test)]
mod unit_tests {
use super::*;
use assert_fs::TempDir;
#[test]
fn config_load_from_returns_default_when_file_missing() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("missing.ron");
let config = Config::load_from(&path).unwrap();
assert_eq!(Config::default(), config);
}
#[test]
fn config_save_then_load_roundtrip() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("nested/config.ron");
let original = Config {
notes_dirpath: Some(PathBuf::from("/some/notes")),
daily_review_limit: None,
auto_commit_state: false,
};
original.save_to(&path).unwrap();
let loaded = Config::load_from(&path).unwrap();
assert_eq!(original, loaded);
}
#[test]
fn config_save_to_creates_missing_parent_directories() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("a/b/c/config.ron");
Config::default().save_to(&path).unwrap();
assert!(path.exists());
}
#[test]
fn config_load_from_returns_err_on_malformed_file() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("bad.ron");
std::fs::write(&path, "not a ron file").unwrap();
let result = Config::load_from(&path);
assert!(result.is_err());
assert!(format!("{:#?}", result.unwrap_err()).contains("Unable to parse config"));
}
#[test]
fn resolve_default_path_honors_xdg_config_home() {
assert_eq!(
PathBuf::from("/custom/config/vultan/config.ron"),
resolve_default_path(
Some("/custom/config".to_string()),
Some("/home/u".to_string())
)
.unwrap()
);
}
#[test]
fn resolve_default_path_falls_back_to_home_dot_config_when_xdg_missing() {
assert_eq!(
PathBuf::from("/home/u/.config/vultan/config.ron"),
resolve_default_path(None, Some("/home/u".to_string())).unwrap()
);
}
#[test]
fn resolve_default_path_treats_empty_xdg_as_unset() {
assert_eq!(
PathBuf::from("/home/u/.config/vultan/config.ron"),
resolve_default_path(Some("".to_string()), Some("/home/u".to_string())).unwrap()
);
}
#[test]
fn resolve_default_path_errors_when_both_env_vars_are_unset() {
let result = resolve_default_path(None, None);
assert!(result.is_err());
let msg = format!("{:#}", result.unwrap_err());
assert!(msg.contains("XDG_CONFIG_HOME") && msg.contains("HOME"));
}
#[test]
fn resolve_default_path_errors_when_xdg_empty_and_home_unset() {
let result = resolve_default_path(Some(String::new()), None);
assert!(result.is_err());
}
}