1use crate::models::KnownSources;
8use anyhow::Context;
9use derive_builder::Builder;
10use serde::{Deserialize, Deserializer, Serialize};
11use std::collections::{BTreeMap, HashMap, HashSet};
12use std::path::Path;
13use tracing::{debug, warn};
14use validator::Validate;
15
16pub type SourceList = Vec<ConfigPackageSource>;
18
19#[derive(Deserialize)]
21struct CustomSourceWithoutName {
22 emoji: String,
23 shell_command: String,
24 #[serde(alias = "install")]
25 install_command: String,
26 #[serde(alias = "check")]
27 check_command: String,
28 #[serde(default)]
29 prepend_to_package_name: Option<String>,
30 #[serde(default)]
31 overrides: Option<Vec<PackageNameOverride>>,
32}
33
34fn deserialize_custom_sources<'de, D>(
36 deserializer: D,
37) -> std::result::Result<Option<SourceList>, D::Error>
38where
39 D: Deserializer<'de>,
40{
41 let map_opt: Option<BTreeMap<String, CustomSourceWithoutName>> =
43 Option::deserialize(deserializer)?;
44
45 match map_opt {
46 None => Ok(None),
47 Some(map) => {
48 let mut sources = Vec::new();
49 for (name, source_data) in map {
50 let source = ConfigPackageSource {
52 name: KnownSources::Unknown(name),
53 emoji: source_data.emoji,
54 shell_command: source_data.shell_command,
55 install_command: source_data.install_command,
56 check_command: source_data.check_command,
57 prepend_to_package_name: source_data.prepend_to_package_name,
58 overrides: source_data.overrides,
59 };
60 sources.push(source);
61 }
62 Ok(Some(sources))
63 }
64 }
65}
66
67#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
69pub struct PackageNameOverride {
70 pub package: String,
71 pub replacement: String,
72}
73
74#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
76pub struct ConfigPackageSource {
77 pub name: KnownSources,
78 pub emoji: String,
79 pub shell_command: String,
80 pub install_command: String,
81 pub check_command: String,
82 #[serde(default)]
83 pub prepend_to_package_name: Option<String>,
84 #[serde(default)]
85 pub overrides: Option<Vec<PackageNameOverride>>,
86}
87
88#[derive(Serialize, Deserialize, Clone, Debug, Builder, Validate)]
90#[builder(setter(into))]
91pub struct SantaConfig {
92 #[validate(length(min = 1, message = "At least one source must be configured"))]
93 pub sources: Vec<KnownSources>,
94 #[validate(length(min = 1, message = "At least one package should be configured"))]
95 pub packages: Vec<String>,
96 #[serde(
97 default,
98 deserialize_with = "deserialize_custom_sources",
99 skip_serializing_if = "Option::is_none"
100 )]
101 pub custom_sources: Option<SourceList>,
102
103 #[serde(skip)]
104 pub _groups: Option<HashMap<KnownSources, Vec<String>>>,
105 #[serde(skip)]
106 pub log_level: u8,
107}
108
109impl SantaConfig {
110 pub fn load_from_str(config_str: &str) -> anyhow::Result<Self> {
128 let data: SantaConfig = sickle::from_str(config_str)
129 .with_context(|| format!("Failed to parse CCL config: {config_str}"))?;
130
131 data.validate_basic()
133 .with_context(|| "Configuration validation failed")?;
134
135 Ok(data)
136 }
137
138 pub fn load_from(file: &Path) -> anyhow::Result<Self> {
148 debug!("Loading config from: {}", file.display());
149
150 if file.exists() {
151 let config_str = std::fs::read_to_string(file)
152 .with_context(|| format!("Failed to read config file: {}", file.display()))?;
153
154 let config: SantaConfig = sickle::from_str(&config_str)
155 .with_context(|| format!("Failed to parse CCL config file: {}", file.display()))?;
156
157 config
158 .validate_basic()
159 .with_context(|| "Configuration validation failed")?;
160
161 Ok(config)
162 } else {
163 warn!("Can't find config file: {}", file.display());
164 warn!("Returning error - no default config in santa-data");
165 Err(anyhow::anyhow!("Config file not found: {}", file.display()))
166 }
167 }
168
169 pub fn validate_basic(&self) -> anyhow::Result<()> {
176 if self.sources.is_empty() {
177 return Err(anyhow::anyhow!("At least one source must be configured"));
178 }
179
180 if self.packages.is_empty() {
181 warn!("No packages configured - santa will not track any packages");
182 }
183
184 let mut seen_sources = HashSet::new();
186 for source in &self.sources {
187 if !seen_sources.insert(source) {
188 return Err(anyhow::anyhow!("Duplicate source found: {:?}", source));
189 }
190 }
191
192 let mut seen_packages = HashSet::new();
194 for package in &self.packages {
195 if !seen_packages.insert(package) {
196 warn!("Duplicate package found: {}", package);
197 }
198 }
199
200 Ok(())
201 }
202}
203
204pub struct ConfigLoader;
206
207impl ConfigLoader {
208 pub fn load_from_path(path: &Path) -> anyhow::Result<SantaConfig> {
210 SantaConfig::load_from(path)
211 }
212
213 pub fn load_from_str(contents: &str) -> anyhow::Result<SantaConfig> {
215 SantaConfig::load_from_str(contents)
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use crate::KnownSources;
223
224 #[test]
225 fn test_validate_basic_empty_sources() {
226 let config = SantaConfig {
227 sources: vec![],
228 packages: vec!["git".to_string()],
229 custom_sources: None,
230 _groups: None,
231 log_level: 0,
232 };
233
234 let result = config.validate_basic();
235 assert!(result.is_err());
236 assert!(result
237 .unwrap_err()
238 .to_string()
239 .contains("At least one source must be configured"));
240 }
241
242 #[test]
243 fn test_validate_basic_duplicate_sources() {
244 let config = SantaConfig {
245 sources: vec![KnownSources::Brew, KnownSources::Brew],
246 packages: vec!["git".to_string()],
247 custom_sources: None,
248 _groups: None,
249 log_level: 0,
250 };
251
252 let result = config.validate_basic();
253 assert!(result.is_err());
254 assert!(result
255 .unwrap_err()
256 .to_string()
257 .contains("Duplicate source found"));
258 }
259
260 #[test]
261 fn test_validate_basic_valid_config() {
262 let config = SantaConfig {
263 sources: vec![KnownSources::Brew, KnownSources::Cargo],
264 packages: vec!["git".to_string(), "rust".to_string()],
265 custom_sources: None,
266 _groups: None,
267 log_level: 0,
268 };
269
270 let result = config.validate_basic();
271 assert!(result.is_ok());
272 }
273
274 #[test]
275 fn test_load_from_str_valid_ccl() {
276 let ccl = r#"
277sources =
278 = brew
279 = cargo
280packages =
281 = git
282 = rust
283 "#;
284
285 let result = SantaConfig::load_from_str(ccl);
286 assert!(result.is_ok());
287
288 let config = result.unwrap();
289 assert_eq!(config.sources.len(), 2);
290 assert_eq!(config.packages.len(), 2);
291 assert!(config.sources.contains(&KnownSources::Brew));
292 assert!(config.sources.contains(&KnownSources::Cargo));
293 }
294
295 #[test]
296 fn test_load_from_str_invalid_ccl() {
297 let ccl = "invalid = yaml = content = ["; let result = SantaConfig::load_from_str(ccl);
300 assert!(result.is_err());
301 assert!(result
302 .unwrap_err()
303 .to_string()
304 .contains("Failed to parse CCL config"));
305 }
306
307 #[test]
308 fn test_load_from_str_validation_failure() {
309 let ccl = r#"
310sources =
311packages =
312 = git
313 "#;
314
315 let result = SantaConfig::load_from_str(ccl);
316 assert!(result.is_err());
317 let error_msg = result.unwrap_err().to_string();
319 eprintln!("Actual error message: {}", error_msg);
320 assert!(
321 error_msg.contains("Configuration validation failed")
322 || error_msg.contains("At least one source must be configured")
323 || error_msg.contains("Failed to parse CCL config")
324 );
325 }
326
327 #[test]
328 fn test_config_loader_from_str() {
329 let ccl = r#"
330sources =
331 = cargo
332packages =
333 = ripgrep
334 "#;
335
336 let result = ConfigLoader::load_from_str(ccl);
337 assert!(result.is_ok());
338 }
339}