Skip to main content

dotfiles_actions/brew/
action.rs

1// Copyright (c) 2021-2026 Miguel Barreto and others
2//
3// Permission is hereby granted, free of charge, to any person obtaining
4// a copy of this software and associated documentation files (the
5// "Software"), to deal in the Software without restriction, including
6// without limitation the rights to use, copy, modify, merge, publish,
7// distribute, sublicense, and/or sell copies of the Software, and to
8// permit persons to whom the Software is furnished to do so, subject to
9// the following conditions:
10//
11// The above copyright notice and this permission notice shall be
12// included in all copies or substantial portions of the Software.
13//
14// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
22//! This module contains the [BrewAction] that installs
23//! a brew formula using homebrew
24
25#![cfg(unix)]
26use crate::install_command::InstallCommand;
27use dotfiles_core::error::{DotfilesError, ErrorType};
28use dotfiles_core::settings::{initialize_settings_object, Setting, Settings};
29use dotfiles_core::yaml_util::{
30  fold_hash_until_first_err, get_boolean_setting_from_yaml_or_context,
31  get_optional_string_array_from_yaml_hash, process_value_from_yaml_hash,
32};
33use dotfiles_core::{action::SKIP_IN_CI_SETTING, Action};
34use getset::Getters;
35#[cfg(target_os = "macos")]
36use log::info;
37#[cfg(target_os = "macos")]
38use std::fmt::Display;
39use std::path::Path;
40use strict_yaml_rust::StrictYaml;
41use subprocess::Exec;
42#[cfg(target_os = "macos")]
43#[derive(Getters, Eq, PartialEq, Debug, Clone)]
44/// An item to download from the app store
45pub struct MacAppStoreItem {
46  #[getset(get)]
47  /// Numeric ID from the app store
48  id: i64,
49  #[getset(get)]
50  /// Human readable name.
51  name: String,
52}
53
54#[cfg(target_os = "macos")]
55impl From<(i64, String)> for MacAppStoreItem {
56  fn from(value: (i64, String)) -> Self {
57    MacAppStoreItem {
58      id: value.0,
59      name: value.1,
60    }
61  }
62}
63
64#[cfg(target_os = "macos")]
65impl Display for MacAppStoreItem {
66  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67    write!(f, "id: {}, name: {}", self.id, self.name)
68  }
69}
70
71#[cfg(target_os = "macos")]
72#[derive(Eq, PartialEq, Debug, Clone)]
73/// Command to download something from the mac app store
74pub struct MacAppStoreCommand {
75  items: Vec<MacAppStoreItem>,
76  args: Vec<String>,
77}
78
79#[cfg(target_os = "macos")]
80impl From<Vec<MacAppStoreItem>> for MacAppStoreCommand {
81  fn from(items: Vec<MacAppStoreItem>) -> Self {
82    let mut args: Vec<String> = items.iter().map(|it| it.id().to_string()).collect();
83    args.insert(0, "install".into());
84    MacAppStoreCommand { items, args }
85  }
86}
87
88#[cfg(target_os = "macos")]
89impl InstallCommand<MacAppStoreItem> for MacAppStoreCommand {
90  fn base_command(&self) -> Exec {
91    Exec::cmd("mas")
92  }
93
94  fn args(&self) -> &Vec<String> {
95    &self.args
96  }
97
98  fn action_description(&self) -> &str {
99    "Installing from Mac App Store"
100  }
101
102  fn items(&self) -> &Vec<MacAppStoreItem> {
103    &self.items
104  }
105
106  fn action_name(&self) -> &str {
107    "mas"
108  }
109
110  fn execute(&self) -> Result<(), DotfilesError> {
111    let item_list: String = self
112      .items()
113      .iter()
114      .map(|it| format!("{}", it))
115      .collect::<Vec<String>>()
116      .join(", ");
117    info!("{} {}", self.action_description(), item_list);
118    let mut cmd = self.base_command();
119    for arg in self.args().iter() {
120      cmd = cmd.arg(arg);
121    }
122    dotfiles_core::exec_wrapper::execute_commands(
123      vec![cmd],
124      format!("Couldn't {} {}", self.action_name(), item_list).as_str(),
125      format!(
126        "Unexpected error while {} {}",
127        self.action_description(),
128        item_list
129      )
130      .as_str(),
131    )
132  }
133}
134
135struct BrewCommand {
136  items: Vec<String>,
137  args: Vec<String>,
138  action_name: String,
139  action_description: String,
140}
141
142impl InstallCommand<String> for BrewCommand {
143  fn base_command(&self) -> Exec {
144    Exec::cmd("brew")
145  }
146
147  fn args(&self) -> &Vec<String> {
148    &self.args
149  }
150
151  fn action_description(&self) -> &str {
152    &self.action_description
153  }
154
155  fn items(&self) -> &Vec<String> {
156    &self.items
157  }
158
159  fn action_name(&self) -> &str {
160    &self.action_name
161  }
162}
163impl BrewCommand {
164  fn tap(tap: &str) -> BrewCommand {
165    BrewCommand {
166      items: vec![tap.into()],
167      args: vec!["tap".into(), tap.into()],
168      action_name: "tap".into(),
169      action_description: "tapping".into(),
170    }
171  }
172
173  fn trust(tap: &str) -> BrewCommand {
174    BrewCommand {
175      items: vec![tap.into()],
176      args: vec!["trust".into(), tap.into()],
177      action_name: "trust".into(),
178      action_description: "trusting".into(),
179    }
180  }
181
182  fn install_formulae(items: &[String]) -> BrewCommand {
183    let mut args: Vec<String> = items.to_vec();
184    args.insert(0, "install".into());
185    BrewCommand {
186      items: items.to_vec(),
187      args,
188      action_name: "install formula".into(),
189      action_description: "installing formula".into(),
190    }
191  }
192
193  fn install_casks(items: &[String], force: &bool, adopt: &bool) -> BrewCommand {
194    let mut args = vec!["install".into(), "--cask".into()];
195    if *force {
196      args.push("--force".into())
197    }
198    if *adopt {
199      args.push("--adopt".into())
200    }
201    {
202      let mut items = items.to_vec();
203      args.append(&mut items)
204    }
205    let args = args;
206    let items = items.to_vec();
207    BrewCommand {
208      items,
209      args,
210      action_name: "install cask".into(),
211      action_description: "installing cask".into(),
212    }
213  }
214}
215
216/// force casks
217pub const FORCE_CASKS_SETTING: &str = "force_casks";
218/// adopt casks to deal with previously installed apps
219pub const ADOPT_CASKS_SETTING: &str = "adopt_casks";
220/// Automatically trust taps
221pub const AUTO_TRUST_TAPS_SETTING: &str = "auto_trust_taps";
222
223/// The string that identifies the list of taps to install
224pub const TAP_SETTING: &str = "tap";
225/// The string that identifies the list of formulae to install
226pub const FORMULA_SETTING: &str = "formula";
227/// The string that identifies the list of casks to install
228pub const CASK_SETTING: &str = "cask";
229
230/// Default settings for the Brew action.
231pub fn default_settings() -> Settings {
232  initialize_settings_object(&[
233    (FORCE_CASKS_SETTING.to_owned(), Setting::Boolean(false)),
234    (ADOPT_CASKS_SETTING.to_owned(), Setting::Boolean(false)),
235    (AUTO_TRUST_TAPS_SETTING.to_owned(), Setting::Boolean(false)),
236    (SKIP_IN_CI_SETTING.to_owned(), Setting::Boolean(false)),
237  ])
238}
239
240/// [BrewAction] Installs software using homebrew.
241#[derive(Eq, PartialEq, Debug, Getters)]
242pub struct BrewAction {
243  /// Skips this action if it is running in a CI environment.
244  #[get = "pub"]
245  skip_in_ci: bool,
246  /// Passes `--force` to `brew install --cask`.
247  #[get = "pub"]
248  force_casks: bool,
249  // Passes `--adopt` to `brew install --cask` to prevent the install failure
250  /// when the app is already installed before the cask install.
251  #[get = "pub"]
252  adopt_casks: bool,
253  /// Automatically run `brew trust` on any custom taps.
254  #[get = "pub"]
255  auto_trust_taps: bool,
256  /// List of repositories to tap into using `brew tap`.
257  #[get = "pub"]
258  taps: Vec<String>,
259  /// List of brew formulae to `brew install`, usually command line tools.
260  #[get = "pub"]
261  formulae: Vec<String>,
262
263  /// List of casks to install. Casks usually are macOS apps with some sort of UI or framework
264  /// dependencies.
265  #[get = "pub"]
266  casks: Vec<String>,
267
268  #[cfg(target_os = "macos")]
269  /// List of Mac OS apps to install from the App Store
270  #[get = "pub"]
271  mas_apps: Vec<MacAppStoreItem>,
272}
273impl BrewAction {
274  /// Constructs a new [BrewAction]
275  #[allow(clippy::too_many_arguments)]
276  pub fn new(
277    skip_in_ci: bool,
278    force_casks: bool,
279    adopt_casks: bool,
280    auto_trust_taps: bool,
281    taps: Vec<String>,
282    formulae: Vec<String>,
283    casks: Vec<String>,
284    #[cfg(target_os = "macos")] mas_apps: Vec<MacAppStoreItem>,
285  ) -> Self {
286    let action = BrewAction {
287      skip_in_ci,
288      force_casks,
289      adopt_casks,
290      auto_trust_taps,
291      taps,
292      formulae,
293      casks,
294      #[cfg(target_os = "macos")]
295      mas_apps,
296    };
297    log::trace!("Creating new {:?}", action);
298    action
299  }
300}
301
302impl Action for BrewAction {
303  fn execute(&self) -> Result<(), DotfilesError> {
304    for tap in &self.taps {
305      if self.auto_trust_taps {
306        BrewCommand::trust(tap).execute()?;
307      }
308      BrewCommand::tap(tap).execute()?;
309    }
310    if !self.formulae.is_empty() {
311      BrewCommand::install_formulae(&self.formulae).execute()?;
312    }
313    if !self.casks.is_empty() {
314      BrewCommand::install_casks(&self.casks, self.force_casks(), self.adopt_casks()).execute()?;
315    }
316    #[cfg(target_os = "macos")]
317    if !self.mas_apps.is_empty() {
318      MacAppStoreCommand::from(self.mas_apps.clone()).execute()?;
319    }
320    Ok(())
321  }
322
323  fn skip_in_ci(&self) -> bool {
324    self.skip_in_ci
325  }
326}
327
328/// Static parsing function to build a list of BrewActions from YAML and settings context
329pub fn parse_action_list(
330  context_settings: &Settings,
331  yaml: &StrictYaml,
332  _current_dir: &Path,
333) -> Result<Vec<BrewAction>, DotfilesError> {
334  let defaults = default_settings();
335  let force_casks = get_boolean_setting_from_yaml_or_context(
336    FORCE_CASKS_SETTING,
337    yaml,
338    context_settings,
339    &defaults,
340  )?;
341  let adopt_casks = get_boolean_setting_from_yaml_or_context(
342    ADOPT_CASKS_SETTING,
343    yaml,
344    context_settings,
345    &defaults,
346  )?;
347  let auto_trust_taps = get_boolean_setting_from_yaml_or_context(
348    AUTO_TRUST_TAPS_SETTING,
349    yaml,
350    context_settings,
351    &defaults,
352  )?;
353  let skip_in_ci = get_boolean_setting_from_yaml_or_context(
354    SKIP_IN_CI_SETTING,
355    yaml,
356    context_settings,
357    &defaults,
358  )?;
359  let taps = get_optional_string_array_from_yaml_hash(TAP_SETTING, yaml)?;
360  let formulae = get_optional_string_array_from_yaml_hash(FORMULA_SETTING, yaml)?;
361  let casks = get_optional_string_array_from_yaml_hash(CASK_SETTING, yaml)?;
362  #[cfg(target_os = "macos")]
363  let mas_apps = process_value_from_yaml_hash("mas", yaml, |mas_yaml| {
364    fold_hash_until_first_err(
365      mas_yaml,
366      Ok(Vec::<MacAppStoreItem>::new()),
367      |key, val| {
368        Ok((
369          val
370            .to_owned()
371            .into_string()
372            .ok_or(DotfilesError::from_wrong_yaml(
373              "Mac App Store app ID is not a string as expected".into(),
374              val.to_owned(),
375              StrictYaml::String("".into()),
376            ))
377            .and_then(|id| {
378              id.parse::<i64>().map_err(|_| {
379                DotfilesError::from(
380                  format!("{id} is not a valid Mac App Store app id"),
381                  ErrorType::InconsistentConfigurationError,
382                )
383              })
384            })?,
385          key,
386        ))
387      },
388      |mut list, item| {
389        list.push(MacAppStoreItem::from(item));
390        Ok(list)
391      },
392    )
393  })
394  .map_or_else(
395    |err| {
396      if err.is_missing_config("mas") {
397        Ok(Vec::new())
398      } else {
399        Err(err)
400      }
401    },
402    Ok,
403  )?;
404
405  Ok(vec![BrewAction::new(
406    skip_in_ci,
407    force_casks,
408    adopt_casks,
409    auto_trust_taps,
410    taps,
411    formulae,
412    casks,
413    #[cfg(target_os = "macos")]
414    mas_apps,
415  )])
416}