1use capsula_core::error::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 #[error("IO error: {0}")]
11 Io(#[from] std::io::Error),
12 #[error("Core error: {0}")]
13 Core(#[from] capsula_core::error::CoreError),
14}
15
16pub type ConfigResult<T> = Result<T, ConfigError>;
17
18#[derive(Deserialize, Debug, Clone)]
19pub struct CapsulaConfig {
20 pub vault: VaultConfig,
21 pub phase: PhaseConfig,
22}
23
24#[derive(Debug, Clone)]
25pub struct VaultConfig {
26 pub name: String,
27 pub path: PathBuf,
28}
29
30impl<'de> Deserialize<'de> for VaultConfig {
31 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
32 where
33 D: Deserializer<'de>,
34 {
35 #[derive(Deserialize)]
36 struct VaultConfigHelper {
37 name: String,
38 path: Option<PathBuf>,
39 }
40
41 let helper = VaultConfigHelper::deserialize(deserializer)?;
42 let path = helper
43 .path
44 .unwrap_or_else(|| PathBuf::from(format!(".capsula/{}", helper.name)));
45
46 Ok(VaultConfig {
47 name: helper.name,
48 path,
49 })
50 }
51}
52
53#[derive(Deserialize, Debug, Clone, Default)]
54pub struct PhaseConfig {
55 #[serde(default)]
56 pub pre: PrePhaseConfig,
57 #[serde(rename = "in", default)]
58 pub in_phase: InPhaseConfig,
59 #[serde(default)]
60 pub post: PostPhaseConfig,
61}
62
63#[derive(Deserialize, Debug, Clone, Default)]
65pub struct ContextPhaseConfig {
66 #[serde(default)]
67 pub contexts: Vec<ContextEnvelope>,
68}
69
70#[derive(Deserialize, Debug, Clone, Default)]
72pub struct WatcherPhaseConfig {
73 #[serde(default)]
74 pub watchers: Vec<WatcherSpec>,
75}
76
77pub type PrePhaseConfig = ContextPhaseConfig;
79pub type PostPhaseConfig = ContextPhaseConfig;
80pub type InPhaseConfig = WatcherPhaseConfig;
81
82#[derive(Deserialize, Debug, Clone)]
83pub struct ContextEnvelope {
84 #[serde(rename = "type")]
85 pub ty: String,
86 #[serde(flatten)]
87 pub rest: serde_json::Value,
88}
89
90#[derive(Deserialize, Debug, Clone)]
91#[serde(tag = "type", rename_all = "lowercase")]
92pub enum WatcherSpec {
93 Time(TimeWatcherSpec),
94}
95
96#[derive(Deserialize, Debug, Clone, Default)]
97pub struct TimeWatcherSpec {}
98
99impl CapsulaConfig {
100 pub fn from_str(content: &str) -> ConfigResult<Self> {
101 Ok(toml::from_str(content)?)
102 }
103
104 pub fn from_file(path: impl AsRef<std::path::Path>) -> ConfigResult<Self> {
105 let content = std::fs::read_to_string(path)?;
106 Self::from_str(&content)
107 }
108}
109
110pub fn build_contexts(
112 phase: &ContextPhaseConfig,
113 project_root: &Path,
114 registry: &capsula_registry::ContextRegistry,
115) -> CoreResult<Vec<Box<dyn capsula_core::context::ContextErased>>> {
116 phase
117 .contexts
118 .iter()
119 .map(|envelope| registry.create_context(&envelope.ty, &envelope.rest, project_root))
120 .collect()
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
128 fn test_parse_example_config() {
129 let config_str = r#"
130[vault]
131name = "capsula"
132
133[[phase.pre.contexts]]
134type = "cwd"
135
136[[phase.pre.contexts]]
137type = "git"
138path = "."
139
140[[phase.pre.contexts]]
141type = "file"
142path = "capsula.toml"
143copy = true
144hash = true
145
146[[phase.pre.contexts]]
147type = "file"
148path = "Cargo.toml"
149hash = true
150
151[[phase.in.watchers]]
152type = "time"
153
154[[phase.post.contexts]]
155type = "env"
156key = "PATH"
157"#;
158
159 let config = CapsulaConfig::from_str(config_str).unwrap();
160
161 assert_eq!(config.vault.name, "capsula");
162 assert_eq!(config.vault.path, PathBuf::from(".capsula/capsula"));
163
164 assert_eq!(config.phase.pre.contexts.len(), 4);
165 assert_eq!(config.phase.pre.contexts[0].ty, "cwd");
166 assert_eq!(config.phase.pre.contexts[1].ty, "git");
167 assert_eq!(config.phase.pre.contexts[2].ty, "file");
168 assert_eq!(config.phase.pre.contexts[3].ty, "file");
169
170 assert_eq!(config.phase.in_phase.watchers.len(), 1);
171 assert!(matches!(
172 &config.phase.in_phase.watchers[0],
173 WatcherSpec::Time(_)
174 ));
175
176 assert_eq!(config.phase.post.contexts.len(), 1);
177 assert_eq!(config.phase.post.contexts[0].ty, "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[[phase.pre.contexts]]
188type = "cwd"
189"#;
190
191 let config = CapsulaConfig::from_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[[phase.pre.contexts]]
204type = "cwd"
205"#;
206
207 let config = CapsulaConfig::from_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}