1use self::task::CommandType;
3use self::task::Task;
4use self::TaskError as E;
5use crate::config;
6use crate::env::get_env;
7use crate::tasks::task::TaskStatus;
8use crate::utils::files;
9use crate::utils::user::current_user_is_root;
10use crate::utils::user::get_and_keep_sudo;
11use camino::Utf8Path;
12use camino::Utf8PathBuf;
13use chrono::SecondsFormat;
14use color_eyre::eyre::bail;
15use color_eyre::eyre::eyre;
16use color_eyre::eyre::Result;
17use displaydoc::Display;
18use indicatif::ProgressState;
19use indicatif::ProgressStyle;
20use itertools::Itertools;
21use rayon::prelude::*;
22use std::collections::HashMap;
23use std::collections::HashSet;
24use std::io;
25use std::time::Duration;
26use std::time::Instant;
27use thiserror::Error;
28use tracing::debug;
29use tracing::error;
30use tracing::info;
31use tracing::trace;
32use tracing::warn;
33use tracing_indicatif::span_ext::IndicatifSpanExt;
34
35pub mod completions;
36pub mod defaults;
37pub mod git;
38pub mod link;
39pub(crate) mod schema;
40pub mod task;
41pub mod update_self;
42
43pub trait ResolveEnv {
46 fn resolve_env<F>(&mut self, _env_fn: F) -> Result<(), E>
52 where
53 F: Fn(&str) -> Result<String, E>,
54 {
55 Ok(())
56 }
57}
58
59#[derive(Debug, Clone, Copy)]
61pub enum TasksAction {
62 Run,
64 List,
66}
67
68#[derive(Debug, Clone, Copy)]
70pub enum TasksDir {
71 Tasks,
73 GenerateTasks,
75}
76
77impl TasksDir {
78 fn to_dir_name(self) -> String {
80 match self {
81 TasksDir::Tasks => "tasks".to_owned(),
82 TasksDir::GenerateTasks => "generate_tasks".to_owned(),
83 }
84 }
85}
86
87pub fn run(
90 config: &config::UpConfig,
91 tasks_dirname: TasksDir,
92 tasks_action: TasksAction,
93) -> Result<()> {
94 let mut tasks_dir = config
96 .up_yaml_path
97 .as_ref()
98 .ok_or(E::UnexpectedNone)?
99 .clone();
100 tasks_dir.pop();
101 tasks_dir.push(tasks_dirname.to_dir_name());
102
103 let env = get_env(
104 config.config_yaml.inherit_env.as_ref(),
105 config.config_yaml.env.as_ref(),
106 )?;
107
108 #[cfg(target_os = "macos")]
110 {
111 use crate::cmd;
112 _ = cmd!("caffeinate", "-ds", "-w", &std::process::id().to_string()).start()?;
113 }
114
115 let bootstrap_tasks = match (config.bootstrap, &config.config_yaml.bootstrap_tasks) {
118 (false, _) => Ok(Vec::new()),
119 (true, None) => Err(eyre!(
120 "Bootstrap flag set but no bootstrap_tasks specified in config."
121 )),
122 (true, Some(b_tasks)) => Ok(b_tasks.clone()),
123 }?;
124
125 let filter_tasks_set: Option<HashSet<String>> =
126 config.tasks.clone().map(|v| v.into_iter().collect());
127 debug!("Filter tasks set: {filter_tasks_set:?}");
128
129 let excluded_tasks: HashSet<String> = config
130 .exclude_tasks
131 .clone()
132 .map_or_else(HashSet::new, |v| v.into_iter().collect());
133 debug!("Excluded tasks set: {excluded_tasks:?}");
134
135 let mut tasks: HashMap<String, task::Task> = HashMap::new();
136 for entry in tasks_dir.read_dir().map_err(|e| E::ReadDir {
137 path: tasks_dir.clone(),
138 source: e,
139 })? {
140 let entry = entry?;
141 if entry.file_type()?.is_dir() {
142 continue;
143 }
144 let path = Utf8PathBuf::try_from(entry.path())?;
145 if !path.exists() && path.symlink_metadata().is_ok() {
147 files::remove_broken_symlink(&path)?;
148 continue;
149 }
150 let task = task::Task::from(&path)?;
151 let name = &task.name;
152
153 if excluded_tasks.contains(name) {
154 debug!(
155 "Not running task '{name}' as it is in the excluded tasks set {excluded_tasks:?}"
156 );
157 continue;
158 }
159
160 if let Some(filter) = filter_tasks_set.as_ref() {
161 if !filter.contains(name) {
162 debug!("Not running task '{name}' as not in tasks filter {filter:?}",);
163 continue;
164 }
165 }
166 tasks.insert(name.clone(), task);
167 }
168
169 if matches!(tasks_action, TasksAction::Run)
170 && tasks.values().any(|t| t.config.needs_sudo)
171 && !current_user_is_root()
172 {
173 get_and_keep_sudo(false)?;
174 }
175
176 debug!("Task count: {:?}", tasks.len());
177 trace!("Task list: {tasks:#?}");
178
179 let console = config
180 .console
181 .unwrap_or_else(|| bootstrap_tasks.len() + tasks.len() == 1);
182 trace!("Setting console option to: {console}");
183
184 match tasks_action {
185 TasksAction::List => println!("{}", tasks.keys().join("\n")),
186 TasksAction::Run => {
187 let run_tempdir = config.temp_dir.join(format!(
188 "runs/{start_time}",
189 start_time = config
190 .start_time
191 .to_rfc3339_opts(SecondsFormat::AutoSi, true)
192 .replace(':', "_")
194 ));
195
196 run_tasks(
197 bootstrap_tasks,
198 tasks,
199 &env,
200 &run_tempdir,
201 config.keep_going,
202 console,
203 )?;
204 }
205 }
206 Ok(())
207}
208
209fn run_tasks(
211 bootstrap_tasks: Vec<String>,
212 mut tasks: HashMap<String, task::Task>,
213 env: &HashMap<String, String>,
214 temp_dir: &Utf8Path,
215 keep_going: bool,
216 console: bool,
217) -> Result<()> {
218 let mut completed_tasks = Vec::new();
219
220 let _header_span;
222 if !console {
223 _header_span = set_up_header(tasks.len() + bootstrap_tasks.len())?;
224 }
225
226 if !bootstrap_tasks.is_empty() {
227 for task_name in bootstrap_tasks {
228 let task_tempdir = create_task_tempdir(temp_dir, &task_name)?;
229
230 let task = run_task(
231 tasks
232 .remove(&task_name)
233 .ok_or_else(|| eyre!("Task '{task_name}' was missing."))?,
234 env,
235 &task_tempdir,
236 console,
237 );
238 if !keep_going {
239 if let TaskStatus::Failed(e) = task.status {
240 bail!(e);
241 }
242 }
243 completed_tasks.push(task);
244 }
245 }
246
247 completed_tasks.extend(
248 tasks
249 .into_par_iter()
250 .filter(|(_, task)| task.config.auto_run.unwrap_or(true))
251 .map(|(_, task)| {
252 let task_name = task.name.as_str();
253 let _span = if console {
254 tracing::info_span!("task", task = task_name, indicatif.pb_hide = true)
255 .entered()
256 } else {
257 tracing::info_span!("task", task = task_name).entered()
258 };
259 let task_tempdir = create_task_tempdir(temp_dir, task_name)?;
260 Ok(run_task(task, env, &task_tempdir, console))
261 })
262 .collect::<Result<Vec<Task>>>()?,
263 );
264 let completed_tasks_len = completed_tasks.len();
265
266 let mut tasks_passed = Vec::new();
267 let mut tasks_skipped = Vec::new();
268 let mut tasks_failed = Vec::new();
269 let mut tasks_incomplete = Vec::new();
270
271 for task in completed_tasks {
272 match task.status {
273 TaskStatus::Failed(_) => {
274 tasks_failed.push(task);
275 }
276 TaskStatus::Passed => tasks_passed.push(task),
277 TaskStatus::Skipped => tasks_skipped.push(task),
278 TaskStatus::Incomplete => tasks_incomplete.push(task),
279 }
280 }
281
282 info!(
283 "Ran {completed_tasks_len} tasks, {} passed, {} failed, {} skipped",
284 tasks_passed.len(),
285 tasks_failed.len(),
286 tasks_skipped.len()
287 );
288 if !tasks_passed.is_empty() {
289 info!(
290 "Tasks passed: {:?}",
291 tasks_passed.iter().map(|t| &t.name).collect::<Vec<_>>()
292 );
293 }
294 if !tasks_skipped.is_empty() {
295 info!(
296 "Tasks skipped: {:?}",
297 tasks_skipped.iter().map(|t| &t.name).collect::<Vec<_>>()
298 );
299 }
300
301 if !tasks_failed.is_empty() {
302 error!("One or more tasks failed, exiting.");
303
304 error!(
305 "Tasks failed: {:#?}",
306 tasks_failed.iter().map(|t| &t.name).collect::<Vec<_>>()
307 );
308
309 let mut tasks_failed_iter = tasks_failed.into_iter().filter_map(|t| match t.status {
310 TaskStatus::Failed(e) => Some(e),
311 _ => None,
312 });
313 let err = tasks_failed_iter.next().ok_or(E::UnexpectedNone)?;
314 let err = eyre!(err);
315 tasks_failed_iter.fold(Err(err), color_eyre::Help::error)?;
316 }
317
318 Ok(())
319}
320
321fn run_task(
323 mut task: Task,
324 env: &HashMap<String, String>,
325 task_tempdir: &Utf8Path,
326 console: bool,
327) -> Task {
328 let env_fn = &|s: &str| {
329 let home_dir = files::home_dir().map_err(|e| E::EyreError { source: e })?;
330 let out = shellexpand::full_with_context(
331 s,
332 || Some(home_dir),
333 |k| env.get(k).ok_or_else(|| eyre!("Value not found")).map(Some),
334 )
335 .map(std::borrow::Cow::into_owned)
336 .map_err(|e| E::ResolveEnv {
337 var: e.var_name,
338 source: e.cause,
339 })?;
340
341 Ok(out)
342 };
343
344 let now = Instant::now();
345 task.run(env_fn, env, task_tempdir, console);
346 let elapsed_time = now.elapsed();
347 if elapsed_time > Duration::from_secs(60) {
348 warn!("Task took {elapsed_time:?}");
349 }
350 task
351}
352
353fn create_task_tempdir(temp_dir: &Utf8Path, task_name: &str) -> Result<Utf8PathBuf> {
355 let task_tempdir = temp_dir.join(task_name);
356 files::create_dir_all(&task_tempdir)?;
357 Ok(task_tempdir)
358}
359
360fn set_up_header(tasks_count: usize) -> Result<tracing::Span> {
367 let header_span = tracing::info_span!("header");
368 let command = std::env::args().join(" ");
369 header_span.pb_set_style(
370 &ProgressStyle::with_template(&format!(
371 "Running {tasks_count} tasks for command: `{command}`. {{wide_msg}} \
372 {{elapsed_sec}}\n{{wide_bar}}"
373 ))?
374 .with_key(
375 "elapsed_sec",
376 |state: &ProgressState, writer: &mut dyn std::fmt::Write| {
377 let seconds = state.elapsed().as_secs();
378 let _ = writer.write_str(&format!("{seconds}s"));
379 },
380 )
381 .progress_chars("---"),
382 );
383 header_span.pb_start();
384 Ok(header_span)
385}
386
387#[allow(clippy::doc_markdown)]
388#[derive(Error, Debug, Display)]
389pub enum TaskError {
391 TaskError {
393 source: color_eyre::eyre::Error,
395 lib: String,
397 name: String,
399 },
400 ReadDir {
402 path: Utf8PathBuf,
404 source: io::Error,
406 },
407 ReadFile {
409 path: Utf8PathBuf,
411 source: io::Error,
413 },
414 EnvLookup {
416 var: String,
418 source: color_eyre::eyre::Error,
420 },
421 EmptyCmd,
423 MissingCmd {
425 name: String,
427 },
428 CmdFailed {
432 command_type: CommandType,
434 name: String,
436 source: io::Error,
438 cmd: Vec<String>,
440 suggestion: String,
442 },
443 CmdNonZero {
448 command_type: CommandType,
450 name: String,
452 cmd: Vec<String>,
454 code: i32,
456 output_file: Utf8PathBuf,
458 },
459 CmdTerminated {
464 command_type: CommandType,
466 name: String,
468 cmd: Vec<String>,
470 output_file: Utf8PathBuf,
472 },
473 UnexpectedNone,
475 InvalidYaml {
477 path: Utf8PathBuf,
479 source: serde_yaml::Error,
481 },
482 MissingHomeDir,
484 ResolveEnv {
486 var: String,
488 source: color_eyre::eyre::Error,
490 },
491 TaskDataRequired {
493 task: String,
495 },
496 DeserializeError {
498 source: serde_yaml::Error,
500 },
501 EyreError {
503 source: color_eyre::Report,
505 },
506}