1use crate::error::{Error, Result};
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10use std::process::Command;
11use tracing::{debug, warn};
12
13#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(untagged)]
64pub enum RestartService {
65 Bool(bool),
66 Symbol(String), }
68
69#[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#[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#[derive(Debug, Default, Clone, Serialize, Deserialize)]
94pub struct MasEntry {
95 pub name: String,
96 pub id: u64,
97}
98
99#[derive(Debug, Default, Clone, Serialize, Deserialize)]
101pub struct WhalebrewEntry {
102 pub name: String,
103}
104
105#[derive(Debug, Default, Clone, Serialize, Deserialize)]
107pub struct VscodeEntry {
108 pub name: String,
109}
110
111impl Brewfile {
112 pub fn parse(path: &Path) -> Result<Self> {
114 if !path.exists() {
115 return Err(Error::BrewfileNotFound(path.display().to_string()));
116 }
117
118 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 warn!("Ruby not available, using basic Rust parser (some options may be ignored)");
131 Self::parse_with_rust(path)
132 }
133
134 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 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 if line.is_empty() || line.starts_with('#') {
226 continue;
227 }
228
229 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 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 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 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 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 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 pub fn generate(
292 taps: &[String],
293 formulas: &[(String, bool)], casks: &[String],
295 ) -> String {
296 let mut output = String::new();
297
298 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 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 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 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 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
350fn extract_quoted_string(s: &str) -> Option<String> {
352 let s = s.trim();
353
354 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 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
371fn parse_mas_entry(s: &str) -> Option<(String, u64)> {
373 let name = extract_quoted_string(s)?;
374
375 if let Some(id_pos) = s.find("id:") {
377 let id_str = s[id_pos + 3..].trim();
378 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), ];
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\"")); assert!(output.contains("cask \"firefox\""));
425 }
426}