santa_data/
lib.rs

1//! Santa Data - Data models and CCL parser for Santa Package Manager
2//!
3//! This crate provides:
4//! - Core data models (Platform, KnownSources, PackageData, etc.)
5//! - CCL schema definitions (PackageDefinition, SourceDefinition, etc.)
6//! - CCL parser that handles both simple and complex formats
7//!
8//! The parser works around limitations in serde_ccl 0.1.1.
9
10use anyhow::{Context, Result};
11use serde::de::DeserializeOwned;
12use serde_json::Value;
13use std::collections::HashMap;
14
15pub mod models;
16mod parser;
17pub mod schemas;
18
19pub use models::*;
20pub use parser::{parse_ccl, CclValue};
21pub use schemas::*;
22
23/// Parse CCL string into a HashMap where values can be either arrays or objects
24///
25/// This function works around serde_ccl's limitation where it returns strings
26/// for nested structures instead of properly parsed values.
27///
28/// # Examples
29///
30/// ```
31/// use santa_data::parse_to_hashmap;
32/// use serde_json::Value;
33///
34/// let ccl = r#"
35/// simple_pkg =
36///   = brew
37///   = scoop
38///
39/// complex_pkg =
40///   _sources =
41///     = brew
42///   brew = gh
43/// "#;
44///
45/// let result = parse_to_hashmap(ccl).unwrap();
46/// assert!(result.contains_key("simple_pkg"));
47/// assert!(result.contains_key("complex_pkg"));
48/// ```
49pub fn parse_to_hashmap(ccl_content: &str) -> Result<HashMap<String, Value>> {
50    // First try serde_ccl for the outer structure
51    // serde_ccl returns HashMap<String, String> where the values are
52    // the raw CCL content as strings
53    let raw: HashMap<String, String> =
54        serde_ccl::from_str(ccl_content).context("Failed to parse CCL with serde_ccl")?;
55
56    let mut result = HashMap::new();
57
58    for (key, value_str) in raw.into_iter() {
59        // Parse the string value as CCL
60        let parsed_value = parse_value_string(&value_str)?;
61        result.insert(key, parsed_value);
62    }
63
64    Ok(result)
65}
66
67/// Parse a CCL value string (from serde_ccl's string output) into a proper JSON Value
68fn parse_value_string(s: &str) -> Result<Value> {
69    let trimmed = s.trim();
70
71    // Check if it's a simple array (starts with '=')
72    if trimmed.starts_with('=') {
73        return parse_simple_array(trimmed);
74    }
75
76    // Check if it contains field assignments (complex object)
77    if trimmed.contains('=') {
78        return parse_complex_object(trimmed);
79    }
80
81    // Fallback: treat as string
82    Ok(Value::String(s.to_string()))
83}
84
85/// Parse simple array format: "= brew\n  = scoop"
86fn parse_simple_array(s: &str) -> Result<Value> {
87    let items: Vec<String> = s
88        .lines()
89        .filter_map(|line| {
90            let trimmed = line.trim();
91            if let Some(stripped) = trimmed.strip_prefix('=') {
92                let value = stripped.trim();
93                if !value.is_empty() {
94                    Some(value.to_string())
95                } else {
96                    None
97                }
98            } else {
99                None
100            }
101        })
102        .collect();
103
104    Ok(Value::Array(items.into_iter().map(Value::String).collect()))
105}
106
107/// Parse complex object format: "_sources =\n  = brew\nbrew = gh"
108fn parse_complex_object(s: &str) -> Result<Value> {
109    let mut obj = serde_json::Map::new();
110    let mut current_key: Option<String> = None;
111    let mut current_lines: Vec<String> = Vec::new();
112    let mut current_indent = 0;
113
114    for line in s.lines() {
115        let line_indent = line.len() - line.trim_start().len();
116        let trimmed = line.trim();
117
118        if trimmed.is_empty() {
119            continue;
120        }
121
122        // Check if this line starts a new field (has '=' and is at base indent or less)
123        if let Some(eq_pos) = trimmed.find('=') {
124            let is_array_element = trimmed.starts_with('=');
125
126            // If this is an array element and we're collecting, add it
127            if is_array_element && current_key.is_some() && line_indent > current_indent {
128                current_lines.push(line.to_string());
129                continue;
130            }
131
132            // This is a new field - process previous field if any
133            if let Some(key) = current_key.take() {
134                let value_str = current_lines.join("\n");
135                let value = parse_value_string(&value_str)?;
136                obj.insert(key, value);
137                current_lines.clear();
138            }
139
140            if is_array_element {
141                // Start collecting array elements without a key name
142                // This shouldn't happen in well-formed CCL but handle it
143                current_lines.push(line.to_string());
144                continue;
145            }
146
147            // Extract the new field name
148            let field_name = trimmed[..eq_pos].trim();
149            let field_value = trimmed[eq_pos + 1..].trim();
150
151            current_indent = line_indent;
152
153            if !field_value.is_empty() {
154                // Value on same line
155                obj.insert(
156                    field_name.to_string(),
157                    Value::String(field_value.to_string()),
158                );
159            } else {
160                // Value on next lines
161                current_key = Some(field_name.to_string());
162            }
163        } else if current_key.is_some() {
164            // Continuation of current field value
165            current_lines.push(line.to_string());
166        }
167    }
168
169    // Process any remaining field
170    if let Some(key) = current_key {
171        let value_str = current_lines.join("\n");
172        let value = parse_value_string(&value_str)?;
173        obj.insert(key, value);
174    }
175
176    Ok(Value::Object(obj))
177}
178
179/// Parse CCL string and deserialize into a specific type
180///
181/// # Examples
182///
183/// ```
184/// use santa_data::parse_ccl_to;
185/// use serde::Deserialize;
186/// use std::collections::HashMap;
187///
188/// #[derive(Deserialize)]
189/// struct Package {
190///     #[serde(rename = "_sources")]
191///     sources: Option<Vec<String>>,
192/// }
193///
194/// let ccl = r#"
195/// bat =
196///   _sources =
197///     = brew
198///     = scoop
199/// "#;
200///
201/// let packages: HashMap<String, Package> = parse_ccl_to(ccl).unwrap();
202/// assert!(packages.contains_key("bat"));
203/// ```
204pub fn parse_ccl_to<T: DeserializeOwned>(ccl_content: &str) -> Result<T> {
205    let hashmap = parse_to_hashmap(ccl_content)?;
206    let value = serde_json::to_value(hashmap)?;
207    serde_json::from_value(value).context("Failed to deserialize parsed CCL")
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_parse_simple_array() {
216        let ccl = r#"
217test_pkg =
218  = brew
219  = scoop
220  = pacman
221"#;
222        let result = parse_to_hashmap(ccl).unwrap();
223
224        assert!(result.contains_key("test_pkg"));
225        let value = &result["test_pkg"];
226        assert!(value.is_array());
227
228        let arr = value.as_array().unwrap();
229        assert_eq!(arr.len(), 3);
230        assert_eq!(arr[0].as_str().unwrap(), "brew");
231        assert_eq!(arr[1].as_str().unwrap(), "scoop");
232        assert_eq!(arr[2].as_str().unwrap(), "pacman");
233    }
234
235    #[test]
236    fn test_parse_complex_object() {
237        let ccl = r#"
238test_pkg =
239  _sources =
240    = brew
241    = scoop
242  brew = gh
243"#;
244        let result = parse_to_hashmap(ccl).unwrap();
245
246        assert!(result.contains_key("test_pkg"));
247        let value = &result["test_pkg"];
248        println!("Parsed value: {:#?}", value);
249        assert!(value.is_object());
250
251        let obj = value.as_object().unwrap();
252        println!("Object keys: {:?}", obj.keys().collect::<Vec<_>>());
253        assert!(obj.contains_key("_sources"));
254        assert!(obj.contains_key("brew"));
255
256        let sources_value = &obj["_sources"];
257        println!("_sources value: {:#?}", sources_value);
258        let sources = sources_value.as_array().unwrap();
259        assert_eq!(sources.len(), 2);
260
261        let brew_override = obj["brew"].as_str().unwrap();
262        assert_eq!(brew_override, "gh");
263    }
264
265    #[test]
266    fn test_parse_multiple_packages() {
267        let ccl = r#"
268simple =
269  = brew
270  = scoop
271
272complex =
273  _sources =
274    = pacman
275  _platforms =
276    = linux
277"#;
278        let result = parse_to_hashmap(ccl).unwrap();
279
280        assert_eq!(result.len(), 2);
281        assert!(result["simple"].is_array());
282        assert!(result["complex"].is_object());
283    }
284}