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