Skip to main content

mk_lib/schema/command/
local_run.rs

1use std::io::{
2  BufRead as _,
3  BufReader,
4};
5use std::process::Stdio;
6use std::thread;
7
8use anyhow::Context as _;
9use indicatif::ProgressDrawTarget;
10use serde::Deserialize;
11
12use crate::defaults::{
13  default_ignore_errors,
14  default_verbose,
15};
16use crate::handle_output;
17use crate::schema::{
18  get_output_handler,
19  interpolate_template_string,
20  Shell,
21  TaskContext,
22};
23
24#[derive(Debug, Deserialize, Clone)]
25pub struct LocalRun {
26  /// The command to run
27  pub command: String,
28
29  /// The shell to use to run the command
30  #[serde(default)]
31  pub shell: Option<Shell>,
32
33  /// The test to run before running command
34  /// If the test fails, the command will not run
35  #[serde(default)]
36  pub test: Option<String>,
37
38  /// The working directory to run the command in
39  #[serde(default)]
40  pub work_dir: Option<String>,
41
42  /// Interactive mode
43  /// If true, the command will be interactive accepting user input
44  #[serde(default)]
45  pub interactive: Option<bool>,
46
47  /// Ignore errors if the command fails
48  #[serde(default)]
49  pub ignore_errors: Option<bool>,
50
51  /// Save the command stdout to a task-scoped output name
52  #[serde(default)]
53  pub save_output_as: Option<String>,
54
55  /// Show verbose output
56  #[serde(default)]
57  pub verbose: Option<bool>,
58}
59
60impl LocalRun {
61  pub fn execute(&self, context: &TaskContext) -> anyhow::Result<()> {
62    assert!(!self.command.is_empty());
63
64    let command = interpolate_template_string(&self.command, context)?;
65    let interactive = self.interactive();
66    let ignore_errors = self.ignore_errors(context);
67    let capture_output = self.save_output_as.is_some();
68    // If interactive mode is enabled, we don't need to redirect the output
69    // to the parent process. This is because the command will be run in the
70    // foreground and the user will be able to see the output.
71    let verbose = interactive || self.verbose(context);
72
73    // Skip the command if the test fails
74    if self.test(context).is_err() {
75      return Ok(());
76    }
77
78    let mut cmd = self
79      .shell
80      .as_ref()
81      .map(|shell| shell.proc())
82      .unwrap_or_else(|| context.shell().proc());
83
84    cmd.arg(&command);
85
86    if capture_output {
87      cmd.stdout(Stdio::piped());
88      if interactive {
89        context.multi.set_draw_target(ProgressDrawTarget::hidden());
90        cmd.stdin(Stdio::inherit()).stderr(Stdio::inherit());
91      } else {
92        cmd.stderr(get_output_handler(verbose));
93      }
94    } else if verbose {
95      if interactive {
96        context.multi.set_draw_target(ProgressDrawTarget::hidden());
97
98        cmd
99          .stdin(Stdio::inherit())
100          .stdout(Stdio::inherit())
101          .stderr(Stdio::inherit());
102      } else {
103        let stdout = get_output_handler(verbose);
104        let stderr = get_output_handler(verbose);
105        cmd.stdout(stdout).stderr(stderr);
106      }
107    }
108
109    if let Some(work_dir) = self.resolved_work_dir(context) {
110      cmd.current_dir(work_dir);
111    }
112
113    // Inject environment variables
114    for (key, value) in context.env_vars.iter() {
115      cmd.env(key, value);
116    }
117
118    let mut cmd = cmd.spawn()?;
119    let stdout_handle = if capture_output {
120      let stdout = cmd.stdout.take().context("Failed to open stdout")?;
121      let multi = context.multi.clone();
122      Some(thread::spawn(move || -> anyhow::Result<String> {
123        let reader = BufReader::new(stdout);
124        let mut output = String::new();
125        for line in reader.lines() {
126          let line = line?;
127          if verbose {
128            let _ = multi.println(line.clone());
129          }
130          output.push_str(&line);
131          output.push('\n');
132        }
133        Ok(output.trim_end_matches(['\r', '\n']).to_string())
134      }))
135    } else {
136      None
137    };
138
139    if verbose && !interactive && !capture_output {
140      handle_output!(cmd.stdout, context);
141      handle_output!(cmd.stderr, context);
142    } else if verbose && !interactive && capture_output {
143      handle_output!(cmd.stderr, context);
144    }
145
146    let status = cmd.wait()?;
147    let captured_stdout = match stdout_handle {
148      Some(handle) => Some(
149        handle
150          .join()
151          .map_err(|_| anyhow::anyhow!("Failed to join stdout capture thread"))??,
152      ),
153      None => None,
154    };
155    if !status.success() && !ignore_errors {
156      anyhow::bail!("Command failed - {}", command);
157    }
158
159    if status.success() {
160      if let (Some(output_name), Some(output_value)) = (&self.save_output_as, captured_stdout) {
161        context.insert_task_output(output_name.clone(), output_value)?;
162      }
163    }
164
165    Ok(())
166  }
167
168  /// Check if the local run task is parallel safe
169  /// If the task is interactive, it is not parallel safe
170  pub fn is_parallel_safe(&self) -> bool {
171    !self.interactive()
172  }
173
174  fn test(&self, context: &TaskContext) -> anyhow::Result<()> {
175    let verbose = self.verbose(context);
176
177    let stdout = get_output_handler(verbose);
178    let stderr = get_output_handler(verbose);
179
180    if let Some(test) = &self.test {
181      let test = interpolate_template_string(test, context)?;
182      let mut cmd = self
183        .shell
184        .as_ref()
185        .map(|shell| shell.proc())
186        .unwrap_or_else(|| context.shell().proc());
187      cmd.arg(&test).stdout(stdout).stderr(stderr);
188
189      if let Some(work_dir) = self.resolved_work_dir(context) {
190        cmd.current_dir(work_dir);
191      }
192
193      let mut cmd = cmd.spawn()?;
194      if verbose {
195        handle_output!(cmd.stdout, context);
196        handle_output!(cmd.stderr, context);
197      }
198
199      let status = cmd.wait()?;
200
201      log::trace!("Test status: {:?}", status.success());
202      if !status.success() {
203        anyhow::bail!("Command test failed - {}", test);
204      }
205    }
206
207    Ok(())
208  }
209
210  fn interactive(&self) -> bool {
211    self.interactive.unwrap_or(false)
212  }
213
214  fn ignore_errors(&self, context: &TaskContext) -> bool {
215    self
216      .ignore_errors
217      .or(context.ignore_errors)
218      .unwrap_or(default_ignore_errors())
219  }
220
221  fn verbose(&self, context: &TaskContext) -> bool {
222    self.verbose.or(context.verbose).unwrap_or(default_verbose())
223  }
224
225  pub fn resolved_work_dir(&self, context: &TaskContext) -> Option<std::path::PathBuf> {
226    self
227      .work_dir
228      .as_ref()
229      .map(|work_dir| context.resolve_from_config(work_dir))
230  }
231}
232
233#[cfg(test)]
234mod test {
235  use super::*;
236
237  #[test]
238  fn test_local_run_1() -> anyhow::Result<()> {
239    {
240      let yaml = "
241        command: echo 'Hello, World!'
242        ignore_errors: false
243        verbose: false
244      ";
245      let local_run = serde_yaml::from_str::<LocalRun>(yaml)?;
246
247      assert_eq!(local_run.command, "echo 'Hello, World!'");
248      assert_eq!(local_run.work_dir, None);
249      assert_eq!(local_run.ignore_errors, Some(false));
250      assert_eq!(local_run.verbose, Some(false));
251      assert_eq!(local_run.save_output_as, None);
252
253      Ok(())
254    }
255  }
256
257  #[test]
258  fn test_local_run_2() -> anyhow::Result<()> {
259    {
260      let yaml = "
261        command: echo 'Hello, World!'
262        test: test $(uname) = 'Linux'
263        ignore_errors: false
264        verbose: false
265      ";
266      let local_run = serde_yaml::from_str::<LocalRun>(yaml)?;
267
268      assert_eq!(local_run.command, "echo 'Hello, World!'");
269      assert_eq!(local_run.test, Some("test $(uname) = 'Linux'".to_string()));
270      assert_eq!(local_run.work_dir, None);
271      assert_eq!(local_run.ignore_errors, Some(false));
272      assert_eq!(local_run.verbose, Some(false));
273      assert_eq!(local_run.save_output_as, None);
274
275      Ok(())
276    }
277  }
278
279  #[test]
280  fn test_local_run_3() -> anyhow::Result<()> {
281    {
282      let yaml = "
283        command: echo 'Hello, World!'
284        test: test $(uname) = 'Linux'
285        shell: bash
286        ignore_errors: false
287        verbose: false
288        interactive: true
289      ";
290      let local_run = serde_yaml::from_str::<LocalRun>(yaml)?;
291
292      assert_eq!(local_run.command, "echo 'Hello, World!'");
293      assert_eq!(local_run.test, Some("test $(uname) = 'Linux'".to_string()));
294      assert_eq!(local_run.shell, Some(Shell::String("bash".to_string())));
295      assert_eq!(local_run.work_dir, None);
296      assert_eq!(local_run.ignore_errors, Some(false));
297      assert_eq!(local_run.verbose, Some(false));
298      assert_eq!(local_run.interactive, Some(true));
299      assert_eq!(local_run.save_output_as, None);
300
301      Ok(())
302    }
303  }
304}