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
23impl 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#[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
115pub 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}