1use capsula_core::error::{CapsulaError, CoreResult};
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)]
43pub struct CapsulaConfig {
44 pub vault: VaultConfig,
45 pub phase: PhaseConfig,
46}
47
48#[derive(Debug, Clone)]
49pub struct VaultConfig {
50 pub name: String,
51 pub path: PathBuf,
52}
53
54impl<'de> Deserialize<'de> for VaultConfig {
55 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
56 where
57 D: Deserializer<'de>,
58 {
59 #[derive(Deserialize)]
60 struct VaultConfigHelper {
61 name: String,
62 path: Option<PathBuf>,
63 }
64
65 let helper = VaultConfigHelper::deserialize(deserializer)?;
66 let path = helper
67 .path
68 .unwrap_or_else(|| PathBuf::from(format!(".capsula/{}", helper.name)));
69
70 Ok(VaultConfig {
71 name: helper.name,
72 path,
73 })
74 }
75}
76
77#[derive(Deserialize, Debug, Clone, Default)]
78pub struct PhaseConfig {
79 #[serde(default)]
80 pub pre: PrePhaseConfig,
81 #[serde(rename = "in", default)]
82 pub in_phase: InPhaseConfig,
83 #[serde(default)]
84 pub post: PostPhaseConfig,
85}
86
87#[derive(Deserialize, Debug, Clone, Default)]
89pub struct ContextPhaseConfig {
90 #[serde(default)]
91 pub contexts: Vec<ContextEnvelope>,
92}
93
94#[derive(Deserialize, Debug, Clone, Default)]
96pub struct WatcherPhaseConfig {
97 #[serde(default)]
98 pub watchers: Vec<WatcherSpec>,
99}
100
101pub type PrePhaseConfig = ContextPhaseConfig;
103pub type PostPhaseConfig = ContextPhaseConfig;
104pub type InPhaseConfig = WatcherPhaseConfig;
105
106#[derive(Deserialize, Debug, Clone)]
107pub struct ContextEnvelope {
108 #[serde(rename = "type")]
109 pub ty: String,
110 #[serde(flatten)]
111 pub rest: serde_json::Value,
112}
113
114#[derive(Deserialize, Debug, Clone)]
115#[serde(tag = "type", rename_all = "lowercase")]
116pub enum WatcherSpec {
117 Time(TimeWatcherSpec),
118}
119
120#[derive(Deserialize, Debug, Clone, Default)]
121pub struct TimeWatcherSpec {}
122
123impl CapsulaConfig {
124 pub fn from_str(content: &str) -> ConfigResult<Self> {
125 Ok(toml::from_str(content)?)
126 }
127
128 pub fn from_file(path: impl AsRef<std::path::Path>) -> ConfigResult<Self> {
129 let path = path.as_ref();
130 let content = std::fs::read_to_string(path).map_err(|e| {
131 if e.kind() == std::io::ErrorKind::NotFound {
132 ConfigError::FileNotFound {
133 path: path.to_path_buf(),
134 }
135 } else {
136 ConfigError::Io(e)
137 }
138 })?;
139 Self::from_str(&content)
140 }
141}
142
143pub fn build_contexts(
145 phase: &ContextPhaseConfig,
146 project_root: &Path,
147 registry: &capsula_registry::ContextRegistry,
148) -> CoreResult<Vec<Box<dyn capsula_core::context::ContextErased>>> {
149 phase
150 .contexts
151 .iter()
152 .map(|envelope| registry.create_context(&envelope.ty, &envelope.rest, project_root))
153 .collect()
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn test_parse_example_config() {
162 let config_str = r#"
163[vault]
164name = "capsula"
165
166[[phase.pre.contexts]]
167type = "cwd"
168
169[[phase.pre.contexts]]
170type = "git"
171path = "."
172
173[[phase.pre.contexts]]
174type = "file"
175path = "capsula.toml"
176copy = true
177hash = true
178
179[[phase.pre.contexts]]
180type = "file"
181path = "Cargo.toml"
182hash = true
183
184[[phase.in.watchers]]
185type = "time"
186
187[[phase.post.contexts]]
188type = "env"
189key = "PATH"
190"#;
191
192 let config = CapsulaConfig::from_str(config_str).unwrap();
193
194 assert_eq!(config.vault.name, "capsula");
195 assert_eq!(config.vault.path, PathBuf::from(".capsula/capsula"));
196
197 assert_eq!(config.phase.pre.contexts.len(), 4);
198 assert_eq!(config.phase.pre.contexts[0].ty, "cwd");
199 assert_eq!(config.phase.pre.contexts[1].ty, "git");
200 assert_eq!(config.phase.pre.contexts[2].ty, "file");
201 assert_eq!(config.phase.pre.contexts[3].ty, "file");
202
203 assert_eq!(config.phase.in_phase.watchers.len(), 1);
204 assert!(matches!(
205 &config.phase.in_phase.watchers[0],
206 WatcherSpec::Time(_)
207 ));
208
209 assert_eq!(config.phase.post.contexts.len(), 1);
210 assert_eq!(config.phase.post.contexts[0].ty, "env");
211 }
212
213 #[test]
214 fn test_vault_config_with_explicit_path() {
215 let config_str = r#"
216[vault]
217name = "my_vault"
218path = "/custom/path/to/vault"
219
220[[phase.pre.contexts]]
221type = "cwd"
222"#;
223
224 let config = CapsulaConfig::from_str(config_str).unwrap();
225
226 assert_eq!(config.vault.name, "my_vault");
227 assert_eq!(config.vault.path, PathBuf::from("/custom/path/to/vault"));
228 }
229
230 #[test]
231 fn test_vault_config_without_path() {
232 let config_str = r#"
233[vault]
234name = "test_vault"
235
236[[phase.pre.contexts]]
237type = "cwd"
238"#;
239
240 let config = CapsulaConfig::from_str(config_str).unwrap();
241
242 assert_eq!(config.vault.name, "test_vault");
243 assert_eq!(config.vault.path, PathBuf::from(".capsula/test_vault"));
244 }
245}