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
26impl From<ConfigError> for CapsulaError {
28 fn from(err: ConfigError) -> Self {
29 match err {
30 ConfigError::TomlParse(e) => CapsulaError::Configuration {
31 message: format!("Failed to parse TOML configuration: {}", e),
32 },
33 ConfigError::FileNotFound { path } => CapsulaError::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 } => CapsulaError::Configuration { message },
40 ConfigError::Io(e) => CapsulaError::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(VaultConfig {
78 name: helper.name,
79 path,
80 })
81 }
82}
83
84#[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
118pub 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)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn test_parse_example_config() {
137 let config_str = r#"
138[vault]
139name = "capsula"
140
141[[pre-run.hooks]]
142id = "capture-cwd"
143
144[[pre-run.hooks]]
145id = "capture-git-repo"
146path = "."
147
148[[pre-run.hooks]]
149id = "capture-file"
150path = "capsula.toml"
151copy = true
152hash = true
153
154[[pre-run.hooks]]
155id = "capture-file"
156path = "Cargo.toml"
157hash = true
158
159[[post-run.hooks]]
160id = "capture-env"
161key = "PATH"
162"#;
163
164 let config = CapsulaConfig::from_toml_str(config_str).unwrap();
165
166 assert_eq!(config.vault.name, "capsula");
167 assert_eq!(config.vault.path, PathBuf::from(".capsula/capsula"));
168
169 assert_eq!(config.pre_run.hooks.len(), 4);
170 assert_eq!(config.pre_run.hooks[0].id, "capture-cwd");
171 assert_eq!(config.pre_run.hooks[1].id, "capture-git-repo");
172 assert_eq!(config.pre_run.hooks[2].id, "capture-file");
173 assert_eq!(config.pre_run.hooks[3].id, "capture-file");
174
175 assert_eq!(config.post_run.hooks.len(), 1);
176 assert_eq!(config.post_run.hooks[0].id, "capture-env");
177 }
178
179 #[test]
180 fn test_vault_config_with_explicit_path() {
181 let config_str = r#"
182[vault]
183name = "my_vault"
184path = "/custom/path/to/vault"
185
186[[pre-run.hooks]]
187id = "capture-cwd"
188"#;
189
190 let config = CapsulaConfig::from_toml_str(config_str).unwrap();
191
192 assert_eq!(config.vault.name, "my_vault");
193 assert_eq!(config.vault.path, PathBuf::from("/custom/path/to/vault"));
194 }
195
196 #[test]
197 fn test_vault_config_without_path() {
198 let config_str = r#"
199[vault]
200name = "test_vault"
201
202[[pre-run.hooks]]
203id = "capture-cwd"
204"#;
205
206 let config = CapsulaConfig::from_toml_str(config_str).unwrap();
207
208 assert_eq!(config.vault.name, "test_vault");
209 assert_eq!(config.vault.path, PathBuf::from(".capsula/test_vault"));
210 }
211}