1#![allow(clippy::str_to_string)] use crate::exec::cmd_log;
5use crate::exec::UpDuct;
6use crate::generate;
7use crate::log;
8use crate::opts::GenerateGitConfig;
9use crate::opts::LinkOptions;
10use crate::opts::UpdateSelfOptions;
11use crate::tasks;
12use crate::tasks::defaults::DefaultsConfig;
13use crate::tasks::git::GitConfig;
14use crate::tasks::ResolveEnv;
15use crate::tasks::TaskError as E;
16use camino::Utf8Path;
17use camino::Utf8PathBuf;
18use color_eyre::eyre::eyre;
19use color_eyre::eyre::Result;
20use schemars::JsonSchema;
21use serde_derive::Deserialize;
22use serde_derive::Serialize;
23use std::collections::HashMap;
24use std::fmt;
25use std::fmt::Display;
26use std::fs;
27use std::process::Output;
28use std::string::String;
29use std::time::Duration;
30use std::time::Instant;
31use tracing::debug;
32use tracing::info;
33use tracing::trace;
34use tracing::Level;
35
36#[derive(Debug)]
38pub enum TaskStatus {
39 Incomplete,
41 Skipped,
43 Passed,
45 Failed(E),
47}
48
49#[derive(Debug)]
51pub struct Task {
52 pub name: String,
54 pub path: Utf8PathBuf,
56 pub config: TaskConfig,
58 pub start_time: Instant,
60 pub status: TaskStatus,
62}
63
64#[derive(Debug, Serialize, Deserialize, JsonSchema)]
67#[serde(deny_unknown_fields)]
68pub struct TaskConfig {
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub name: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub constraints: Option<HashMap<String, String>>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub requires: Option<Vec<String>>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub auto_run: Option<bool>,
81 #[serde(skip_serializing_if = "Option::is_none")]
84 pub run_lib: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
92 pub run_if_cmd: Option<Vec<String>>,
93 #[serde(skip_serializing_if = "Option::is_none")]
100 pub run_cmd: Option<Vec<String>>,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub description: Option<String>,
104 #[serde(default = "default_false")]
107 pub needs_sudo: bool,
108 #[serde(skip_serializing_if = "Option::is_none")]
112 #[schemars(with = "Option<serde_json::Value>")]
115 pub data: Option<serde_yaml::Value>,
116}
117
118const fn default_false() -> bool {
120 false
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum CommandType {
126 RunIf,
128 Run,
130}
131
132impl Display for CommandType {
133 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
134 match self {
135 Self::Run => write!(f, "run command"),
136 Self::RunIf => write!(f, "run_if command"),
137 }
138 }
139}
140
141impl Task {
142 pub fn from(path: &Utf8Path) -> Result<Self> {
144 let start_time = Instant::now();
145 let s = fs::read_to_string(path).map_err(|e| E::ReadFile {
146 path: path.to_owned(),
147 source: e,
148 })?;
149 trace!("Task '{path}' contents: <<<{s}>>>");
150 let config = serde_yaml::from_str::<TaskConfig>(&s).map_err(|e| E::InvalidYaml {
151 path: path.to_owned(),
152 source: e,
153 })?;
154 let name = match &config.name {
155 Some(n) => n.clone(),
156 None => path
157 .file_stem()
158 .ok_or_else(|| eyre!("Task had no path."))?
159 .to_owned(),
160 };
161 let task = Self {
162 name,
163 path: path.to_owned(),
164 config,
165 start_time,
166 status: TaskStatus::Incomplete,
167 };
168 debug!("Task '{name}': {task:?}", name = &task.name);
169 Ok(task)
170 }
171
172 pub fn run<F>(
174 &mut self,
175 env_fn: F,
176 env: &HashMap<String, String>,
177 task_tempdir: &Utf8Path,
178 console: bool,
179 ) where
180 F: Fn(&str) -> Result<String, E>,
181 {
182 match self.try_run(env_fn, env, task_tempdir, console) {
183 Ok(status) => self.status = status,
184 Err(e) => self.status = TaskStatus::Failed(e),
185 }
186 }
187
188 pub fn try_run<F>(
190 &mut self,
191 env_fn: F,
192 env: &HashMap<String, String>,
193 task_tempdir: &Utf8Path,
194 console: bool,
195 ) -> Result<TaskStatus, E>
196 where
197 F: Fn(&str) -> Result<String, E>,
198 {
199 let name = &self.name;
200 info!("Running");
201
202 if let Some(mut cmd) = self.config.run_if_cmd.clone() {
203 debug!("Running run_if command.");
204 for s in &mut cmd {
205 *s = env_fn(s)?;
206 }
207 if !self.run_command(CommandType::RunIf, &cmd, env, task_tempdir, console)? {
210 debug!("Skipping task as run_if command failed.");
211 return Ok(TaskStatus::Skipped);
212 }
213 } else {
214 debug!("You haven't specified a run_if command, so it will always be run",);
215 }
216
217 if let Some(lib) = &self.config.run_lib {
218 let maybe_data = self.config.data.clone();
219
220 let status = match lib.as_str() {
221 "defaults" => {
222 let data: DefaultsConfig =
223 parse_task_config(maybe_data, &self.name, false, env_fn)?;
224 tasks::defaults::run(data, task_tempdir)
225 }
226
227 "generate_git" => {
228 let data: Vec<GenerateGitConfig> =
229 parse_task_config(maybe_data, &self.name, false, env_fn)?;
230 generate::git::run(&data)
231 }
232
233 "git" => {
234 let data: Vec<GitConfig> =
235 parse_task_config(maybe_data, &self.name, false, env_fn)?;
236 tasks::git::run(&data)
237 }
238
239 "link" => {
240 let data: LinkOptions =
241 parse_task_config(maybe_data, &self.name, false, env_fn)?;
242 tasks::link::run(data, task_tempdir)
243 }
244
245 "self" => {
246 let data: UpdateSelfOptions =
247 parse_task_config(maybe_data, &self.name, true, env_fn)?;
248 tasks::update_self::run(&data)
249 }
250
251 _ => Err(eyre!("This run_lib is invalid or not yet implemented.")),
252 }
253 .map_err(|e| E::TaskError {
254 name: self.name.clone(),
255 lib: lib.to_string(),
256 source: e,
257 })?;
258 return Ok(status);
259 }
260
261 if let Some(mut cmd) = self.config.run_cmd.clone() {
262 debug!("Running '{name}' run command.");
263 for s in &mut cmd {
264 *s = env_fn(s)?;
265 }
266 if self.run_command(CommandType::Run, &cmd, env, task_tempdir, console)? {
267 return Ok(TaskStatus::Passed);
268 }
269 return Ok(TaskStatus::Skipped);
270 }
271
272 Err(E::MissingCmd {
273 name: self.name.clone(),
274 })
275 }
276
277 pub fn run_command(
282 &self,
283 command_type: CommandType,
284 cmd: &[String],
285 env: &HashMap<String, String>,
286 task_tempdir: &Utf8Path,
287 console: bool,
288 ) -> Result<bool, E> {
289 let now = Instant::now();
290 let task_output_file = task_tempdir.join("task_stdout_stderr.txt");
291
292 let command = cmd_log(
293 Level::DEBUG,
294 cmd.first().ok_or(E::EmptyCmd)?,
295 cmd.get(1..).unwrap_or(&[]),
296 )
297 .dir(task_tempdir)
298 .full_env(env)
299 .unchecked();
300
301 let output = if console {
302 command.run_with_inherit()
303 } else {
304 command
305 .stderr_path(&task_output_file)
306 .run_with_path(&task_output_file)
307 };
308
309 let output = output.map_err(|e| {
310 let suggestion = match e.kind() {
311 std::io::ErrorKind::PermissionDenied => format!(
312 "\n Suggestion: Try making the file executable with `chmod +x {path}`",
313 path = cmd.first().map_or("", String::as_str)
314 ),
315 _ => String::new(),
316 };
317 E::CmdFailed {
318 command_type,
319 name: self.name.clone(),
320 cmd: cmd.into(),
321 source: e,
322 suggestion,
323 }
324 })?;
325
326 let elapsed_time = now.elapsed();
327 let command_result = match output.status.code() {
328 Some(0) => Ok(true),
329 Some(204) => Ok(false),
330 Some(code) => Err(E::CmdNonZero {
331 name: self.name.clone(),
332 command_type,
333 cmd: cmd.to_owned(),
334 output_file: task_output_file,
335 code,
336 }),
337 None => Err(E::CmdTerminated {
338 command_type,
339 name: self.name.clone(),
340 cmd: cmd.to_owned(),
341 output_file: task_output_file,
342 }),
343 };
344 self.log_command_output(command_type, command_result.is_ok(), &output, elapsed_time);
345 command_result
346 }
347
348 pub fn log_command_output(
350 &self,
351 command_type: CommandType,
352 command_success: bool,
353 output: &Output,
354 elapsed_time: Duration,
355 ) {
356 let name = &self.name;
357 let level = if command_success {
358 Level::DEBUG
359 } else {
360 Level::ERROR
361 };
362
363 log!(
365 level,
366 "Task '{name}' {command_type} ran in {elapsed_time:?} with {}",
367 output.status
368 );
369 if !output.stdout.is_empty() {
370 log!(
371 level,
372 "Task '{name}' {command_type} stdout:\n<<<\n{}>>>\n",
373 String::from_utf8_lossy(&output.stdout),
374 );
375 }
376 if !output.stderr.is_empty() {
377 log!(
378 level,
379 "Task '{name}' {command_type} command stderr:\n<<<\n{}>>>\n",
380 String::from_utf8_lossy(&output.stderr),
381 );
382 }
383 }
384}
385
386fn parse_task_config<F, T: ResolveEnv + Default + for<'de> serde::Deserialize<'de>>(
390 maybe_data: Option<serde_yaml::Value>,
391 task_name: &str,
392 has_default: bool,
393 env_fn: F,
394) -> Result<T, E>
395where
396 F: Fn(&str) -> Result<String, E>,
397{
398 let data = match maybe_data {
399 Some(data) => data,
400 None if has_default => return Ok(T::default()),
401 None => {
402 return Err(E::TaskDataRequired {
403 task: task_name.to_owned(),
404 });
405 }
406 };
407
408 let mut raw_opts: T =
409 serde_yaml::from_value(data).map_err(|e| E::DeserializeError { source: e })?;
410 raw_opts.resolve_env(env_fn)?;
411 Ok(raw_opts)
412}