use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WriteBackend {
#[default]
Legacy,
Git,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum GitMergeStrategy {
#[default]
MergeCommit,
FastForward,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitAuthor {
pub name: String,
pub email: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultGitConfig {
#[serde(default)]
pub branch: Option<String>,
#[serde(default)]
pub author: Option<GitAuthor>,
#[serde(default)]
pub merge_strategy: GitMergeStrategy,
#[serde(default = "default_include_ignored")]
pub include_ignored: bool,
#[serde(default)]
pub require_commit_message: bool,
}
fn default_include_ignored() -> bool {
true
}
impl Default for VaultGitConfig {
fn default() -> Self {
Self {
branch: None,
author: None,
merge_strategy: GitMergeStrategy::default(),
include_ignored: default_include_ignored(),
require_commit_message: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultConfig {
pub name: String,
pub path: PathBuf,
pub is_default: bool,
pub watch_for_changes: Option<bool>,
pub max_file_size: Option<u64>,
pub allowed_extensions: Option<HashSet<String>>,
pub excluded_paths: Option<HashSet<String>>,
pub enable_caching: Option<bool>,
pub cache_ttl: Option<u64>,
pub template_dirs: Option<Vec<PathBuf>>,
pub allowed_operations: Option<HashSet<String>>,
#[serde(default)]
pub write_backend: WriteBackend,
#[serde(default)]
pub git: Option<VaultGitConfig>,
}
impl VaultConfig {
pub fn builder(name: impl Into<String>, path: impl Into<PathBuf>) -> VaultConfigBuilder {
VaultConfigBuilder::new(name, path)
}
pub fn validate(&self) -> Result<()> {
if self.name.is_empty() {
return Err(Error::config_error("Vault name cannot be empty"));
}
if !self.path.exists() {
std::fs::create_dir_all(&self.path).map_err(|e| {
Error::config_error(format!(
"Vault path does not exist and could not be created: {} ({})",
self.path.display(),
e
))
})?;
}
if !self.path.is_dir() {
return Err(Error::config_error(format!(
"Vault path is not a directory: {}",
self.path.display()
)));
}
Ok(())
}
}
pub struct VaultConfigBuilder {
name: String,
path: PathBuf,
is_default: bool,
watch_for_changes: Option<bool>,
max_file_size: Option<u64>,
allowed_extensions: Option<HashSet<String>>,
excluded_paths: Option<HashSet<String>>,
enable_caching: Option<bool>,
cache_ttl: Option<u64>,
template_dirs: Option<Vec<PathBuf>>,
allowed_operations: Option<HashSet<String>>,
write_backend: WriteBackend,
git: Option<VaultGitConfig>,
}
impl VaultConfigBuilder {
pub fn new(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
Self {
name: name.into(),
path: path.into(),
is_default: false,
watch_for_changes: None,
max_file_size: None,
allowed_extensions: None,
excluded_paths: None,
enable_caching: None,
cache_ttl: None,
template_dirs: None,
allowed_operations: None,
write_backend: WriteBackend::default(),
git: None,
}
}
pub fn as_default(mut self) -> Self {
self.is_default = true;
self
}
pub fn watch_for_changes(mut self, watch: bool) -> Self {
self.watch_for_changes = Some(watch);
self
}
pub fn write_backend(mut self, backend: WriteBackend) -> Self {
self.write_backend = backend;
self
}
pub fn git(mut self, git: VaultGitConfig) -> Self {
self.git = Some(git);
self
}
pub fn build(self) -> Result<VaultConfig> {
let expanded_path = shellexpand::full(&self.path.to_string_lossy())
.map(|p| PathBuf::from(p.into_owned()))
.unwrap_or(self.path);
let config = VaultConfig {
name: self.name,
path: expanded_path,
is_default: self.is_default,
watch_for_changes: self.watch_for_changes,
max_file_size: self.max_file_size,
allowed_extensions: self.allowed_extensions,
excluded_paths: self.excluded_paths,
enable_caching: self.enable_caching,
cache_ttl: self.cache_ttl,
template_dirs: self.template_dirs,
allowed_operations: self.allowed_operations,
write_backend: self.write_backend,
git: self.git,
};
config.validate()?;
Ok(config)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub vaults: Vec<VaultConfig>,
pub profile: String,
pub watch_for_changes: bool,
pub max_file_size: u64,
pub allowed_extensions: HashSet<String>,
pub excluded_paths: HashSet<String>,
pub enable_caching: bool,
pub cache_ttl: u64,
pub log_level: String,
pub template_dirs: Vec<PathBuf>,
pub default_template_variables: serde_json::Value,
pub editor_backup_enabled: bool,
pub editor_atomic_writes: bool,
pub max_backup_files: usize,
pub max_edit_history: usize,
pub backup_retention_days: u32,
pub link_graph_enabled: bool,
pub link_suggestions_enabled: bool,
pub max_link_suggestions: usize,
pub link_similarity_threshold: f32,
pub full_text_search_enabled: bool,
pub index_rebuild_interval: u64,
pub multi_vault_enabled: bool,
pub metrics_enabled: bool,
pub debug_mode: bool,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
vaults: vec![],
profile: "default".to_string(),
watch_for_changes: true,
max_file_size: 10 * 1024 * 1024, allowed_extensions: [".md", ".txt", ".canvas"]
.iter()
.map(|s| s.to_string())
.collect(),
excluded_paths: [".obsidian", ".git", ".DS_Store", "node_modules"]
.iter()
.map(|s| s.to_string())
.collect(),
enable_caching: true,
cache_ttl: 3600,
log_level: "INFO".to_string(),
template_dirs: vec![],
default_template_variables: serde_json::json!({}),
editor_backup_enabled: true,
editor_atomic_writes: true,
max_backup_files: 100,
max_edit_history: 100,
backup_retention_days: 7,
link_graph_enabled: true,
link_suggestions_enabled: true,
max_link_suggestions: 10,
link_similarity_threshold: 0.3,
full_text_search_enabled: true,
index_rebuild_interval: 3600,
multi_vault_enabled: false,
metrics_enabled: false,
debug_mode: false,
}
}
}
impl ServerConfig {
pub fn new() -> Self {
Self::default()
}
pub fn validate(&self) -> Result<()> {
if self.vaults.is_empty() {
return Err(Error::config_error("At least one vault must be configured"));
}
let names: HashSet<_> = self.vaults.iter().map(|v| &v.name).collect();
if names.len() != self.vaults.len() {
return Err(Error::config_error("Vault names must be unique"));
}
let defaults: Vec<_> = self.vaults.iter().filter(|v| v.is_default).collect();
if defaults.len() > 1 {
return Err(Error::config_error("Only one vault can be default"));
}
for vault in &self.vaults {
vault.validate()?;
}
Ok(())
}
pub fn default_vault(&self) -> Result<&VaultConfig> {
self.vaults
.iter()
.find(|v| v.is_default)
.or_else(|| self.vaults.first())
.ok_or_else(|| Error::config_error("No default vault configured"))
}
pub async fn save_vaults(&self, path: &Path) -> Result<()> {
let yaml = yaml_serde::to_string(&self.vaults)
.map_err(|e| Error::config_error(format!("Failed to serialize vaults: {}", e)))?;
tokio::fs::write(path, yaml).await.map_err(|e| {
Error::config_error(format!(
"Failed to save vaults to {}: {}",
path.display(),
e
))
})
}
pub async fn load_vaults(path: &Path) -> Result<Vec<VaultConfig>> {
if !path.exists() {
return Ok(Vec::new()); }
let content = tokio::fs::read_to_string(path).await.map_err(|e| {
Error::config_error(format!(
"Failed to load vaults from {}: {}",
path.display(),
e
))
})?;
let vaults = yaml_serde::from_str(&content)
.map_err(|e| Error::config_error(format!("Invalid vault configuration: {}", e)))?;
Ok(vaults)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_vault_config_builder() {
let temp = TempDir::new().unwrap();
let vault = VaultConfig::builder("main", temp.path())
.as_default()
.watch_for_changes(true)
.build();
assert!(vault.is_ok());
let v = vault.unwrap();
assert_eq!(v.name, "main");
assert!(v.is_default);
}
#[test]
fn test_server_config_validation() {
let mut config = ServerConfig::new();
config.vaults.clear();
assert!(config.validate().is_err());
}
#[test]
fn vault_config_defaults_to_legacy_backend_and_no_git() {
let temp = TempDir::new().unwrap();
let v = VaultConfig::builder("main", temp.path()).build().unwrap();
assert_eq!(v.write_backend, WriteBackend::Legacy);
assert!(v.git.is_none());
}
#[test]
fn vault_config_builder_sets_git_backend() {
let temp = TempDir::new().unwrap();
let v = VaultConfig::builder("g", temp.path())
.write_backend(WriteBackend::Git)
.git(VaultGitConfig {
branch: Some("main".to_string()),
author: Some(GitAuthor {
name: "TurboVault".to_string(),
email: "tv@localhost".to_string(),
}),
merge_strategy: GitMergeStrategy::FastForward,
include_ignored: false,
require_commit_message: false,
})
.build()
.unwrap();
assert_eq!(v.write_backend, WriteBackend::Git);
let g = v.git.unwrap();
assert_eq!(g.branch.as_deref(), Some("main"));
assert_eq!(g.merge_strategy, GitMergeStrategy::FastForward);
assert!(!g.include_ignored);
assert_eq!(g.author.unwrap().email, "tv@localhost");
}
#[test]
fn vault_config_yaml_roundtrip_with_git_section() {
let temp = TempDir::new().unwrap();
let v = VaultConfig::builder("g", temp.path())
.write_backend(WriteBackend::Git)
.git(VaultGitConfig::default())
.build()
.unwrap();
let yaml = yaml_serde::to_string(&v).unwrap();
let back: VaultConfig = yaml_serde::from_str(&yaml).unwrap();
assert_eq!(back.write_backend, WriteBackend::Git);
assert!(back.git.is_some());
let g = back.git.unwrap();
assert_eq!(g.merge_strategy, GitMergeStrategy::MergeCommit);
assert!(g.include_ignored, "include_ignored defaults to true");
}
#[test]
fn vault_config_yaml_legacy_omits_git_section() {
let temp = TempDir::new().unwrap();
let v = VaultConfig::builder("l", temp.path()).build().unwrap();
let yaml = yaml_serde::to_string(&v).unwrap();
let back: VaultConfig = yaml_serde::from_str(&yaml).unwrap();
assert_eq!(back.write_backend, WriteBackend::Legacy);
assert!(back.git.is_none());
}
#[test]
fn write_backend_serializes_lowercase() {
let yaml = yaml_serde::to_string(&WriteBackend::Git).unwrap();
assert!(yaml.contains("git"), "got: {yaml}");
let back: WriteBackend = yaml_serde::from_str("legacy\n").unwrap();
assert_eq!(back, WriteBackend::Legacy);
}
#[test]
fn merge_strategy_serializes_kebab_case() {
let yaml = yaml_serde::to_string(&GitMergeStrategy::MergeCommit).unwrap();
assert!(yaml.contains("merge-commit"), "got: {yaml}");
let back: GitMergeStrategy = yaml_serde::from_str("fast-forward\n").unwrap();
assert_eq!(back, GitMergeStrategy::FastForward);
}
}