use crate::exec::cmd_log;
use crate::exec::LivDuct;
use crate::generate;
use crate::log;
use crate::opts::GenerateGitConfig;
use crate::opts::LinkOptions;
use crate::opts::UpdateSelfOptions;
use crate::tasks;
use crate::tasks::defaults::DefaultsConfig;
use crate::tasks::git::GitConfig;
use crate::tasks::ResolveEnv;
use crate::tasks::TaskError as E;
use camino::Utf8Path;
use camino::Utf8PathBuf;
use color_eyre::eyre::eyre;
use color_eyre::eyre::Result;
use serde_derive::Deserialize;
use serde_derive::Serialize;
use std::collections::HashMap;
use std::fmt;
use std::fmt::Display;
use std::fs;
use std::process::Output;
use std::string::String;
use std::time::Duration;
use std::time::Instant;
use tracing::debug;
use tracing::info;
use tracing::trace;
use tracing::Level;
#[derive(Debug)]
pub enum TaskStatus {
Incomplete,
Skipped,
Passed,
Failed(E),
}
#[derive(Debug)]
pub struct Task {
pub name: String,
pub path: Utf8PathBuf,
pub config: TaskConfig,
pub start_time: Instant,
pub status: TaskStatus,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TaskConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub constraints: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub requires: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_run: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub run_lib: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub run_if_cmd: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub run_cmd: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default = "default_false")]
pub needs_sudo: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_yaml::Value>,
}
const fn default_false() -> bool {
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandType {
RunIf,
Run,
}
impl Display for CommandType {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Run => write!(f, "run command"),
Self::RunIf => write!(f, "run_if command"),
}
}
}
impl Task {
pub fn from(path: &Utf8Path) -> Result<Self> {
let start_time = Instant::now();
let s = fs::read_to_string(path).map_err(|e| E::ReadFile {
path: path.to_owned(),
source: e,
})?;
trace!("Task '{path}' contents: <<<{s}>>>");
let config = serde_yaml::from_str::<TaskConfig>(&s).map_err(|e| E::InvalidYaml {
path: path.to_owned(),
source: e,
})?;
let name = match &config.name {
Some(n) => n.clone(),
None => path
.file_stem()
.ok_or_else(|| eyre!("Task had no path."))?
.to_owned(),
};
let task = Self {
name,
path: path.to_owned(),
config,
start_time,
status: TaskStatus::Incomplete,
};
debug!("Task '{name}': {task:?}", name = &task.name);
Ok(task)
}
pub fn run<F>(&mut self, env_fn: F, env: &HashMap<String, String>, task_tempdir: &Utf8Path)
where
F: Fn(&str) -> Result<String, E>,
{
match self.try_run(env_fn, env, task_tempdir) {
Ok(status) => self.status = status,
Err(e) => self.status = TaskStatus::Failed(e),
}
}
pub fn try_run<F>(
&mut self,
env_fn: F,
env: &HashMap<String, String>,
task_tempdir: &Utf8Path,
) -> Result<TaskStatus, E>
where
F: Fn(&str) -> Result<String, E>,
{
let name = &self.name;
info!("Running");
if let Some(mut cmd) = self.config.run_if_cmd.clone() {
debug!("Running run_if command.");
for s in &mut cmd {
*s = env_fn(s)?;
}
if !self.run_command(CommandType::RunIf, &cmd, env, task_tempdir)? {
debug!("Skipping task as run_if command failed.");
return Ok(TaskStatus::Skipped);
}
} else {
debug!("You haven't specified a run_if command, so it will always be run",);
}
if let Some(lib) = &self.config.run_lib {
let maybe_data = self.config.data.clone();
let status = match lib.as_str() {
"link" => {
let data: LinkOptions =
parse_task_config(maybe_data, &self.name, false, env_fn)?;
tasks::link::run(data, task_tempdir)
}
"git" => {
let data: Vec<GitConfig> =
parse_task_config(maybe_data, &self.name, false, env_fn)?;
tasks::git::run(&data)
}
"generate_git" => {
let data: Vec<GenerateGitConfig> =
parse_task_config(maybe_data, &self.name, false, env_fn)?;
generate::git::run(&data)
}
"defaults" => {
let data: DefaultsConfig =
parse_task_config(maybe_data, &self.name, false, env_fn)?;
tasks::defaults::run(data, task_tempdir)
}
"self" => {
let data: UpdateSelfOptions =
parse_task_config(maybe_data, &self.name, true, env_fn)?;
tasks::update_self::run(&data)
}
_ => Err(eyre!("This run_lib is invalid or not yet implemented.")),
}
.map_err(|e| E::TaskError {
name: self.name.clone(),
lib: lib.to_string(),
source: e,
})?;
return Ok(status);
}
if let Some(mut cmd) = self.config.run_cmd.clone() {
debug!("Running '{name}' run command.");
for s in &mut cmd {
*s = env_fn(s)?;
}
if self.run_command(CommandType::Run, &cmd, env, task_tempdir)? {
return Ok(TaskStatus::Passed);
}
return Ok(TaskStatus::Skipped);
}
Err(E::MissingCmd {
name: self.name.clone(),
})
}
pub fn run_command(
&self,
command_type: CommandType,
cmd: &[String],
env: &HashMap<String, String>,
task_tempdir: &Utf8Path,
) -> Result<bool, E> {
let now = Instant::now();
let task_output_file = task_tempdir.join("task_stdout_stderr.txt");
let output = cmd_log(
Level::DEBUG,
cmd.first().ok_or(E::EmptyCmd)?,
cmd.get(1..).unwrap_or(&[]),
)
.dir(task_tempdir)
.full_env(env)
.stderr_path(&task_output_file)
.unchecked()
.run_with_path(&task_output_file);
let output = output.map_err(|e| {
let suggestion = match e.kind() {
std::io::ErrorKind::PermissionDenied => format!(
"\n Suggestion: Try making the file executable with `chmod +x {path}`",
path = cmd.first().map_or("", String::as_str)
),
_ => String::new(),
};
E::CmdFailed {
command_type,
name: self.name.clone(),
cmd: cmd.into(),
source: e,
suggestion,
}
})?;
let elapsed_time = now.elapsed();
let command_result = match output.status.code() {
Some(0) => Ok(true),
Some(204) => Ok(false),
Some(code) => Err(E::CmdNonZero {
name: self.name.clone(),
command_type,
cmd: cmd.to_owned(),
output_file: task_output_file,
code,
}),
None => Err(E::CmdTerminated {
command_type,
name: self.name.clone(),
cmd: cmd.to_owned(),
output_file: task_output_file,
}),
};
self.log_command_output(command_type, command_result.is_ok(), &output, elapsed_time);
command_result
}
pub fn log_command_output(
&self,
command_type: CommandType,
command_success: bool,
output: &Output,
elapsed_time: Duration,
) {
let name = &self.name;
let level = if command_success {
Level::DEBUG
} else {
Level::ERROR
};
log!(
level,
"Task '{name}' {command_type} ran in {elapsed_time:?} with {}",
output.status
);
if !output.stdout.is_empty() {
log!(
level,
"Task '{name}' {command_type} stdout:\n<<<\n{}>>>\n",
String::from_utf8_lossy(&output.stdout),
);
}
if !output.stderr.is_empty() {
log!(
level,
"Task '{name}' {command_type} command stderr:\n<<<\n{}>>>\n",
String::from_utf8_lossy(&output.stderr),
);
}
}
}
fn parse_task_config<F, T: ResolveEnv + Default + for<'de> serde::Deserialize<'de>>(
maybe_data: Option<serde_yaml::Value>,
task_name: &str,
has_default: bool,
env_fn: F,
) -> Result<T, E>
where
F: Fn(&str) -> Result<String, E>,
{
let data = match maybe_data {
Some(data) => data,
None if has_default => return Ok(T::default()),
None => {
return Err(E::TaskDataRequired {
task: task_name.to_owned(),
});
}
};
let mut raw_opts: T =
serde_yaml::from_value(data).map_err(|e| E::DeserializeError { source: e })?;
raw_opts.resolve_env(env_fn)?;
Ok(raw_opts)
}