1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use super::source::default_sync_interval;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase", deny_unknown_fields)]
10pub struct SyncConfig {
11 #[serde(default)]
12 pub auto_push: bool,
13 #[serde(default)]
14 pub auto_pull: bool,
15 #[serde(default = "default_sync_interval")]
16 pub interval: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase", deny_unknown_fields)]
21pub struct NotifyConfig {
22 #[serde(default)]
23 pub drift: bool,
24 #[serde(default)]
25 pub method: NotifyMethod,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub webhook_url: Option<String>,
28}
29
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31pub enum NotifyMethod {
32 #[default]
33 Desktop,
34 Stdout,
35 Webhook,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase", deny_unknown_fields)]
40pub struct SecretsConfig {
41 #[serde(default = "default_secrets_backend")]
42 pub backend: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub sops: Option<SopsConfig>,
45 #[serde(default)]
46 pub integrations: Vec<SecretIntegration>,
47}
48
49fn default_secrets_backend() -> String {
50 "sops".to_string()
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase", deny_unknown_fields)]
55pub struct SopsConfig {
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub age_key: Option<PathBuf>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct SecretIntegration {
66 pub name: String,
67 #[serde(flatten)]
68 pub extra: HashMap<String, serde_yaml::Value>,
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74 use crate::config::source::default_sync_interval;
75
76 #[test]
77 fn sync_config_defaults_auto_push_to_false_when_omitted() {
78 let parsed: SyncConfig = serde_yaml::from_str("interval: 1h").unwrap();
79 assert!(!parsed.auto_push);
80 assert!(!parsed.auto_pull);
81 assert_eq!(parsed.interval, "1h");
82 }
83
84 #[test]
85 fn sync_config_uses_default_interval_when_all_omitted() {
86 let parsed: SyncConfig = serde_yaml::from_str("{}").unwrap();
87 assert_eq!(parsed.interval, default_sync_interval());
88 }
89
90 #[test]
91 fn sync_config_yaml_uses_camelcase_field_names() {
92 let v = SyncConfig {
93 auto_push: true,
94 auto_pull: false,
95 interval: "5m".into(),
96 };
97 let yaml = serde_yaml::to_string(&v).unwrap();
98 assert!(
99 yaml.contains("autoPush: true"),
100 "yaml missing autoPush: {yaml}"
101 );
102 assert!(
103 yaml.contains("autoPull: false"),
104 "yaml missing autoPull: {yaml}"
105 );
106 assert!(
107 yaml.contains("interval: 5m"),
108 "yaml missing interval: {yaml}"
109 );
110 assert!(
111 !yaml.contains("auto_push"),
112 "yaml leaked snake_case: {yaml}"
113 );
114 assert!(
115 !yaml.contains("auto_pull"),
116 "yaml leaked snake_case: {yaml}"
117 );
118
119 let parsed: SyncConfig = serde_yaml::from_str(&yaml).unwrap();
120 assert!(parsed.auto_push);
121 assert!(!parsed.auto_pull);
122 assert_eq!(parsed.interval, "5m");
123 }
124
125 #[test]
126 fn notify_config_defaults_drift_to_false() {
127 let parsed: NotifyConfig = serde_yaml::from_str("{}").unwrap();
128 assert!(!parsed.drift);
129 }
130
131 #[test]
132 fn notify_config_defaults_method_to_desktop() {
133 let parsed: NotifyConfig = serde_yaml::from_str("{}").unwrap();
134 assert!(matches!(parsed.method, NotifyMethod::Desktop));
135 }
136
137 #[test]
138 fn notify_config_omits_webhook_url() {
139 let parsed: NotifyConfig = serde_yaml::from_str("{}").unwrap();
140 assert!(parsed.webhook_url.is_none());
141
142 let v = NotifyConfig {
143 drift: false,
144 method: NotifyMethod::Desktop,
145 webhook_url: None,
146 };
147 let yaml = serde_yaml::to_string(&v).unwrap();
148 assert!(
149 !yaml.contains("webhookUrl"),
150 "yaml should skip None webhookUrl: {yaml}"
151 );
152 assert!(
153 !yaml.contains("webhook_url"),
154 "yaml should skip None webhook_url: {yaml}"
155 );
156 }
157
158 #[test]
159 fn notify_config_yaml_uses_camelcase_field_names() {
160 let v = NotifyConfig {
161 drift: true,
162 method: NotifyMethod::Webhook,
163 webhook_url: Some("https://example.com/hook".into()),
164 };
165 let yaml = serde_yaml::to_string(&v).unwrap();
166 assert!(yaml.contains("drift: true"), "yaml missing drift: {yaml}");
167 assert!(
168 yaml.contains("method: Webhook"),
169 "yaml missing method: {yaml}"
170 );
171 assert!(
172 yaml.contains("webhookUrl: https://example.com/hook"),
173 "yaml missing webhookUrl: {yaml}"
174 );
175 assert!(
176 !yaml.contains("webhook_url"),
177 "yaml leaked snake_case: {yaml}"
178 );
179
180 let parsed: NotifyConfig = serde_yaml::from_str(&yaml).unwrap();
181 assert!(parsed.drift);
182 assert!(matches!(parsed.method, NotifyMethod::Webhook));
183 assert_eq!(
184 parsed.webhook_url.as_deref(),
185 Some("https://example.com/hook")
186 );
187 }
188
189 #[test]
190 fn notify_method_default_is_desktop() {
191 assert!(matches!(NotifyMethod::default(), NotifyMethod::Desktop));
192 }
193
194 #[test]
195 fn notify_method_serializes_as_pascalcase_default() {
196 let yaml = serde_yaml::to_string(&NotifyMethod::Desktop).unwrap();
197 assert_eq!(yaml.trim(), "Desktop");
198 }
199
200 #[test]
201 fn notify_method_deserializes_each_variant() {
202 let cases = [
203 ("Desktop", NotifyMethod::Desktop),
204 ("Stdout", NotifyMethod::Stdout),
205 ("Webhook", NotifyMethod::Webhook),
206 ];
207 for (input, expected) in cases {
208 let parsed: NotifyMethod = serde_yaml::from_str(input).unwrap();
209 match (&parsed, &expected) {
210 (NotifyMethod::Desktop, NotifyMethod::Desktop)
211 | (NotifyMethod::Stdout, NotifyMethod::Stdout)
212 | (NotifyMethod::Webhook, NotifyMethod::Webhook) => {}
213 _ => panic!("expected {expected:?} for input {input}, got {parsed:?}"),
214 }
215 }
216 }
217
218 #[test]
219 fn secrets_config_defaults_backend_to_sops() {
220 let parsed: SecretsConfig = serde_yaml::from_str("{}").unwrap();
221 assert_eq!(parsed.backend, "sops");
222 }
223
224 #[test]
225 fn secrets_config_defaults_integrations_to_empty() {
226 let parsed: SecretsConfig = serde_yaml::from_str("{}").unwrap();
227 assert!(parsed.integrations.is_empty());
228 assert!(parsed.sops.is_none());
229 }
230
231 #[test]
232 fn sops_config_yaml_uses_camelcase_field_names() {
233 let v = SopsConfig {
234 age_key: Some(PathBuf::from("/etc/cfgd/age.key")),
235 };
236 let yaml = serde_yaml::to_string(&v).unwrap();
237 assert!(
238 yaml.contains("ageKey: /etc/cfgd/age.key"),
239 "yaml missing ageKey: {yaml}"
240 );
241 assert!(!yaml.contains("age_key"), "yaml leaked snake_case: {yaml}");
242
243 let parsed: SopsConfig = serde_yaml::from_str(&yaml).unwrap();
244 assert_eq!(
245 parsed.age_key.as_deref(),
246 Some(PathBuf::from("/etc/cfgd/age.key").as_path())
247 );
248 }
249
250 #[test]
251 fn sops_config_omits_age_key_when_none() {
252 let v = SopsConfig { age_key: None };
253 let yaml = serde_yaml::to_string(&v).unwrap();
254 assert!(
255 !yaml.contains("ageKey"),
256 "yaml should skip None ageKey: {yaml}"
257 );
258 assert!(
259 !yaml.contains("age_key"),
260 "yaml should skip None age_key: {yaml}"
261 );
262 }
263
264 #[test]
265 fn secret_integration_flattens_extra_fields_into_top_level_yaml() {
266 let mut extra: HashMap<String, serde_yaml::Value> = HashMap::new();
267 extra.insert(
268 "vault".to_string(),
269 serde_yaml::Value::String("Personal".to_string()),
270 );
271 extra.insert(
272 "item".to_string(),
273 serde_yaml::Value::String("GitHub Token".to_string()),
274 );
275 let v = SecretIntegration {
276 name: "1password".to_string(),
277 extra,
278 };
279
280 let yaml = serde_yaml::to_string(&v).unwrap();
281 assert!(
282 yaml.contains("name: 1password"),
283 "yaml missing name: {yaml}"
284 );
285 assert!(
286 yaml.contains("vault: Personal"),
287 "yaml missing vault: {yaml}"
288 );
289 assert!(
290 yaml.contains("item: GitHub Token"),
291 "yaml missing item: {yaml}"
292 );
293 assert!(
294 !yaml.contains("extra:"),
295 "yaml should not have nested extra block: {yaml}"
296 );
297
298 let parsed: SecretIntegration = serde_yaml::from_str(&yaml).unwrap();
299 assert_eq!(parsed.name, "1password");
300 assert_eq!(parsed.extra.len(), 2);
301 assert_eq!(
302 parsed.extra.get("vault").and_then(|v| v.as_str()),
303 Some("Personal")
304 );
305 assert_eq!(
306 parsed.extra.get("item").and_then(|v| v.as_str()),
307 Some("GitHub Token")
308 );
309 }
310
311 #[test]
312 fn secret_integration_collects_unknown_fields_into_extra() {
313 let yaml = "name: foo\narbitrary: bar\nanother: 123\n";
314 let parsed: SecretIntegration = serde_yaml::from_str(yaml).unwrap();
315 assert_eq!(parsed.name, "foo");
316 assert_eq!(parsed.extra.len(), 2);
317 assert_eq!(
318 parsed.extra.get("arbitrary").and_then(|v| v.as_str()),
319 Some("bar")
320 );
321 assert_eq!(
322 parsed.extra.get("another").and_then(|v| v.as_i64()),
323 Some(123)
324 );
325 }
326}