Skip to main content

capsula_config/
lib.rs

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