1use crate::error::{Error, Result};
6use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8use std::path::Path;
9use std::path::PathBuf;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum WriteBackend {
26 #[default]
28 Legacy,
29 Git,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
39#[serde(rename_all = "kebab-case")]
40pub enum GitMergeStrategy {
41 #[default]
44 MergeCommit,
45 FastForward,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct GitAuthor {
55 pub name: String,
56 pub email: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct VaultGitConfig {
63 #[serde(default)]
65 pub branch: Option<String>,
66 #[serde(default)]
68 pub author: Option<GitAuthor>,
69 #[serde(default)]
71 pub merge_strategy: GitMergeStrategy,
72 #[serde(default = "default_include_ignored")]
81 pub include_ignored: bool,
82 #[serde(default)]
89 pub require_commit_message: bool,
90}
91
92fn default_include_ignored() -> bool {
93 true
94}
95
96impl Default for VaultGitConfig {
100 fn default() -> Self {
101 Self {
102 branch: None,
103 author: None,
104 merge_strategy: GitMergeStrategy::default(),
105 include_ignored: default_include_ignored(),
106 require_commit_message: false,
107 }
108 }
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct VaultConfig {
114 pub name: String,
116 pub path: PathBuf,
118 pub is_default: bool,
120
121 pub watch_for_changes: Option<bool>,
123 pub max_file_size: Option<u64>,
124 pub allowed_extensions: Option<HashSet<String>>,
125 pub excluded_paths: Option<HashSet<String>>,
126 pub enable_caching: Option<bool>,
127 pub cache_ttl: Option<u64>,
128 pub template_dirs: Option<Vec<PathBuf>>,
129 pub allowed_operations: Option<HashSet<String>>,
130
131 #[serde(default)]
133 pub write_backend: WriteBackend,
134 #[serde(default)]
136 pub git: Option<VaultGitConfig>,
137}
138
139impl VaultConfig {
140 pub fn builder(name: impl Into<String>, path: impl Into<PathBuf>) -> VaultConfigBuilder {
142 VaultConfigBuilder::new(name, path)
143 }
144
145 pub fn validate(&self) -> Result<()> {
147 if self.name.is_empty() {
148 return Err(Error::config_error("Vault name cannot be empty"));
149 }
150
151 if !self.path.exists() {
152 std::fs::create_dir_all(&self.path).map_err(|e| {
153 Error::config_error(format!(
154 "Vault path does not exist and could not be created: {} ({})",
155 self.path.display(),
156 e
157 ))
158 })?;
159 }
160
161 if !self.path.is_dir() {
162 return Err(Error::config_error(format!(
163 "Vault path is not a directory: {}",
164 self.path.display()
165 )));
166 }
167
168 Ok(())
169 }
170}
171
172pub struct VaultConfigBuilder {
174 name: String,
175 path: PathBuf,
176 is_default: bool,
177 watch_for_changes: Option<bool>,
178 max_file_size: Option<u64>,
179 allowed_extensions: Option<HashSet<String>>,
180 excluded_paths: Option<HashSet<String>>,
181 enable_caching: Option<bool>,
182 cache_ttl: Option<u64>,
183 template_dirs: Option<Vec<PathBuf>>,
184 allowed_operations: Option<HashSet<String>>,
185 write_backend: WriteBackend,
186 git: Option<VaultGitConfig>,
187}
188
189impl VaultConfigBuilder {
190 pub fn new(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
192 Self {
193 name: name.into(),
194 path: path.into(),
195 is_default: false,
196 watch_for_changes: None,
197 max_file_size: None,
198 allowed_extensions: None,
199 excluded_paths: None,
200 enable_caching: None,
201 cache_ttl: None,
202 template_dirs: None,
203 allowed_operations: None,
204 write_backend: WriteBackend::default(),
205 git: None,
206 }
207 }
208
209 pub fn as_default(mut self) -> Self {
211 self.is_default = true;
212 self
213 }
214
215 pub fn watch_for_changes(mut self, watch: bool) -> Self {
217 self.watch_for_changes = Some(watch);
218 self
219 }
220
221 pub fn write_backend(mut self, backend: WriteBackend) -> Self {
223 self.write_backend = backend;
224 self
225 }
226
227 pub fn git(mut self, git: VaultGitConfig) -> Self {
230 self.git = Some(git);
231 self
232 }
233
234 pub fn build(self) -> Result<VaultConfig> {
236 let expanded_path = shellexpand::full(&self.path.to_string_lossy())
238 .map(|p| PathBuf::from(p.into_owned()))
239 .unwrap_or(self.path);
240
241 let config = VaultConfig {
242 name: self.name,
243 path: expanded_path,
244 is_default: self.is_default,
245 watch_for_changes: self.watch_for_changes,
246 max_file_size: self.max_file_size,
247 allowed_extensions: self.allowed_extensions,
248 excluded_paths: self.excluded_paths,
249 enable_caching: self.enable_caching,
250 cache_ttl: self.cache_ttl,
251 template_dirs: self.template_dirs,
252 allowed_operations: self.allowed_operations,
253 write_backend: self.write_backend,
254 git: self.git,
255 };
256 config.validate()?;
257 Ok(config)
258 }
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct ServerConfig {
264 pub vaults: Vec<VaultConfig>,
266 pub profile: String,
268
269 pub watch_for_changes: bool,
271 pub max_file_size: u64,
272 pub allowed_extensions: HashSet<String>,
273 pub excluded_paths: HashSet<String>,
274 pub enable_caching: bool,
275 pub cache_ttl: u64,
276 pub log_level: String,
277
278 pub template_dirs: Vec<PathBuf>,
280 pub default_template_variables: serde_json::Value,
281 pub editor_backup_enabled: bool,
282 pub editor_atomic_writes: bool,
283 pub max_backup_files: usize,
284 pub max_edit_history: usize,
285 pub backup_retention_days: u32,
286
287 pub link_graph_enabled: bool,
289 pub link_suggestions_enabled: bool,
290 pub max_link_suggestions: usize,
291 pub link_similarity_threshold: f32,
292
293 pub full_text_search_enabled: bool,
295 pub index_rebuild_interval: u64,
296
297 pub multi_vault_enabled: bool,
299
300 pub metrics_enabled: bool,
302 pub debug_mode: bool,
303}
304
305impl Default for ServerConfig {
306 fn default() -> Self {
307 Self {
308 vaults: vec![],
309 profile: "default".to_string(),
310 watch_for_changes: true,
311 max_file_size: 10 * 1024 * 1024, allowed_extensions: [".md", ".txt", ".canvas"]
313 .iter()
314 .map(|s| s.to_string())
315 .collect(),
316 excluded_paths: [".obsidian", ".git", ".DS_Store", "node_modules"]
317 .iter()
318 .map(|s| s.to_string())
319 .collect(),
320 enable_caching: true,
321 cache_ttl: 3600,
322 log_level: "INFO".to_string(),
323 template_dirs: vec![],
324 default_template_variables: serde_json::json!({}),
325 editor_backup_enabled: true,
326 editor_atomic_writes: true,
327 max_backup_files: 100,
328 max_edit_history: 100,
329 backup_retention_days: 7,
330 link_graph_enabled: true,
331 link_suggestions_enabled: true,
332 max_link_suggestions: 10,
333 link_similarity_threshold: 0.3,
334 full_text_search_enabled: true,
335 index_rebuild_interval: 3600,
336 multi_vault_enabled: false,
337 metrics_enabled: false,
338 debug_mode: false,
339 }
340 }
341}
342
343impl ServerConfig {
344 pub fn new() -> Self {
346 Self::default()
347 }
348
349 pub fn validate(&self) -> Result<()> {
351 if self.vaults.is_empty() {
352 return Err(Error::config_error("At least one vault must be configured"));
353 }
354
355 let names: HashSet<_> = self.vaults.iter().map(|v| &v.name).collect();
357 if names.len() != self.vaults.len() {
358 return Err(Error::config_error("Vault names must be unique"));
359 }
360
361 let defaults: Vec<_> = self.vaults.iter().filter(|v| v.is_default).collect();
363 if defaults.len() > 1 {
364 return Err(Error::config_error("Only one vault can be default"));
365 }
366
367 for vault in &self.vaults {
369 vault.validate()?;
370 }
371
372 Ok(())
373 }
374
375 pub fn default_vault(&self) -> Result<&VaultConfig> {
377 self.vaults
378 .iter()
379 .find(|v| v.is_default)
380 .or_else(|| self.vaults.first())
381 .ok_or_else(|| Error::config_error("No default vault configured"))
382 }
383
384 pub async fn save_vaults(&self, path: &Path) -> Result<()> {
386 let yaml = yaml_serde::to_string(&self.vaults)
387 .map_err(|e| Error::config_error(format!("Failed to serialize vaults: {}", e)))?;
388
389 tokio::fs::write(path, yaml).await.map_err(|e| {
390 Error::config_error(format!(
391 "Failed to save vaults to {}: {}",
392 path.display(),
393 e
394 ))
395 })
396 }
397
398 pub async fn load_vaults(path: &Path) -> Result<Vec<VaultConfig>> {
400 if !path.exists() {
401 return Ok(Vec::new()); }
403
404 let content = tokio::fs::read_to_string(path).await.map_err(|e| {
405 Error::config_error(format!(
406 "Failed to load vaults from {}: {}",
407 path.display(),
408 e
409 ))
410 })?;
411
412 let vaults = yaml_serde::from_str(&content)
413 .map_err(|e| Error::config_error(format!("Invalid vault configuration: {}", e)))?;
414
415 Ok(vaults)
416 }
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 use tempfile::TempDir;
423
424 #[test]
425 fn test_vault_config_builder() {
426 let temp = TempDir::new().unwrap();
427 let vault = VaultConfig::builder("main", temp.path())
428 .as_default()
429 .watch_for_changes(true)
430 .build();
431
432 assert!(vault.is_ok());
433 let v = vault.unwrap();
434 assert_eq!(v.name, "main");
435 assert!(v.is_default);
436 }
437
438 #[test]
439 fn test_server_config_validation() {
440 let mut config = ServerConfig::new();
441 config.vaults.clear();
442 assert!(config.validate().is_err());
443 }
444
445 #[test]
448 fn vault_config_defaults_to_legacy_backend_and_no_git() {
449 let temp = TempDir::new().unwrap();
450 let v = VaultConfig::builder("main", temp.path()).build().unwrap();
451 assert_eq!(v.write_backend, WriteBackend::Legacy);
452 assert!(v.git.is_none());
453 }
454
455 #[test]
456 fn vault_config_builder_sets_git_backend() {
457 let temp = TempDir::new().unwrap();
458 let v = VaultConfig::builder("g", temp.path())
459 .write_backend(WriteBackend::Git)
460 .git(VaultGitConfig {
461 branch: Some("main".to_string()),
462 author: Some(GitAuthor {
463 name: "TurboVault".to_string(),
464 email: "tv@localhost".to_string(),
465 }),
466 merge_strategy: GitMergeStrategy::FastForward,
467 include_ignored: false,
468 require_commit_message: false,
469 })
470 .build()
471 .unwrap();
472 assert_eq!(v.write_backend, WriteBackend::Git);
473 let g = v.git.unwrap();
474 assert_eq!(g.branch.as_deref(), Some("main"));
475 assert_eq!(g.merge_strategy, GitMergeStrategy::FastForward);
476 assert!(!g.include_ignored);
477 assert_eq!(g.author.unwrap().email, "tv@localhost");
478 }
479
480 #[test]
481 fn vault_config_yaml_roundtrip_with_git_section() {
482 let temp = TempDir::new().unwrap();
483 let v = VaultConfig::builder("g", temp.path())
484 .write_backend(WriteBackend::Git)
485 .git(VaultGitConfig::default())
486 .build()
487 .unwrap();
488 let yaml = yaml_serde::to_string(&v).unwrap();
489 let back: VaultConfig = yaml_serde::from_str(&yaml).unwrap();
490 assert_eq!(back.write_backend, WriteBackend::Git);
491 assert!(back.git.is_some());
492 let g = back.git.unwrap();
494 assert_eq!(g.merge_strategy, GitMergeStrategy::MergeCommit);
495 assert!(g.include_ignored, "include_ignored defaults to true");
496 }
497
498 #[test]
499 fn vault_config_yaml_legacy_omits_git_section() {
500 let temp = TempDir::new().unwrap();
501 let v = VaultConfig::builder("l", temp.path()).build().unwrap();
502 let yaml = yaml_serde::to_string(&v).unwrap();
503 let back: VaultConfig = yaml_serde::from_str(&yaml).unwrap();
505 assert_eq!(back.write_backend, WriteBackend::Legacy);
506 assert!(back.git.is_none());
507 }
508
509 #[test]
510 fn write_backend_serializes_lowercase() {
511 let yaml = yaml_serde::to_string(&WriteBackend::Git).unwrap();
512 assert!(yaml.contains("git"), "got: {yaml}");
513 let back: WriteBackend = yaml_serde::from_str("legacy\n").unwrap();
514 assert_eq!(back, WriteBackend::Legacy);
515 }
516
517 #[test]
518 fn merge_strategy_serializes_kebab_case() {
519 let yaml = yaml_serde::to_string(&GitMergeStrategy::MergeCommit).unwrap();
520 assert!(yaml.contains("merge-commit"), "got: {yaml}");
521 let back: GitMergeStrategy = yaml_serde::from_str("fast-forward\n").unwrap();
522 assert_eq!(back, GitMergeStrategy::FastForward);
523 }
524}