Skip to main content

stout_bundle/
parser.rs

1//! Brewfile parser
2//!
3//! Parses Homebrew Brewfile format with two strategies:
4//! 1. Ruby parser (full compatibility) - shells out to Ruby
5//! 2. Rust parser (fallback) - handles common cases
6
7use crate::error::{Error, Result};
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10use std::process::Command;
11use tracing::{debug, warn};
12
13/// Parsed Brewfile contents
14#[derive(Debug, Default, Clone, Serialize, Deserialize)]
15pub struct Brewfile {
16    #[serde(default)]
17    pub taps: Vec<TapEntry>,
18
19    #[serde(default)]
20    pub brews: Vec<BrewEntry>,
21
22    #[serde(default)]
23    pub casks: Vec<CaskEntry>,
24
25    #[serde(default)]
26    pub mas: Vec<MasEntry>,
27
28    #[serde(default)]
29    pub whalebrew: Vec<WhalebrewEntry>,
30
31    #[serde(default)]
32    pub vscode: Vec<VscodeEntry>,
33}
34
35/// Tap entry (custom repository)
36#[derive(Debug, Default, Clone, Serialize, Deserialize)]
37pub struct TapEntry {
38    pub name: String,
39    #[serde(default)]
40    pub url: Option<String>,
41    #[serde(default)]
42    pub force_auto_update: Option<bool>,
43}
44
45/// Brew entry (formula)
46#[derive(Debug, Default, Clone, Serialize, Deserialize)]
47pub struct BrewEntry {
48    pub name: String,
49    #[serde(default)]
50    pub args: Vec<String>,
51    #[serde(default)]
52    pub link: Option<bool>,
53    #[serde(default)]
54    pub conflicts_with: Vec<String>,
55    #[serde(default)]
56    pub restart_service: Option<RestartService>,
57    #[serde(default)]
58    pub start_service: Option<bool>,
59}
60
61/// Restart service option
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(untagged)]
64pub enum RestartService {
65    Bool(bool),
66    Symbol(String), // :changed, :immediately
67}
68
69/// Cask entry (application)
70#[derive(Debug, Default, Clone, Serialize, Deserialize)]
71pub struct CaskEntry {
72    pub name: String,
73    #[serde(default)]
74    pub args: CaskArgs,
75    #[serde(default)]
76    pub greedy: bool,
77}
78
79/// Cask installation arguments
80#[derive(Debug, Default, Clone, Serialize, Deserialize)]
81pub struct CaskArgs {
82    #[serde(default)]
83    pub appdir: Option<String>,
84    #[serde(default)]
85    pub force: bool,
86    #[serde(default)]
87    pub require_sha: bool,
88    #[serde(default)]
89    pub no_quarantine: bool,
90}
91
92/// Mac App Store entry
93#[derive(Debug, Default, Clone, Serialize, Deserialize)]
94pub struct MasEntry {
95    pub name: String,
96    pub id: u64,
97}
98
99/// Whalebrew entry (Docker-based CLI tools)
100#[derive(Debug, Default, Clone, Serialize, Deserialize)]
101pub struct WhalebrewEntry {
102    pub name: String,
103}
104
105/// VS Code extension entry
106#[derive(Debug, Default, Clone, Serialize, Deserialize)]
107pub struct VscodeEntry {
108    pub name: String,
109}
110
111impl Brewfile {
112    /// Parse a Brewfile from the given path
113    pub fn parse(path: &Path) -> Result<Self> {
114        if !path.exists() {
115            return Err(Error::BrewfileNotFound(path.display().to_string()));
116        }
117
118        // Try Ruby parser first (full compatibility)
119        match Self::parse_with_ruby(path) {
120            Ok(bf) => {
121                debug!("Parsed Brewfile with Ruby parser");
122                return Ok(bf);
123            }
124            Err(e) => {
125                debug!("Ruby parser failed: {}, trying Rust parser", e);
126            }
127        }
128
129        // Fall back to Rust parser
130        warn!("Ruby not available, using basic Rust parser (some options may be ignored)");
131        Self::parse_with_rust(path)
132    }
133
134    /// Parse Brewfile using Ruby for full DSL compatibility
135    fn parse_with_ruby(path: &Path) -> Result<Self> {
136        const RUBY_SCRIPT: &str = r#"
137require 'json'
138
139$e = {
140  taps: [],
141  brews: [],
142  casks: [],
143  mas: [],
144  whalebrew: [],
145  vscode: []
146}
147
148def tap(name, url: nil, force_auto_update: nil)
149  entry = { name: name }
150  entry[:url] = url if url
151  entry[:force_auto_update] = force_auto_update unless force_auto_update.nil?
152  $e[:taps] << entry
153end
154
155def brew(name, args: [], link: nil, conflicts_with: [], restart_service: nil, start_service: nil)
156  entry = { name: name }
157  entry[:args] = args unless args.empty?
158  entry[:link] = link unless link.nil?
159  entry[:conflicts_with] = conflicts_with unless conflicts_with.empty?
160  entry[:restart_service] = restart_service.to_s if restart_service
161  entry[:start_service] = start_service unless start_service.nil?
162  $e[:brews] << entry
163end
164
165def cask(name, args: {}, greedy: false)
166  entry = { name: name }
167  entry[:args] = args unless args.empty?
168  entry[:greedy] = greedy if greedy
169  $e[:casks] << entry
170end
171
172def mas(name, id:)
173  $e[:mas] << { name: name, id: id }
174end
175
176def whalebrew(name)
177  $e[:whalebrew] << { name: name }
178end
179
180def vscode(name)
181  $e[:vscode] << { name: name }
182end
183
184# Ignore cask_args (global cask settings)
185def cask_args(args = {})
186end
187
188begin
189  eval(File.read(ARGV[0]))
190  puts JSON.generate($e)
191rescue => e
192  STDERR.puts "Error: #{e.message}"
193  exit 1
194end
195"#;
196
197        let output = Command::new("ruby")
198            .arg("-e")
199            .arg(RUBY_SCRIPT)
200            .arg(path)
201            .output()
202            .map_err(|e| Error::RubyError(format!("Failed to execute Ruby: {}", e)))?;
203
204        if !output.status.success() {
205            let stderr = String::from_utf8_lossy(&output.stderr);
206            return Err(Error::RubyError(stderr.to_string()));
207        }
208
209        let json_str = String::from_utf8_lossy(&output.stdout);
210        let brewfile: Brewfile = serde_json::from_str(&json_str)
211            .map_err(|e| Error::ParseError(format!("Failed to parse Ruby output: {}", e)))?;
212
213        Ok(brewfile)
214    }
215
216    /// Parse Brewfile using Rust (handles common cases)
217    fn parse_with_rust(path: &Path) -> Result<Self> {
218        let content = std::fs::read_to_string(path)?;
219        let mut brewfile = Brewfile::default();
220
221        for line in content.lines() {
222            let line = line.trim();
223
224            // Skip empty lines and comments
225            if line.is_empty() || line.starts_with('#') {
226                continue;
227            }
228
229            // Parse tap entries
230            if let Some(rest) = line.strip_prefix("tap ") {
231                if let Some(name) = extract_quoted_string(rest) {
232                    brewfile.taps.push(TapEntry {
233                        name,
234                        ..Default::default()
235                    });
236                }
237                continue;
238            }
239
240            // Parse brew entries
241            if let Some(rest) = line.strip_prefix("brew ") {
242                if let Some(name) = extract_quoted_string(rest) {
243                    brewfile.brews.push(BrewEntry {
244                        name,
245                        ..Default::default()
246                    });
247                }
248                continue;
249            }
250
251            // Parse cask entries
252            if let Some(rest) = line.strip_prefix("cask ") {
253                if let Some(name) = extract_quoted_string(rest) {
254                    brewfile.casks.push(CaskEntry {
255                        name,
256                        ..Default::default()
257                    });
258                }
259                continue;
260            }
261
262            // Parse mas entries
263            if let Some(rest) = line.strip_prefix("mas ") {
264                if let Some((name, id)) = parse_mas_entry(rest) {
265                    brewfile.mas.push(MasEntry { name, id });
266                }
267                continue;
268            }
269
270            // Parse whalebrew entries
271            if let Some(rest) = line.strip_prefix("whalebrew ") {
272                if let Some(name) = extract_quoted_string(rest) {
273                    brewfile.whalebrew.push(WhalebrewEntry { name });
274                }
275                continue;
276            }
277
278            // Parse vscode entries
279            if let Some(rest) = line.strip_prefix("vscode ") {
280                if let Some(name) = extract_quoted_string(rest) {
281                    brewfile.vscode.push(VscodeEntry { name });
282                }
283                continue;
284            }
285        }
286
287        Ok(brewfile)
288    }
289
290    /// Generate a Brewfile from the current state
291    pub fn generate(
292        taps: &[String],
293        formulas: &[(String, bool)], // (name, requested)
294        casks: &[String],
295    ) -> String {
296        let mut output = String::new();
297
298        // Taps
299        if !taps.is_empty() {
300            output.push_str("# Taps\n");
301            for tap in taps {
302                output.push_str(&format!("tap \"{}\"\n", tap));
303            }
304            output.push('\n');
305        }
306
307        // Formulas (only requested ones by default)
308        let requested: Vec<_> = formulas.iter().filter(|(_, r)| *r).collect();
309        if !requested.is_empty() {
310            output.push_str("# Formulas\n");
311            for (name, _) in requested {
312                output.push_str(&format!("brew \"{}\"\n", name));
313            }
314            output.push('\n');
315        }
316
317        // Casks
318        if !casks.is_empty() {
319            output.push_str("# Casks\n");
320            for cask in casks {
321                output.push_str(&format!("cask \"{}\"\n", cask));
322            }
323            output.push('\n');
324        }
325
326        output
327    }
328
329    /// Check if Brewfile is empty
330    pub fn is_empty(&self) -> bool {
331        self.taps.is_empty()
332            && self.brews.is_empty()
333            && self.casks.is_empty()
334            && self.mas.is_empty()
335            && self.whalebrew.is_empty()
336            && self.vscode.is_empty()
337    }
338
339    /// Get total entry count
340    pub fn entry_count(&self) -> usize {
341        self.taps.len()
342            + self.brews.len()
343            + self.casks.len()
344            + self.mas.len()
345            + self.whalebrew.len()
346            + self.vscode.len()
347    }
348}
349
350/// Extract a quoted string from the start of a line
351fn extract_quoted_string(s: &str) -> Option<String> {
352    let s = s.trim();
353
354    // Handle double-quoted strings
355    if let Some(rest) = s.strip_prefix('"') {
356        if let Some(end) = rest.find('"') {
357            return Some(rest[..end].to_string());
358        }
359    }
360
361    // Handle single-quoted strings
362    if let Some(rest) = s.strip_prefix('\'') {
363        if let Some(end) = rest.find('\'') {
364            return Some(rest[..end].to_string());
365        }
366    }
367
368    None
369}
370
371/// Parse a mas entry: mas "Name", id: 123456
372fn parse_mas_entry(s: &str) -> Option<(String, u64)> {
373    let name = extract_quoted_string(s)?;
374
375    // Find id: after the name
376    if let Some(id_pos) = s.find("id:") {
377        let id_str = s[id_pos + 3..].trim();
378        // Extract digits
379        let id_digits: String = id_str.chars().take_while(|c| c.is_ascii_digit()).collect();
380        if let Ok(id) = id_digits.parse() {
381            return Some((name, id));
382        }
383    }
384
385    None
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn test_extract_quoted_string() {
394        assert_eq!(extract_quoted_string("\"jq\""), Some("jq".to_string()));
395        assert_eq!(extract_quoted_string("'jq'"), Some("jq".to_string()));
396        assert_eq!(
397            extract_quoted_string("\"homebrew/cask\""),
398            Some("homebrew/cask".to_string())
399        );
400    }
401
402    #[test]
403    fn test_parse_mas_entry() {
404        assert_eq!(
405            parse_mas_entry("\"Xcode\", id: 497799835"),
406            Some(("Xcode".to_string(), 497799835))
407        );
408    }
409
410    #[test]
411    fn test_generate_brewfile() {
412        let taps = vec!["homebrew/cask".to_string()];
413        let formulas = vec![
414            ("jq".to_string(), true),
415            ("oniguruma".to_string(), false), // dependency
416        ];
417        let casks = vec!["firefox".to_string()];
418
419        let output = Brewfile::generate(&taps, &formulas, &casks);
420
421        assert!(output.contains("tap \"homebrew/cask\""));
422        assert!(output.contains("brew \"jq\""));
423        assert!(!output.contains("brew \"oniguruma\"")); // dependency excluded
424        assert!(output.contains("cask \"firefox\""));
425    }
426}