Skip to main content

turbovault_core/
config.rs

1//! Configuration types for the Obsidian server.
2//!
3//! Follows a builder pattern for complex configuration with validation.
4
5use crate::error::{Error, Result};
6use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8use std::path::Path;
9use std::path::PathBuf;
10
11/// Selects which write path serves a vault's mutations (GWS.11).
12///
13/// **Short-lived**: this flag exists so the git-native substrate
14/// (`turbovault-git`) can be wired alongside the legacy `VaultManager` path
15/// behind a per-vault switch during the cutover (GWS.15). At cutover the
16/// default flips to `Git`, the legacy path is deleted, and the flag is removed
17/// from this config entirely.
18///
19/// Per-vault by design: the substrate's working-tree-equals-HEAD invariant
20/// forbids mixing within one vault (a legacy write commits nothing, leaving
21/// the working tree out of sync with the git tip), so a vault is one or the
22/// other end-to-end.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum WriteBackend {
26    /// The legacy `VaultManager` mutators + `BatchExecutor` (default until cutover).
27    #[default]
28    Legacy,
29    /// The git-native write substrate (`turbovault-git`). Requires the vault
30    /// path to be a git repository.
31    Git,
32}
33
34/// How the git substrate merges a fan-out's wip branch back into main
35/// (mirrors `turbovault_git::MergeStrategy` as a serializable config type so
36/// `turbovault-core` doesn't pick up a git2/libgit2 dependency). The consumer
37/// converts at the substrate boundary.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
39#[serde(rename_all = "kebab-case")]
40pub enum GitMergeStrategy {
41    /// `git merge --no-ff` — preserves the wip branch's per-transaction
42    /// commits with a merge commit on main. The default.
43    #[default]
44    MergeCommit,
45    /// Advance main directly to the wip tip — errors if main advanced
46    /// concurrently (caller falls back to `MergeCommit`).
47    FastForward,
48}
49
50/// Commit identity for git-backed writes. Optional in the config — when
51/// absent, the substrate falls back to the repo's `user.name`/`user.email`
52/// and then to a built-in TurboVault default.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct GitAuthor {
55    pub name: String,
56    pub email: String,
57}
58
59/// Per-vault git substrate configuration. Only meaningful when
60/// [`VaultConfig::write_backend`] is `Git`.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct VaultGitConfig {
63    /// Target branch for commits. `None` = use the repo's current HEAD branch.
64    #[serde(default)]
65    pub branch: Option<String>,
66    /// Commit author identity. `None` = repo's git config -> TurboVault default.
67    #[serde(default)]
68    pub author: Option<GitAuthor>,
69    /// Default merge strategy for fan-out merge-back (`commit_transaction`).
70    #[serde(default)]
71    pub merge_strategy: GitMergeStrategy,
72    /// turbovault-lri: when `false`, every git-backend mutation pre-checks
73    /// each touched path against the worktree's `.gitignore` matcher and
74    /// refuses the transaction (typed config error) if any path would be
75    /// excluded. When `true` (the default), `.gitignore` is ignored and
76    /// every requested path is committed — the original always-write
77    /// behavior. Useful for vaults that gitignore `.obsidian/`, build
78    /// artifacts, or per-user clutter and want a backstop against an MCP
79    /// client accidentally committing them.
80    #[serde(default = "default_include_ignored")]
81    pub include_ignored: bool,
82    /// turbovault-5nn: when `true`, every git-backend mutation MUST carry a
83    /// caller-supplied commit message — a tool called without one (or with a
84    /// blank/whitespace-only one) is refused loudly instead of falling back to
85    /// the auto-derived subject (`write_note <path>`, etc.). Default `false`
86    /// preserves the auto-derive behavior. Only meaningful on the git backend
87    /// (the legacy backend produces no commits, so a message is moot).
88    #[serde(default)]
89    pub require_commit_message: bool,
90}
91
92fn default_include_ignored() -> bool {
93    true
94}
95
96// Manual `Default` so `VaultGitConfig::default().include_ignored == true`,
97// matching the serde-default for that field (derive(Default) on a bool yields
98// `false`, which would disagree with the missing-field deserialization).
99impl 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/// Configuration for a single vault
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct VaultConfig {
114    /// Unique identifier for this vault
115    pub name: String,
116    /// Path to the vault directory
117    pub path: PathBuf,
118    /// Whether this is the default vault
119    pub is_default: bool,
120
121    // Optional overrides
122    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    /// Write backend selection (GWS.11). Default `Legacy` until cutover.
132    #[serde(default)]
133    pub write_backend: WriteBackend,
134    /// Git substrate settings. Only used when `write_backend == Git`.
135    #[serde(default)]
136    pub git: Option<VaultGitConfig>,
137}
138
139impl VaultConfig {
140    /// Create a new vault config with builder
141    pub fn builder(name: impl Into<String>, path: impl Into<PathBuf>) -> VaultConfigBuilder {
142        VaultConfigBuilder::new(name, path)
143    }
144
145    /// Validate the vault configuration
146    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
172/// Builder for VaultConfig
173pub 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    /// Create a new builder
191    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    /// Mark as default vault
210    pub fn as_default(mut self) -> Self {
211        self.is_default = true;
212        self
213    }
214
215    /// Set watch_for_changes
216    pub fn watch_for_changes(mut self, watch: bool) -> Self {
217        self.watch_for_changes = Some(watch);
218        self
219    }
220
221    /// Select the write backend (GWS.11).
222    pub fn write_backend(mut self, backend: WriteBackend) -> Self {
223        self.write_backend = backend;
224        self
225    }
226
227    /// Set the per-vault git substrate config (typically combined with
228    /// `write_backend(WriteBackend::Git)`).
229    pub fn git(mut self, git: VaultGitConfig) -> Self {
230        self.git = Some(git);
231        self
232    }
233
234    /// Build and validate
235    pub fn build(self) -> Result<VaultConfig> {
236        // Expand tilde and environment variables in the path
237        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/// Global server configuration
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct ServerConfig {
264    /// List of configured vaults
265    pub vaults: Vec<VaultConfig>,
266    /// Configuration profile name
267    pub profile: String,
268
269    // Core settings
270    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    // Advanced settings
279    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    // Link graph settings
288    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    // Search settings
294    pub full_text_search_enabled: bool,
295    pub index_rebuild_interval: u64,
296
297    // Multi-vault
298    pub multi_vault_enabled: bool,
299
300    // Admin
301    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, // 10MB
312            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    /// Create new configuration
345    pub fn new() -> Self {
346        Self::default()
347    }
348
349    /// Validate configuration
350    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        // Check unique vault names
356        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        // Check unique default vaults
362        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        // Validate each vault
368        for vault in &self.vaults {
369            vault.validate()?;
370        }
371
372        Ok(())
373    }
374
375    /// Get default vault config
376    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    /// Save vault configuration to file (for persistence)
385    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    /// Load vault configuration from file
399    pub async fn load_vaults(path: &Path) -> Result<Vec<VaultConfig>> {
400        if !path.exists() {
401            return Ok(Vec::new()); // Return empty if file doesn't exist
402        }
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    // -------- GWS.11 write-backend + git config --------
446
447    #[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        // VaultGitConfig defaults survive a roundtrip.
493        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        // The roundtrip preserves the legacy default + None git.
504        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}