capsula_config/
lib.rs

1use capsula_core::error::CoreResult;
2use serde::{Deserialize, Deserializer};
3use std::path::{Path, PathBuf};
4use thiserror::Error;
5
6#[derive(Error, Debug)]
7pub enum ConfigError {
8    #[error("Failed to parse TOML: {0}")]
9    TomlParse(#[from] toml::de::Error),
10    #[error("IO error: {0}")]
11    Io(#[from] std::io::Error),
12    #[error("Core error: {0}")]
13    Core(#[from] capsula_core::error::CoreError),
14}
15
16pub type ConfigResult<T> = Result<T, ConfigError>;
17
18#[derive(Deserialize, Debug, Clone)]
19pub struct CapsulaConfig {
20    pub vault: VaultConfig,
21    pub phase: PhaseConfig,
22}
23
24#[derive(Debug, Clone)]
25pub struct VaultConfig {
26    pub name: String,
27    pub path: PathBuf,
28}
29
30impl<'de> Deserialize<'de> for VaultConfig {
31    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
32    where
33        D: Deserializer<'de>,
34    {
35        #[derive(Deserialize)]
36        struct VaultConfigHelper {
37            name: String,
38            path: Option<PathBuf>,
39        }
40
41        let helper = VaultConfigHelper::deserialize(deserializer)?;
42        let path = helper
43            .path
44            .unwrap_or_else(|| PathBuf::from(format!(".capsula/{}", helper.name)));
45
46        Ok(VaultConfig {
47            name: helper.name,
48            path,
49        })
50    }
51}
52
53#[derive(Deserialize, Debug, Clone, Default)]
54pub struct PhaseConfig {
55    #[serde(default)]
56    pub pre: PrePhaseConfig,
57    #[serde(rename = "in", default)]
58    pub in_phase: InPhaseConfig,
59    #[serde(default)]
60    pub post: PostPhaseConfig,
61}
62
63/// A phase configuration that contains contexts
64#[derive(Deserialize, Debug, Clone, Default)]
65pub struct ContextPhaseConfig {
66    #[serde(default)]
67    pub contexts: Vec<ContextEnvelope>,
68}
69
70/// A phase configuration that contains watchers
71#[derive(Deserialize, Debug, Clone, Default)]
72pub struct WatcherPhaseConfig {
73    #[serde(default)]
74    pub watchers: Vec<WatcherSpec>,
75}
76
77// Type aliases for semantic clarity
78pub type PrePhaseConfig = ContextPhaseConfig;
79pub type PostPhaseConfig = ContextPhaseConfig;
80pub type InPhaseConfig = WatcherPhaseConfig;
81
82#[derive(Deserialize, Debug, Clone)]
83pub struct ContextEnvelope {
84    #[serde(rename = "type")]
85    pub ty: String,
86    #[serde(flatten)]
87    pub rest: serde_json::Value,
88}
89
90#[derive(Deserialize, Debug, Clone)]
91#[serde(tag = "type", rename_all = "lowercase")]
92pub enum WatcherSpec {
93    Time(TimeWatcherSpec),
94}
95
96#[derive(Deserialize, Debug, Clone, Default)]
97pub struct TimeWatcherSpec {}
98
99impl CapsulaConfig {
100    pub fn from_str(content: &str) -> ConfigResult<Self> {
101        Ok(toml::from_str(content)?)
102    }
103
104    pub fn from_file(path: impl AsRef<std::path::Path>) -> ConfigResult<Self> {
105        let content = std::fs::read_to_string(path)?;
106        Self::from_str(&content)
107    }
108}
109
110/// Build contexts from any phase config that contains contexts
111pub fn build_contexts(
112    phase: &ContextPhaseConfig,
113    project_root: &Path,
114    registry: &capsula_registry::ContextRegistry,
115) -> CoreResult<Vec<Box<dyn capsula_core::context::ContextErased>>> {
116    phase
117        .contexts
118        .iter()
119        .map(|envelope| registry.create_context(&envelope.ty, &envelope.rest, project_root))
120        .collect()
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_parse_example_config() {
129        let config_str = r#"
130[vault]
131name = "capsula"
132
133[[phase.pre.contexts]]
134type = "cwd"
135
136[[phase.pre.contexts]]
137type = "git"
138path = "."
139
140[[phase.pre.contexts]]
141type = "file"
142path = "capsula.toml"
143copy = true
144hash = true
145
146[[phase.pre.contexts]]
147type = "file"
148path = "Cargo.toml"
149hash = true
150
151[[phase.in.watchers]]
152type = "time"
153
154[[phase.post.contexts]]
155type = "env"
156key = "PATH"
157"#;
158
159        let config = CapsulaConfig::from_str(config_str).unwrap();
160
161        assert_eq!(config.vault.name, "capsula");
162        assert_eq!(config.vault.path, PathBuf::from(".capsula/capsula"));
163
164        assert_eq!(config.phase.pre.contexts.len(), 4);
165        assert_eq!(config.phase.pre.contexts[0].ty, "cwd");
166        assert_eq!(config.phase.pre.contexts[1].ty, "git");
167        assert_eq!(config.phase.pre.contexts[2].ty, "file");
168        assert_eq!(config.phase.pre.contexts[3].ty, "file");
169
170        assert_eq!(config.phase.in_phase.watchers.len(), 1);
171        assert!(matches!(
172            &config.phase.in_phase.watchers[0],
173            WatcherSpec::Time(_)
174        ));
175
176        assert_eq!(config.phase.post.contexts.len(), 1);
177        assert_eq!(config.phase.post.contexts[0].ty, "env");
178    }
179
180    #[test]
181    fn test_vault_config_with_explicit_path() {
182        let config_str = r#"
183[vault]
184name = "my_vault"
185path = "/custom/path/to/vault"
186
187[[phase.pre.contexts]]
188type = "cwd"
189"#;
190
191        let config = CapsulaConfig::from_str(config_str).unwrap();
192
193        assert_eq!(config.vault.name, "my_vault");
194        assert_eq!(config.vault.path, PathBuf::from("/custom/path/to/vault"));
195    }
196
197    #[test]
198    fn test_vault_config_without_path() {
199        let config_str = r#"
200[vault]
201name = "test_vault"
202
203[[phase.pre.contexts]]
204type = "cwd"
205"#;
206
207        let config = CapsulaConfig::from_str(config_str).unwrap();
208
209        assert_eq!(config.vault.name, "test_vault");
210        assert_eq!(config.vault.path, PathBuf::from(".capsula/test_vault"));
211    }
212}