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
27impl 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#[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
127pub 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}