capsula_config/
lib.rs

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