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