capsula_config/
lib.rs

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