Skip to main content

santa_data/
config.rs

1//! Configuration loading and management for Santa Package Manager
2//!
3//! This module provides the core configuration structures and loading logic
4//! for Santa. It handles CCL parsing, validation, and provides a clean API
5//! for configuration access.
6
7use 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
16/// Type alias for lists of package sources
17pub type SourceList = Vec<ConfigPackageSource>;
18
19/// Helper struct to deserialize custom source with name from HashMap key
20#[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
34/// Custom deserializer for custom_sources that converts HashMap to Vec and sets names
35fn deserialize_custom_sources<'de, D>(
36    deserializer: D,
37) -> std::result::Result<Option<SourceList>, D::Error>
38where
39    D: Deserializer<'de>,
40{
41    // Use BTreeMap to maintain sorted order by key name
42    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                // Create ConfigPackageSource with name from HashMap key
51                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/// Represents a package name override (renaming packages for specific sources)
68#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
69pub struct PackageNameOverride {
70    pub package: String,
71    pub replacement: String,
72}
73
74/// Represents a custom package source configuration
75#[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/// Main configuration structure for Santa
89#[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    /// Load configuration from a string (CCL format)
111    ///
112    /// # Example
113    /// ```
114    /// use santa_data::config::SantaConfig;
115    ///
116    /// let ccl = r#"
117    /// sources =
118    ///   = brew
119    ///   = cargo
120    /// packages =
121    ///   = git
122    /// "#;
123    ///
124    /// let config = SantaConfig::load_from_str(ccl).unwrap();
125    /// assert_eq!(config.sources.len(), 2);
126    /// ```
127    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        // Validate the configuration
132        data.validate_basic()
133            .with_context(|| "Configuration validation failed")?;
134
135        Ok(data)
136    }
137
138    /// Load configuration from a file path
139    ///
140    /// # Example
141    /// ```no_run
142    /// use santa_data::config::SantaConfig;
143    /// use std::path::Path;
144    ///
145    /// let config = SantaConfig::load_from(Path::new("santa.ccl")).unwrap();
146    /// ```
147    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    /// Basic configuration validation
170    ///
171    /// Checks for:
172    /// - At least one source is configured
173    /// - No duplicate sources
174    /// - Warns about duplicate packages
175    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        // Check for duplicate sources
185        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        // Check for duplicate packages
193        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
204/// Configuration loader - provides static methods for loading configurations
205pub struct ConfigLoader;
206
207impl ConfigLoader {
208    /// Load configuration from a file path
209    pub fn load_from_path(path: &Path) -> anyhow::Result<SantaConfig> {
210        SantaConfig::load_from(path)
211    }
212
213    /// Load configuration from a string
214    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 = ["; // Malformed CCL
298
299        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        // The error should be about missing sources, which is caught during validation
318        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}