From 058944f11fcd185b914681b0ed73fa2f5bdc8bf8 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Fri, 13 Dec 2024 22:50:09 +0000 Subject: [PATCH] feat(tasks): optional automatic outputs Fixes #2621 --- src/cli/run.rs | 14 ++- src/cli/tasks/info.rs | 5 +- src/task/mod.rs | 43 ++++++--- src/task/task_sources.rs | 191 +++++++++++++++++++++++++++++++++++++++ tasks.toml | 4 +- 5 files changed, 237 insertions(+), 20 deletions(-) create mode 100644 src/task/task_sources.rs diff --git a/src/cli/run.rs b/src/cli/run.rs index 43aed1ab6c..91ca353320 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -586,14 +586,15 @@ impl Run { } fn sources_are_fresh(&self, task: &Task) -> Result { - if task.sources.is_empty() && task.outputs.is_empty() { + let outputs = task.outputs.paths(task); + if task.sources.is_empty() && outputs.is_empty() { return Ok(false); } let run = || -> Result { let mut sources = task.sources.clone(); sources.push(task.config_source.to_string_lossy().to_string()); let sources = self.get_last_modified(&self.cwd(task)?, &sources)?; - let outputs = self.get_last_modified(&self.cwd(task)?, &task.outputs)?; + let outputs = self.get_last_modified(&self.cwd(task)?, &outputs)?; trace!("sources: {sources:?}, outputs: {outputs:?}"); match (sources, outputs) { (Some(sources), Some(outputs)) => Ok(sources < outputs), @@ -658,7 +659,12 @@ impl Run { if task.sources.is_empty() { return Ok(()); } - // TODO + if task.outputs.is_auto() { + for p in task.outputs.paths(task) { + debug!("touching auto output file: {p}"); + file::touch_file(&PathBuf::from(&p))?; + } + } Ok(()) } @@ -913,4 +919,4 @@ pub fn get_task_lists(args: &[String], prompt: bool) -> Result> { }) .flatten_ok() .collect() -} +} \ No newline at end of file diff --git a/src/cli/tasks/info.rs b/src/cli/tasks/info.rs index c497cf1337..6fc94ca979 100644 --- a/src/cli/tasks/info.rs +++ b/src/cli/tasks/info.rs @@ -69,8 +69,9 @@ impl TasksInfo { if !task.sources.is_empty() { info::inline_section("Sources", task.sources.join(", "))?; } - if !task.outputs.is_empty() { - info::inline_section("Outputs", task.outputs.join(", "))?; + let outputs = task.outputs.paths(task); + if !outputs.is_empty() { + info::inline_section("Outputs", outputs.join(", "))?; } if let Some(file) = &task.file { info::inline_section("File", display_path(file))?; diff --git a/src/task/mod.rs b/src/task/mod.rs index 4848e9ada8..1262656a62 100644 --- a/src/task/mod.rs +++ b/src/task/mod.rs @@ -27,14 +27,16 @@ use xx::regex; mod deps; mod task_dep; mod task_script_parser; +pub mod task_sources; use crate::config::config_file::ConfigFile; use crate::file::display_path; use crate::ui::style; pub use deps::Deps; use task_dep::TaskDep; +use task_sources::TaskOutputs; -#[derive(Debug, Clone, Eq, PartialEq, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct Task { #[serde(skip)] pub name: String, @@ -65,7 +67,7 @@ pub struct Task { #[serde(default)] pub sources: Vec, #[serde(default)] - pub outputs: Vec, + pub outputs: TaskOutputs, #[serde(default)] pub shell: Option, #[serde(default)] @@ -91,6 +93,10 @@ pub struct Task { // file type #[serde(default)] pub file: Option, + #[serde(skip)] + pub tera: tera::Tera, + #[serde(skip)] + pub tera_ctx: tera::Context, } #[derive(Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -156,9 +162,10 @@ impl Task { map }); let info = toml::Value::Table(info); - let mut tera_ctx = BASE_CONTEXT.clone(); - tera_ctx.insert("config_root", &config_root); - let p = TomlParser::new(&info, get_tera(Some(config_root)), tera_ctx); + task.tera_ctx = BASE_CONTEXT.clone(); + task.tera_ctx.insert("config_root", &config_root); + task.tera = get_tera(Some(config_root)); + let p = TomlParser::new(&info, task.tera.clone(), task.tera_ctx.clone()); // trace!("task info: {:#?}", info); task.hide = !file::is_executable(path) || p.parse_bool("hide").unwrap_or_default(); @@ -170,7 +177,8 @@ impl Task { .unwrap_or_default(); task.description = p.parse_str("description")?.unwrap_or_default(); task.sources = p.parse_array("sources")?.unwrap_or_default(); - task.outputs = p.parse_array("outputs")?.unwrap_or_default(); + task.outputs = info.get("outputs").map(|to| to.into()).unwrap_or_default(); + task.outputs.render(&mut task.tera, &task.tera_ctx)?; task.depends = p.parse_array("depends")?.unwrap_or_default(); task.depends_post = p.parse_array("depends_post")?.unwrap_or_default(); task.wait_for = p.parse_array("wait_for")?.unwrap_or_default(); @@ -337,14 +345,14 @@ impl Task { style::ereset() + &style::estyle(self.prefix()).fg(COLORS[idx]).to_string() } + pub fn tera_render(&self, s: &str) -> Result { + let mut tera = get_tera(self.config_root.as_deref()); + Ok(tera.render_str(s, &self.tera_ctx)?) + } + pub fn dir(&self) -> Result> { let render = |dir| { - let mut tera = get_tera(self.config_root.as_deref()); - let mut ctx = BASE_CONTEXT.clone(); - if let Some(config_root) = &self.config_root { - ctx.insert("config_root", config_root); - } - let dir = tera.render_str(dir, &ctx)?; + let dir = self.tera_render(dir)?; let dir = file::replace_path(&dir); if dir.is_absolute() { Ok(Some(dir.to_path_buf())) @@ -441,7 +449,7 @@ impl Default for Task { hide: false, raw: false, sources: vec![], - outputs: vec![], + outputs: Default::default(), shell: None, silent: false, run: vec![], @@ -449,6 +457,8 @@ impl Default for Task { args: vec![], file: None, quiet: false, + tera: get_tera(None), + tera_ctx: BASE_CONTEXT.clone(), } } } @@ -492,6 +502,13 @@ impl Hash for Task { } } +impl Eq for Task {} +impl PartialEq for Task { + fn eq(&self, other: &Self) -> bool { + self.name == other.name && self.args == other.args + } +} + impl TreeItem for (&Graph, NodeIndex) { type Child = Self; diff --git a/src/task/task_sources.rs b/src/task/task_sources.rs new file mode 100644 index 0000000000..0f31fce905 --- /dev/null +++ b/src/task/task_sources.rs @@ -0,0 +1,191 @@ +use crate::dirs; +use crate::task::Task; +use serde::ser::{SerializeMap, SerializeSeq}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::hash::{DefaultHasher, Hash, Hasher}; + +#[derive(Debug, Clone, Eq, PartialEq, strum::EnumIs)] +pub enum TaskOutputs { + Files(Vec), + Auto, +} + +impl Default for TaskOutputs { + fn default() -> Self { + TaskOutputs::Files(vec![]) + } +} + +impl TaskOutputs { + pub fn paths(&self, task: &Task) -> Vec { + match self { + TaskOutputs::Files(files) => files.clone(), + TaskOutputs::Auto => vec![self.auto_path(task)], + } + } + + fn auto_path(&self, task: &Task) -> String { + let mut hasher = DefaultHasher::new(); + task.hash(&mut hasher); + task.config_source.hash(&mut hasher); + let hash = format!("{:x}", hasher.finish()); + dirs::STATE + .join("task-auto-outputs") + .join(&hash) + .to_string_lossy() + .to_string() + } + + pub fn render(&mut self, tera: &mut tera::Tera, ctx: &tera::Context) -> eyre::Result<()> { + match self { + TaskOutputs::Files(files) => { + for file in files.iter_mut() { + *file = tera.render_str(file, ctx)?; + } + } + TaskOutputs::Auto => {} + } + Ok(()) + } +} + +impl From<&toml::Value> for TaskOutputs { + fn from(value: &toml::Value) -> Self { + match value { + toml::Value::String(file) => TaskOutputs::Files(vec![file.to_string()]), + toml::Value::Array(files) => TaskOutputs::Files( + files + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(), + ), + toml::Value::Table(table) => { + let auto = table + .get("auto") + .and_then(|v| v.as_bool()) + .unwrap_or_default(); + if auto { + TaskOutputs::Auto + } else { + TaskOutputs::default() + } + } + _ => TaskOutputs::default(), + } + } +} + +impl<'de> Deserialize<'de> for TaskOutputs { + fn deserialize>(deserializer: D) -> Result { + struct TaskOutputsVisitor; + + impl<'de> serde::de::Visitor<'de> for TaskOutputsVisitor { + type Value = TaskOutputs; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string, a sequence of strings, or a map") + } + + fn visit_str(self, value: &str) -> Result { + Ok(TaskOutputs::Files(vec![value.to_string()])) + } + + fn visit_seq>( + self, + mut seq: A, + ) -> Result { + let mut files = vec![]; + while let Some(file) = seq.next_element()? { + files.push(file); + } + Ok(TaskOutputs::Files(files)) + } + + fn visit_map>( + self, + mut map: A, + ) -> Result { + if let Some(key) = map.next_key::()? { + if key == "auto" { + if map.next_value::()? { + Ok(TaskOutputs::Auto) + } else { + Ok(TaskOutputs::default()) + } + } else { + Err(serde::de::Error::custom("Invalid TaskOutputs map")) + } + } else { + Ok(TaskOutputs::default()) + } + } + } + + deserializer.deserialize_any(TaskOutputsVisitor) + } +} + +impl Serialize for TaskOutputs { + fn serialize(&self, serializer: S) -> Result { + match self { + TaskOutputs::Files(files) => { + let mut seq = serializer.serialize_seq(Some(files.len()))?; + for file in files { + seq.serialize_element(file)?; + } + seq.end() + } + TaskOutputs::Auto => { + let mut m = serializer.serialize_map(Some(1))?; + m.serialize_entry("auto", &true)?; + m.end() + } + } + } +} + +mod tests { + #[allow(unused_imports)] + use super::*; + + #[test] + fn test_task_outputs_from_toml() { + let value: toml::Table = toml::from_str("outputs = \"file1\"").unwrap(); + let value = value.get("outputs").unwrap(); + let outputs = TaskOutputs::from(value); + assert_eq!(outputs, TaskOutputs::Files(vec!["file1".to_string()])); + + let value: toml::Table = toml::from_str("outputs = [\"file1\"]").unwrap(); + let value = value.get("outputs").unwrap(); + let outputs = TaskOutputs::from(value); + assert_eq!(outputs, TaskOutputs::Files(vec!["file1".to_string()])); + + let value: toml::Table = toml::from_str("outputs = { auto = true }").unwrap(); + let value = value.get("outputs").unwrap(); + let outputs = TaskOutputs::from(value); + assert_eq!(outputs, TaskOutputs::Auto); + } + + #[test] + fn test_task_outputs_serialize() { + let outputs = TaskOutputs::Files(vec!["file1".to_string()]); + let serialized = serde_json::to_string(&outputs).unwrap(); + assert_eq!(serialized, "[\"file1\"]"); + + let outputs = TaskOutputs::Auto; + let serialized = serde_json::to_string(&outputs).unwrap(); + assert_eq!(serialized, "{\"auto\":true}"); + } + + #[test] + fn test_task_outputs_deserialize() { + let deserialized: TaskOutputs = serde_json::from_str("\"file1\"").unwrap(); + assert_eq!(deserialized, TaskOutputs::Files(vec!["file1".to_string()])); + + let deserialized: TaskOutputs = serde_json::from_str("[\"file1\"]").unwrap(); + assert_eq!(deserialized, TaskOutputs::Files(vec!["file1".to_string()])); + + let deserialized: TaskOutputs = serde_json::from_str("{ \"auto\": true }").unwrap(); + assert_eq!(deserialized, TaskOutputs::Auto); + } +} diff --git a/tasks.toml b/tasks.toml index 1a2249c48a..f5d3d450f6 100644 --- a/tasks.toml +++ b/tasks.toml @@ -1,3 +1,5 @@ +#:schema ./schema/mise-task.json + clean = "cargo clean" release = "cargo release" signal-test = "node ./test/fixtures/signal-test.js" @@ -117,4 +119,4 @@ description = "run e2e tests inside of development docker container" run = "mise tasks run docker:mise run test:e2e" ["test:shuffle"] -run = "cargo +nightly test --all-features -- -Z unstable-options --shuffle" +run = "echo cargo +nightly test --all-features -- -Z unstable-options --shuffle"