pub mod backend;
pub mod cache;
pub mod captures;
pub mod executor;
pub mod graph;
pub mod index;
pub mod output_refs;
pub mod process_registry;
pub use backend::{
BackendFactory, HostBackend, TaskBackend, TaskExecutionContext, create_backend,
create_backend_with_factory, should_use_dagger,
};
pub use executor::*;
pub use graph::*;
pub use index::{IndexedTask, TaskIndex, TaskPath, WorkspaceTask};
pub use output_refs::{
OutputRefResolver, TaskOutputField, TaskOutputRef, has_output_refs, process_output_refs,
};
pub use process_registry::global_registry;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
fn default_hermetic() -> bool {
true
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum TaskCacheMode {
#[default]
Never,
Read,
Write,
ReadWrite,
}
impl TaskCacheMode {
#[must_use]
pub const fn allows_read(self) -> bool {
matches!(self, Self::Read | Self::ReadWrite)
}
#[must_use]
pub const fn allows_write(self) -> bool {
matches!(self, Self::Write | Self::ReadWrite)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct TaskCachePolicy {
#[serde(default)]
pub mode: TaskCacheMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_age: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum CaptureSource {
#[default]
Stdout,
Stderr,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TaskCapture {
pub pattern: String,
#[serde(default)]
pub source: CaptureSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TaskCaptureRef {
pub cuenv_capture_ref: bool,
pub cuenv_task: String,
pub cuenv_capture: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum ScriptShell {
#[default]
Bash,
Sh,
Zsh,
Fish,
Nu,
Powershell,
Pwsh,
Python,
Node,
Ruby,
Perl,
}
impl ScriptShell {
#[must_use]
pub fn command_and_flag(&self) -> (&'static str, &'static str) {
match self {
ScriptShell::Bash => ("bash", "-c"),
ScriptShell::Sh => ("sh", "-c"),
ScriptShell::Zsh => ("zsh", "-c"),
ScriptShell::Fish => ("fish", "-c"),
ScriptShell::Nu => ("nu", "-c"),
ScriptShell::Powershell => ("powershell", "-Command"),
ScriptShell::Pwsh => ("pwsh", "-Command"),
ScriptShell::Python => ("python", "-c"),
ScriptShell::Node => ("node", "-e"),
ScriptShell::Ruby => ("ruby", "-e"),
ScriptShell::Perl => ("perl", "-e"),
}
}
#[must_use]
pub fn supports_shell_options(&self) -> bool {
matches!(self, ScriptShell::Bash | ScriptShell::Sh | ScriptShell::Zsh)
}
#[must_use]
pub fn supports_pipefail(&self) -> bool {
matches!(self, ScriptShell::Bash | ScriptShell::Zsh)
}
#[must_use]
pub(crate) fn from_command(command: &str) -> Option<Self> {
let file_name = Path::new(command)
.file_name()?
.to_str()?
.to_ascii_lowercase();
let normalized = file_name.strip_suffix(".exe").unwrap_or(&file_name);
match normalized {
"bash" => Some(Self::Bash),
"sh" => Some(Self::Sh),
"zsh" => Some(Self::Zsh),
"fish" => Some(Self::Fish),
"nu" => Some(Self::Nu),
"powershell" => Some(Self::Powershell),
"pwsh" => Some(Self::Pwsh),
"python" => Some(Self::Python),
"node" => Some(Self::Node),
"ruby" => Some(Self::Ruby),
"perl" => Some(Self::Perl),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct ShellOptions {
#[serde(default = "default_true")]
pub errexit: bool,
#[serde(default = "default_true")]
pub nounset: bool,
#[serde(default = "default_true")]
pub pipefail: bool,
#[serde(default)]
pub xtrace: bool,
}
fn default_true() -> bool {
true
}
impl Default for ShellOptions {
fn default() -> Self {
Self {
errexit: true,
nounset: true,
pipefail: true,
xtrace: false,
}
}
}
impl ShellOptions {
#[must_use]
pub fn to_set_commands(&self) -> String {
let mut opts = Vec::new();
if self.errexit {
opts.push("-e");
}
if self.nounset {
opts.push("-u");
}
if self.pipefail {
opts.push("-o pipefail");
}
if self.xtrace {
opts.push("-x");
}
if opts.is_empty() {
String::new()
} else {
format!("set {}\n", opts.join(" "))
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Shell {
pub command: Option<String>,
pub flag: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TaskCommandSpec {
pub program: String,
pub args: Vec<String>,
}
#[derive(Debug, Clone)]
struct EffectiveScriptShell {
command: String,
flag: String,
display_name: String,
supports_shell_options: bool,
supports_pipefail: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Mapping {
pub from: String,
pub to: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum Input {
Path(String),
Project(ProjectReference),
Task(TaskOutput),
}
impl Input {
pub fn as_path(&self) -> Option<&String> {
match self {
Input::Path(path) => Some(path),
Input::Project(_) | Input::Task(_) => None,
}
}
pub fn as_project(&self) -> Option<&ProjectReference> {
match self {
Input::Project(reference) => Some(reference),
Input::Path(_) | Input::Task(_) => None,
}
}
pub fn as_task_output(&self) -> Option<&TaskOutput> {
match self {
Input::Task(output) => Some(output),
Input::Path(_) | Input::Project(_) => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProjectReference {
pub project: String,
pub task: String,
pub map: Vec<Mapping>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TaskOutput {
pub task: String,
#[serde(default)]
pub map: Option<Vec<Mapping>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct SourceLocation {
pub file: String,
pub line: u32,
pub column: u32,
}
impl SourceLocation {
pub fn directory(&self) -> Option<&str> {
std::path::Path::new(&self.file)
.parent()
.and_then(|p| p.to_str())
.filter(|s| !s.is_empty())
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct TaskDependency {
#[serde(rename = "_name")]
pub name: String,
#[serde(flatten)]
_rest: serde_json::Value,
}
impl<'de> serde::Deserialize<'de> for TaskDependency {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{self, Visitor};
struct TaskDependencyVisitor;
impl<'de> Visitor<'de> for TaskDependencyVisitor {
type Value = TaskDependency;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string or an object with _name field")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(TaskDependency::from_name(value))
}
fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(TaskDependency::from_name(value))
}
fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
let value: serde_json::Value =
serde::Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))?;
let name = value
.get("_name")
.and_then(|v| v.as_str())
.ok_or_else(|| de::Error::missing_field("_name"))?
.to_string();
Ok(TaskDependency { name, _rest: value })
}
}
deserializer.deserialize_any(TaskDependencyVisitor)
}
}
impl TaskDependency {
#[must_use]
pub fn from_name(name: impl Into<String>) -> Self {
Self {
name: name.into(),
_rest: serde_json::Value::Null,
}
}
#[must_use]
pub fn task_name(&self) -> &str {
&self.name
}
pub fn matches(&self, name: &str) -> bool {
self.name == name
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct Task {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell: Option<Shell>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script: Option<String>,
#[serde(
default,
rename = "scriptShell",
skip_serializing_if = "Option::is_none"
)]
pub script_shell: Option<ScriptShell>,
#[serde(
default,
rename = "shellOptions",
skip_serializing_if = "Option::is_none"
)]
pub shell_options: Option<ShellOptions>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, serde_json::Value>,
#[serde(default)]
pub dagger: Option<DaggerTaskConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<crate::manifest::Runtime>,
#[serde(default = "default_hermetic")]
pub hermetic: bool,
#[serde(default, rename = "dependsOn")]
pub depends_on: Vec<TaskDependency>,
#[serde(default)]
pub inputs: Vec<Input>,
#[serde(default)]
pub outputs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache: Option<TaskCachePolicy>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub params: Option<TaskParams>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry: Option<RetryConfig>,
#[serde(default, rename = "continueOnError")]
pub continue_on_error: bool,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub captures: HashMap<String, TaskCapture>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_root: Option<std::path::PathBuf>,
#[serde(default, rename = "_source", skip_serializing_if = "Option::is_none")]
pub source: Option<SourceLocation>,
#[serde(default, rename = "dir", skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RetryConfig {
#[serde(default = "default_retry_attempts")]
pub attempts: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delay: Option<String>,
}
fn default_retry_attempts() -> u32 {
3
}
impl<'de> serde::Deserialize<'de> for Task {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
struct TaskHelper {
#[serde(default)]
shell: Option<Shell>,
#[serde(default)]
command: Option<String>,
#[serde(default)]
script: Option<String>,
#[serde(default, rename = "scriptShell")]
script_shell: Option<ScriptShell>,
#[serde(default, rename = "shellOptions")]
shell_options: Option<ShellOptions>,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, serde_json::Value>,
#[serde(default)]
dagger: Option<DaggerTaskConfig>,
#[serde(default)]
runtime: Option<crate::manifest::Runtime>,
#[serde(default = "default_hermetic")]
hermetic: bool,
#[serde(default, rename = "dependsOn")]
depends_on: Vec<TaskDependency>,
#[serde(default)]
inputs: Vec<Input>,
#[serde(default)]
outputs: Vec<String>,
#[serde(default)]
cache: Option<TaskCachePolicy>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
params: Option<TaskParams>,
#[serde(default)]
labels: Vec<String>,
#[serde(default)]
timeout: Option<String>,
#[serde(default)]
retry: Option<RetryConfig>,
#[serde(default, rename = "continueOnError")]
continue_on_error: bool,
#[serde(default)]
captures: HashMap<String, TaskCapture>,
#[serde(default)]
task_ref: Option<String>,
#[serde(default)]
project_root: Option<std::path::PathBuf>,
#[serde(default, rename = "_source")]
source: Option<SourceLocation>,
#[serde(default, rename = "dir")]
directory: Option<String>,
}
let helper = TaskHelper::deserialize(deserializer)?;
let has_command = helper.command.as_ref().is_some_and(|c| !c.is_empty());
let has_script = helper.script.is_some();
let has_task_ref = helper.task_ref.is_some();
if !has_command && !has_script && !has_task_ref {
return Err(serde::de::Error::custom(
"Task must have either 'command', 'script', or 'task_ref' field",
));
}
Ok(Task {
shell: helper.shell,
command: helper.command.unwrap_or_default(),
script: helper.script,
script_shell: helper.script_shell,
shell_options: helper.shell_options,
args: helper.args,
env: helper.env,
dagger: helper.dagger,
runtime: helper.runtime,
hermetic: helper.hermetic,
depends_on: helper.depends_on,
inputs: helper.inputs,
outputs: helper.outputs,
cache: helper.cache,
description: helper.description,
params: helper.params,
labels: helper.labels,
timeout: helper.timeout,
retry: helper.retry,
continue_on_error: helper.continue_on_error,
captures: helper.captures,
task_ref: helper.task_ref,
project_root: helper.project_root,
source: helper.source,
directory: helper.directory,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct DaggerTaskConfig {
#[serde(default)]
pub image: Option<String>,
#[serde(default)]
pub from: Option<String>,
#[serde(default)]
pub secrets: Option<Vec<DaggerSecret>>,
#[serde(default)]
pub cache: Option<Vec<DaggerCacheMount>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DaggerSecret {
pub name: String,
#[serde(default)]
pub path: Option<String>,
#[serde(default, rename = "envVar")]
pub env_var: Option<String>,
pub resolver: crate::secrets::Secret,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DaggerCacheMount {
pub path: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct TaskParams {
#[serde(default)]
pub positional: Vec<ParamDef>,
#[serde(flatten, default)]
pub named: HashMap<String, ParamDef>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum ParamType {
#[default]
String,
Bool,
Int,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ParamDef {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub default: Option<String>,
#[serde(default, rename = "type")]
pub param_type: ParamType,
#[serde(default)]
pub short: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ResolvedArgs {
pub positional: Vec<String>,
pub named: HashMap<String, String>,
}
impl ResolvedArgs {
pub fn new() -> Self {
Self::default()
}
pub fn interpolate(&self, template: &str) -> String {
let mut result = template.to_string();
for (i, value) in self.positional.iter().enumerate() {
let placeholder = format!("{{{{{}}}}}", i);
result = result.replace(&placeholder, value);
}
for (name, value) in &self.named {
let placeholder = format!("{{{{{}}}}}", name);
result = result.replace(&placeholder, value);
}
result
}
pub fn interpolate_args(&self, args: &[String]) -> Vec<String> {
args.iter().map(|arg| self.interpolate(arg)).collect()
}
}
impl Default for Task {
fn default() -> Self {
Self {
shell: None,
command: String::new(),
script: None,
script_shell: None,
shell_options: None,
args: vec![],
env: HashMap::new(),
dagger: None,
runtime: None,
hermetic: true, depends_on: vec![],
inputs: vec![],
outputs: vec![],
cache: None,
description: None,
params: None,
labels: vec![],
timeout: None,
retry: None,
continue_on_error: false,
captures: HashMap::new(),
task_ref: None,
project_root: None,
source: None,
directory: None,
}
}
}
impl Task {
pub fn from_task_ref(ref_str: &str) -> Self {
Self {
task_ref: Some(ref_str.to_string()),
description: Some(format!("Reference to {}", ref_str)),
..Default::default()
}
}
pub fn is_task_ref(&self) -> bool {
self.task_ref.is_some()
}
pub fn dependency_names(&self) -> impl Iterator<Item = &str> {
self.depends_on.iter().map(|d| d.task_name())
}
#[must_use]
pub fn cache_policy(&self) -> TaskCachePolicy {
self.cache.clone().unwrap_or_default()
}
pub fn description(&self) -> &str {
self.description
.as_deref()
.unwrap_or("No description provided")
}
pub(crate) fn command_spec<F>(&self, mut resolve_command: F) -> crate::Result<TaskCommandSpec>
where
F: FnMut(&str) -> String,
{
if let Some(script) = &self.script {
let shell = self.effective_script_shell();
let script = self.prepare_script(script, &shell)?;
return Ok(TaskCommandSpec {
program: resolve_command(&shell.command),
args: vec![shell.flag, script],
});
}
if let Some(shell) = &self.shell
&& let (Some(shell_command), Some(shell_flag)) = (&shell.command, &shell.flag)
{
let full_command = if self.command.is_empty() {
self.args.join(" ")
} else if self.args.is_empty() {
resolve_command(&self.command)
} else {
let resolved_command = resolve_command(&self.command);
format!("{} {}", resolved_command, self.args.join(" "))
};
return Ok(TaskCommandSpec {
program: resolve_command(shell_command),
args: vec![shell_flag.clone(), full_command],
});
}
Ok(TaskCommandSpec {
program: resolve_command(&self.command),
args: self.args.clone(),
})
}
pub fn iter_path_inputs(&self) -> impl Iterator<Item = &String> {
self.inputs.iter().filter_map(Input::as_path)
}
pub fn iter_project_refs(&self) -> impl Iterator<Item = &ProjectReference> {
self.inputs.iter().filter_map(Input::as_project)
}
pub fn iter_task_outputs(&self) -> impl Iterator<Item = &TaskOutput> {
self.inputs.iter().filter_map(Input::as_task_output)
}
pub fn collect_path_inputs_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
self.iter_path_inputs()
.map(|path| apply_prefix(prefix, path))
.collect()
}
pub fn collect_project_destinations_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
self.iter_project_refs()
.flat_map(|reference| reference.map.iter().map(|m| apply_prefix(prefix, &m.to)))
.collect()
}
pub fn collect_all_inputs_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
let mut inputs = self.collect_path_inputs_with_prefix(prefix);
inputs.extend(self.collect_project_destinations_with_prefix(prefix));
inputs
}
fn effective_script_shell(&self) -> EffectiveScriptShell {
if let Some(script_shell) = self.script_shell {
let (command, flag) = script_shell.command_and_flag();
return EffectiveScriptShell {
command: command.to_string(),
flag: flag.to_string(),
display_name: command.to_string(),
supports_shell_options: script_shell.supports_shell_options(),
supports_pipefail: script_shell.supports_pipefail(),
};
}
if let Some(shell) = &self.shell {
let command = shell.command.clone().unwrap_or_else(|| "bash".to_string());
let flag = shell.flag.clone().unwrap_or_else(|| "-c".to_string());
let (supports_shell_options, supports_pipefail) = ScriptShell::from_command(&command)
.map(|script_shell| {
(
script_shell.supports_shell_options(),
script_shell.supports_pipefail(),
)
})
.unwrap_or((false, false));
return EffectiveScriptShell {
display_name: command.clone(),
command,
flag,
supports_shell_options,
supports_pipefail,
};
}
let default_shell = ScriptShell::default();
let (command, flag) = default_shell.command_and_flag();
EffectiveScriptShell {
command: command.to_string(),
flag: flag.to_string(),
display_name: command.to_string(),
supports_shell_options: default_shell.supports_shell_options(),
supports_pipefail: default_shell.supports_pipefail(),
}
}
fn prepare_script(&self, script: &str, shell: &EffectiveScriptShell) -> crate::Result<String> {
let Some(shell_options) = self.shell_options else {
return Ok(script.to_string());
};
if !shell.supports_shell_options {
return Err(crate::Error::configuration(format!(
"Task uses shellOptions with unsupported script shell '{}'. \
Use scriptShell 'bash', 'sh', or 'zsh'.",
shell.display_name
)));
}
if shell_options.pipefail && !shell.supports_pipefail {
return Err(crate::Error::configuration(format!(
"Task uses shellOptions.pipefail with unsupported script shell '{}'. \
Disable pipefail or use scriptShell 'bash' or 'zsh'.",
shell.display_name
)));
}
let set_commands = shell_options.to_set_commands();
if set_commands.is_empty() {
return Ok(script.to_string());
}
Ok(format!("{set_commands}{script}"))
}
}
impl crate::AffectedBy for Task {
fn is_affected_by(&self, changed_files: &[std::path::PathBuf], project_root: &Path) -> bool {
let inputs: Vec<_> = self.iter_path_inputs().collect();
if inputs.is_empty() {
return true;
}
inputs
.iter()
.any(|pattern| crate::matches_pattern(changed_files, project_root, pattern))
}
fn input_patterns(&self) -> Vec<&str> {
self.iter_path_inputs().map(String::as_str).collect()
}
}
fn apply_prefix(prefix: Option<&Path>, value: &str) -> String {
if let Some(prefix) = prefix {
prefix.join(value).to_string_lossy().to_string()
} else {
value.to_string()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TaskGroup {
#[serde(rename = "type")]
pub type_: String,
#[serde(default, rename = "dependsOn")]
pub depends_on: Vec<TaskDependency>,
#[serde(default, rename = "maxConcurrency")]
pub max_concurrency: Option<u32>,
#[serde(default)]
pub description: Option<String>,
#[serde(flatten)]
pub children: HashMap<String, TaskNode>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum TaskNode {
Task(Box<Task>),
Group(TaskGroup),
Sequence(Vec<TaskNode>),
}
#[deprecated(since = "0.26.0", note = "Use TaskNode instead")]
pub type TaskDefinition = TaskNode;
#[deprecated(since = "0.26.0", note = "Use Vec<TaskNode> directly")]
pub type TaskList = Vec<TaskNode>;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Tasks {
#[serde(flatten)]
pub tasks: HashMap<String, TaskNode>,
}
impl Tasks {
pub fn new() -> Self {
Self::default()
}
pub fn get(&self, name: &str) -> Option<&TaskNode> {
self.tasks.get(name)
}
pub fn list_tasks(&self) -> Vec<&str> {
self.tasks.keys().map(|s| s.as_str()).collect()
}
pub fn contains(&self, name: &str) -> bool {
self.tasks.contains_key(name)
}
}
impl TaskNode {
pub fn is_task(&self) -> bool {
matches!(self, TaskNode::Task(_))
}
pub fn is_group(&self) -> bool {
matches!(self, TaskNode::Group(_))
}
pub fn is_sequence(&self) -> bool {
matches!(self, TaskNode::Sequence(_))
}
pub fn as_task(&self) -> Option<&Task> {
match self {
TaskNode::Task(task) => Some(task.as_ref()),
_ => None,
}
}
pub fn as_group(&self) -> Option<&TaskGroup> {
match self {
TaskNode::Group(group) => Some(group),
_ => None,
}
}
pub fn as_sequence(&self) -> Option<&Vec<TaskNode>> {
match self {
TaskNode::Sequence(seq) => Some(seq),
_ => None,
}
}
pub fn depends_on(&self) -> &[TaskDependency] {
match self {
TaskNode::Task(task) => &task.depends_on,
TaskNode::Group(group) => &group.depends_on,
TaskNode::Sequence(_) => &[], }
}
pub fn description(&self) -> Option<&str> {
match self {
TaskNode::Task(task) => task.description.as_deref(),
TaskNode::Group(group) => group.description.as_deref(),
TaskNode::Sequence(_) => None, }
}
#[deprecated(since = "0.26.0", note = "Use is_task() instead")]
pub fn is_single(&self) -> bool {
self.is_task()
}
#[deprecated(since = "0.26.0", note = "Use as_task() instead")]
pub fn as_single(&self) -> Option<&Task> {
self.as_task()
}
#[deprecated(since = "0.26.0", note = "Use is_sequence() instead")]
pub fn is_list(&self) -> bool {
self.is_sequence()
}
#[deprecated(since = "0.26.0", note = "Use as_sequence() instead")]
pub fn as_list(&self) -> Option<&Vec<TaskNode>> {
self.as_sequence()
}
}
impl TaskGroup {
pub fn len(&self) -> usize {
self.children.len()
}
pub fn is_empty(&self) -> bool {
self.children.is_empty()
}
}
impl crate::AffectedBy for TaskGroup {
fn is_affected_by(&self, changed_files: &[std::path::PathBuf], project_root: &Path) -> bool {
self.children
.values()
.any(|node| node.is_affected_by(changed_files, project_root))
}
fn input_patterns(&self) -> Vec<&str> {
self.children
.values()
.flat_map(|node| node.input_patterns())
.collect()
}
}
impl crate::AffectedBy for TaskNode {
fn is_affected_by(&self, changed_files: &[std::path::PathBuf], project_root: &Path) -> bool {
match self {
TaskNode::Task(task) => task.is_affected_by(changed_files, project_root),
TaskNode::Group(group) => group.is_affected_by(changed_files, project_root),
TaskNode::Sequence(seq) => seq
.iter()
.any(|node| node.is_affected_by(changed_files, project_root)),
}
}
fn input_patterns(&self) -> Vec<&str> {
match self {
TaskNode::Task(task) => task.input_patterns(),
TaskNode::Group(group) => group.input_patterns(),
TaskNode::Sequence(seq) => seq.iter().flat_map(|node| node.input_patterns()).collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_task_default_values() {
let task = Task {
command: "echo".to_string(),
..Default::default()
};
assert!(task.shell.is_none());
assert_eq!(task.command, "echo");
assert_eq!(task.description(), "No description provided");
assert!(task.args.is_empty());
assert!(task.hermetic); }
#[test]
fn test_task_deserialization() {
let json = r#"{
"command": "echo",
"args": ["Hello", "World"]
}"#;
let task: Task = serde_json::from_str(json).unwrap();
assert_eq!(task.command, "echo");
assert_eq!(task.args, vec!["Hello", "World"]);
assert!(task.shell.is_none()); }
#[test]
fn test_task_script_deserialization() {
let json = r#"{
"script": "echo hello",
"inputs": ["src/main.rs"]
}"#;
let task: Task = serde_json::from_str(json).unwrap();
assert!(task.command.is_empty()); assert_eq!(task.script, Some("echo hello".to_string()));
assert_eq!(task.inputs.len(), 1);
}
#[test]
fn test_task_cache_policy_defaults_to_never() {
let task = Task {
command: "echo".to_string(),
..Default::default()
};
let policy = task.cache_policy();
assert_eq!(policy.mode, TaskCacheMode::Never);
assert!(policy.max_age.is_none());
}
#[test]
fn test_task_cache_policy_deserialization() {
let json = r#"{
"command": "echo",
"cache": {
"mode": "read-write",
"maxAge": "1h"
}
}"#;
let task: Task = serde_json::from_str(json).unwrap();
let policy = task.cache_policy();
assert_eq!(policy.mode, TaskCacheMode::ReadWrite);
assert_eq!(policy.max_age, Some("1h".to_string()));
}
#[test]
fn test_task_node_script_variant() {
let json = r#"{
"script": "echo hello"
}"#;
let node: TaskNode = serde_json::from_str(json).unwrap();
assert!(node.is_task());
}
#[test]
fn test_task_group_with_script_task() {
let json = r#"{
"type": "group",
"linux": {
"script": "echo building",
"inputs": ["src/main.rs"]
}
}"#;
let group: TaskGroup = serde_json::from_str(json).unwrap();
assert_eq!(group.len(), 1);
}
#[test]
fn test_full_tasks_map_with_script() {
let json = r#"{
"pwd": { "command": "pwd" },
"cross": {
"type": "group",
"linux": {
"script": "echo building",
"inputs": ["src/main.rs"]
}
}
}"#;
let tasks: HashMap<String, TaskNode> = serde_json::from_str(json).unwrap();
assert_eq!(tasks.len(), 2);
assert!(tasks.contains_key("pwd"));
assert!(tasks.contains_key("cross"));
assert!(tasks.get("pwd").unwrap().is_task());
assert!(tasks.get("cross").unwrap().is_group());
}
#[test]
fn test_complex_nested_tasks_like_cuenv() {
let json = r#"{
"pwd": { "command": "pwd" },
"check": {
"command": "nix",
"args": ["flake", "check"],
"inputs": ["flake.nix"]
},
"fmt": {
"type": "group",
"fix": {
"command": "treefmt",
"inputs": [".config"]
},
"check": {
"command": "treefmt",
"args": ["--fail-on-change"],
"inputs": [".config"]
}
},
"cross": {
"type": "group",
"linux": {
"script": "echo building",
"inputs": ["Cargo.toml"]
}
},
"docs": {
"type": "group",
"build": {
"command": "bash",
"args": ["-c", "bun install"],
"inputs": ["docs"],
"outputs": ["docs/dist"]
},
"deploy": {
"command": "bash",
"args": ["-c", "wrangler deploy"],
"dependsOn": ["docs.build"],
"inputs": [{"task": "docs.build"}]
}
}
}"#;
let result: Result<HashMap<String, TaskNode>, _> = serde_json::from_str(json);
match result {
Ok(tasks) => {
assert_eq!(tasks.len(), 5);
assert!(tasks.get("pwd").unwrap().is_task());
assert!(tasks.get("check").unwrap().is_task());
assert!(tasks.get("fmt").unwrap().is_group());
assert!(tasks.get("cross").unwrap().is_group());
assert!(tasks.get("docs").unwrap().is_group());
}
Err(e) => {
panic!("Failed to deserialize complex tasks: {}", e);
}
}
}
#[test]
fn test_task_list_sequential() {
let task1 = Task {
command: "echo".to_string(),
args: vec!["first".to_string()],
description: Some("First task".to_string()),
..Default::default()
};
let task2 = Task {
command: "echo".to_string(),
args: vec!["second".to_string()],
description: Some("Second task".to_string()),
..Default::default()
};
let sequence: Vec<TaskNode> = vec![
TaskNode::Task(Box::new(task1)),
TaskNode::Task(Box::new(task2)),
];
assert_eq!(sequence.len(), 2);
assert!(!sequence.is_empty());
}
#[test]
fn test_task_group_parallel() {
let task1 = Task {
command: "echo".to_string(),
args: vec!["task1".to_string()],
description: Some("Task 1".to_string()),
..Default::default()
};
let task2 = Task {
command: "echo".to_string(),
args: vec!["task2".to_string()],
description: Some("Task 2".to_string()),
..Default::default()
};
let mut parallel_tasks = HashMap::new();
parallel_tasks.insert("task1".to_string(), TaskNode::Task(Box::new(task1)));
parallel_tasks.insert("task2".to_string(), TaskNode::Task(Box::new(task2)));
let group = TaskGroup {
type_: "group".to_string(),
children: parallel_tasks,
depends_on: vec![],
max_concurrency: None,
description: None,
};
assert_eq!(group.len(), 2);
assert!(!group.is_empty());
}
#[test]
fn test_tasks_collection() {
let mut tasks = Tasks::new();
assert!(tasks.list_tasks().is_empty());
let task = Task {
command: "echo".to_string(),
args: vec!["hello".to_string()],
description: Some("Hello task".to_string()),
..Default::default()
};
tasks
.tasks
.insert("greet".to_string(), TaskNode::Task(Box::new(task)));
assert!(tasks.contains("greet"));
assert!(!tasks.contains("nonexistent"));
assert_eq!(tasks.list_tasks(), vec!["greet"]);
let retrieved = tasks.get("greet").unwrap();
assert!(retrieved.is_task());
}
#[test]
fn test_task_node_helpers() {
let task = Task {
command: "test".to_string(),
description: Some("Test task".to_string()),
..Default::default()
};
let task_node = TaskNode::Task(Box::new(task.clone()));
assert!(task_node.is_task());
assert!(!task_node.is_group());
assert!(!task_node.is_sequence());
assert_eq!(task_node.as_task().unwrap().command, "test");
assert!(task_node.as_group().is_none());
assert!(task_node.as_sequence().is_none());
let group = TaskNode::Group(TaskGroup {
type_: "group".to_string(),
children: HashMap::new(),
depends_on: vec![],
max_concurrency: None,
description: None,
});
assert!(!group.is_task());
assert!(group.is_group());
assert!(!group.is_sequence());
assert!(group.as_task().is_none());
assert!(group.as_group().is_some());
let sequence = TaskNode::Sequence(vec![]);
assert!(!sequence.is_task());
assert!(!sequence.is_group());
assert!(sequence.is_sequence());
assert!(sequence.as_sequence().is_some());
}
#[test]
fn test_script_shell_command_and_flag() {
assert_eq!(ScriptShell::Bash.command_and_flag(), ("bash", "-c"));
assert_eq!(ScriptShell::Nu.command_and_flag(), ("nu", "-c"));
assert_eq!(ScriptShell::Python.command_and_flag(), ("python", "-c"));
assert_eq!(ScriptShell::Node.command_and_flag(), ("node", "-e"));
assert_eq!(
ScriptShell::Powershell.command_and_flag(),
("powershell", "-Command")
);
}
#[test]
fn test_script_shell_from_command() {
assert_eq!(
ScriptShell::from_command("/usr/bin/nu"),
Some(ScriptShell::Nu)
);
assert_eq!(
ScriptShell::from_command("pwsh.exe"),
Some(ScriptShell::Pwsh)
);
assert_eq!(ScriptShell::from_command("custom-shell"), None);
}
#[test]
fn test_shell_options_default() {
let opts = ShellOptions::default();
assert!(opts.errexit);
assert!(opts.nounset);
assert!(opts.pipefail);
assert!(!opts.xtrace);
}
#[test]
fn test_shell_options_to_set_commands() {
let opts = ShellOptions::default();
assert_eq!(opts.to_set_commands(), "set -e -u -o pipefail\n");
let debug_opts = ShellOptions {
errexit: true,
nounset: false,
pipefail: true,
xtrace: true,
};
assert_eq!(debug_opts.to_set_commands(), "set -e -o pipefail -x\n");
let no_opts = ShellOptions {
errexit: false,
nounset: false,
pipefail: false,
xtrace: false,
};
assert_eq!(no_opts.to_set_commands(), "");
}
#[test]
fn test_task_command_spec_uses_script_shell() {
let task = Task {
script: Some("echo hello".to_string()),
script_shell: Some(ScriptShell::Nu),
..Default::default()
};
let spec = task
.command_spec(|command| format!("resolved:{command}"))
.unwrap();
assert_eq!(spec.program, "resolved:nu");
assert_eq!(spec.args, vec!["-c".to_string(), "echo hello".to_string()]);
}
#[test]
fn test_task_command_spec_prepends_shell_options() {
let task = Task {
script: Some("echo hello".to_string()),
shell_options: Some(ShellOptions {
errexit: true,
nounset: false,
pipefail: false,
xtrace: true,
}),
..Default::default()
};
let spec = task.command_spec(str::to_string).unwrap();
assert_eq!(spec.program, "bash");
assert_eq!(
spec.args,
vec!["-c".to_string(), "set -e -x\necho hello".to_string()]
);
}
#[test]
fn test_task_command_spec_rejects_pipefail_for_sh() {
let task = Task {
script: Some("echo hello".to_string()),
script_shell: Some(ScriptShell::Sh),
shell_options: Some(ShellOptions::default()),
..Default::default()
};
let err = task.command_spec(str::to_string).unwrap_err();
assert!(
err.to_string()
.contains("shellOptions.pipefail with unsupported script shell 'sh'"),
"unexpected error: {err}"
);
}
#[test]
fn test_task_command_spec_rejects_shell_options_for_unsupported_shell() {
let task = Task {
script: Some("console.log('hello')".to_string()),
script_shell: Some(ScriptShell::Node),
shell_options: Some(ShellOptions::default()),
..Default::default()
};
let err = task.command_spec(str::to_string).unwrap_err();
assert!(
err.to_string().contains("unsupported script shell 'node'"),
"unexpected error: {err}"
);
}
#[test]
fn test_task_command_spec_does_not_resolve_empty_command_for_shell_wrapper() {
let task = Task {
args: vec!["echo".to_string(), "hello".to_string()],
shell: Some(Shell {
command: Some("bash".to_string()),
flag: Some("-c".to_string()),
}),
..Default::default()
};
let mut resolved_commands = Vec::new();
let spec = task
.command_spec(|command| {
resolved_commands.push(command.to_string());
format!("resolved:{command}")
})
.unwrap();
assert_eq!(resolved_commands, vec!["bash".to_string()]);
assert_eq!(spec.program, "resolved:bash");
assert_eq!(spec.args, vec!["-c".to_string(), "echo hello".to_string()]);
}
#[test]
fn test_input_deserialization_variants() {
let path_json = r#""src/**/*.rs""#;
let path_input: Input = serde_json::from_str(path_json).unwrap();
assert_eq!(path_input, Input::Path("src/**/*.rs".to_string()));
let project_json = r#"{
"project": "../projB",
"task": "build",
"map": [{"from": "dist/app.txt", "to": "vendor/app.txt"}]
}"#;
let project_input: Input = serde_json::from_str(project_json).unwrap();
match project_input {
Input::Project(reference) => {
assert_eq!(reference.project, "../projB");
assert_eq!(reference.task, "build");
assert_eq!(reference.map.len(), 1);
assert_eq!(reference.map[0].from, "dist/app.txt");
assert_eq!(reference.map[0].to, "vendor/app.txt");
}
other => panic!("Expected project reference, got {:?}", other),
}
let task_json = r#"{"task": "build.deps"}"#;
let task_input: Input = serde_json::from_str(task_json).unwrap();
match task_input {
Input::Task(output) => {
assert_eq!(output.task, "build.deps");
assert!(output.map.is_none());
}
other => panic!("Expected task output reference, got {:?}", other),
}
}
#[test]
fn test_task_input_helpers_collect() {
use std::collections::HashSet;
use std::path::Path;
let task = Task {
inputs: vec![
Input::Path("src".into()),
Input::Project(ProjectReference {
project: "../projB".into(),
task: "build".into(),
map: vec![Mapping {
from: "dist/app.txt".into(),
to: "vendor/app.txt".into(),
}],
}),
],
..Default::default()
};
let path_inputs: Vec<String> = task.iter_path_inputs().cloned().collect();
assert_eq!(path_inputs, vec!["src".to_string()]);
let project_refs: Vec<&ProjectReference> = task.iter_project_refs().collect();
assert_eq!(project_refs.len(), 1);
assert_eq!(project_refs[0].project, "../projB");
let prefix = Path::new("prefix");
let collected = task.collect_all_inputs_with_prefix(Some(prefix));
let collected: HashSet<_> = collected
.into_iter()
.map(std::path::PathBuf::from)
.collect();
let expected: HashSet<_> = ["src", "vendor/app.txt"]
.into_iter()
.map(|p| prefix.join(p))
.collect();
assert_eq!(collected, expected);
}
#[test]
fn test_resolved_args_interpolate_positional() {
let args = ResolvedArgs {
positional: vec!["video123".into(), "1080p".into()],
named: HashMap::new(),
};
assert_eq!(args.interpolate("{{0}}"), "video123");
assert_eq!(args.interpolate("{{1}}"), "1080p");
assert_eq!(args.interpolate("--id={{0}}"), "--id=video123");
assert_eq!(args.interpolate("{{0}}-{{1}}"), "video123-1080p");
}
#[test]
fn test_resolved_args_interpolate_named() {
let mut named = HashMap::new();
named.insert("url".into(), "https://example.com".into());
named.insert("quality".into(), "720p".into());
let args = ResolvedArgs {
positional: vec![],
named,
};
assert_eq!(args.interpolate("{{url}}"), "https://example.com");
assert_eq!(args.interpolate("--quality={{quality}}"), "--quality=720p");
}
#[test]
fn test_resolved_args_interpolate_mixed() {
let mut named = HashMap::new();
named.insert("format".into(), "mp4".into());
let args = ResolvedArgs {
positional: vec!["VIDEO_ID".into()],
named,
};
assert_eq!(
args.interpolate("download {{0}} --format={{format}}"),
"download VIDEO_ID --format=mp4"
);
}
#[test]
fn test_resolved_args_no_placeholder_unchanged() {
let args = ResolvedArgs::new();
assert_eq!(
args.interpolate("no placeholders here"),
"no placeholders here"
);
assert_eq!(args.interpolate(""), "");
}
#[test]
fn test_resolved_args_interpolate_args_list() {
let args = ResolvedArgs {
positional: vec!["id123".into()],
named: HashMap::new(),
};
let input = vec!["--id".into(), "{{0}}".into(), "--verbose".into()];
let result = args.interpolate_args(&input);
assert_eq!(result, vec!["--id", "id123", "--verbose"]);
}
#[test]
fn test_task_params_deserialization_with_flatten() {
let json = r#"{
"positional": [{"description": "Video ID", "required": true}],
"quality": {"description": "Quality", "default": "1080p", "short": "q"},
"verbose": {"description": "Verbose output", "type": "bool"}
}"#;
let params: TaskParams = serde_json::from_str(json).unwrap();
assert_eq!(params.positional.len(), 1);
assert_eq!(
params.positional[0].description,
Some("Video ID".to_string())
);
assert!(params.positional[0].required);
assert_eq!(params.named.len(), 2);
assert!(params.named.contains_key("quality"));
assert!(params.named.contains_key("verbose"));
let quality = ¶ms.named["quality"];
assert_eq!(quality.default, Some("1080p".to_string()));
assert_eq!(quality.short, Some("q".to_string()));
let verbose = ¶ms.named["verbose"];
assert_eq!(verbose.param_type, ParamType::Bool);
}
#[test]
fn test_task_params_empty() {
let json = r#"{}"#;
let params: TaskParams = serde_json::from_str(json).unwrap();
assert!(params.positional.is_empty());
assert!(params.named.is_empty());
}
#[test]
fn test_param_def_defaults() {
let def = ParamDef::default();
assert!(def.description.is_none());
assert!(!def.required);
assert!(def.default.is_none());
assert_eq!(def.param_type, ParamType::String);
assert!(def.short.is_none());
}
mod affected_tests {
use super::*;
use crate::AffectedBy;
use std::path::PathBuf;
fn make_task(inputs: Vec<&str>) -> Task {
Task {
inputs: inputs
.into_iter()
.map(|s| Input::Path(s.to_string()))
.collect(),
command: "echo test".to_string(),
..Default::default()
}
}
#[test]
fn test_task_no_inputs_always_affected() {
let task = make_task(vec![]);
let changed_files: Vec<PathBuf> = vec![];
let root = Path::new(".");
assert!(task.is_affected_by(&changed_files, root));
}
#[test]
fn test_task_with_inputs_matching() {
let task = make_task(vec!["src/**"]);
let changed_files = vec![PathBuf::from("src/lib.rs")];
let root = Path::new(".");
assert!(task.is_affected_by(&changed_files, root));
}
#[test]
fn test_task_with_inputs_not_matching() {
let task = make_task(vec!["src/**"]);
let changed_files = vec![PathBuf::from("docs/readme.md")];
let root = Path::new(".");
assert!(!task.is_affected_by(&changed_files, root));
}
#[test]
fn test_task_with_project_root_path_normalization() {
let task = make_task(vec!["src/**"]);
let changed_files = vec![PathBuf::from("projects/website/src/app.rs")];
let root = Path::new("projects/website");
assert!(task.is_affected_by(&changed_files, root));
}
#[test]
fn test_task_node_delegates_to_task() {
let task = make_task(vec!["src/**"]);
let node = TaskNode::Task(Box::new(task));
let changed_files = vec![PathBuf::from("src/lib.rs")];
let root = Path::new(".");
assert!(node.is_affected_by(&changed_files, root));
}
#[test]
fn test_task_group_any_affected() {
let lint_task = make_task(vec!["src/**"]);
let test_task = make_task(vec!["tests/**"]);
let mut parallel_tasks = HashMap::new();
parallel_tasks.insert("lint".to_string(), TaskNode::Task(Box::new(lint_task)));
parallel_tasks.insert("test".to_string(), TaskNode::Task(Box::new(test_task)));
let group = TaskGroup {
type_: "group".to_string(),
children: parallel_tasks,
depends_on: vec![],
max_concurrency: None,
description: None,
};
let changed_files = vec![PathBuf::from("src/lib.rs")];
let root = Path::new(".");
assert!(group.is_affected_by(&changed_files, root));
}
#[test]
fn test_task_group_none_affected() {
let lint_task = make_task(vec!["src/**"]);
let test_task = make_task(vec!["tests/**"]);
let mut parallel_tasks = HashMap::new();
parallel_tasks.insert("lint".to_string(), TaskNode::Task(Box::new(lint_task)));
parallel_tasks.insert("test".to_string(), TaskNode::Task(Box::new(test_task)));
let group = TaskGroup {
type_: "group".to_string(),
children: parallel_tasks,
depends_on: vec![],
max_concurrency: None,
description: None,
};
let changed_files = vec![PathBuf::from("docs/readme.md")];
let root = Path::new(".");
assert!(!group.is_affected_by(&changed_files, root));
}
#[test]
fn test_task_sequence_any_affected() {
let build_task = make_task(vec!["src/**"]);
let deploy_task = make_task(vec!["deploy/**"]);
let sequence = TaskNode::Sequence(vec![
TaskNode::Task(Box::new(build_task)),
TaskNode::Task(Box::new(deploy_task)),
]);
let changed_files = vec![PathBuf::from("src/lib.rs")];
let root = Path::new(".");
assert!(sequence.is_affected_by(&changed_files, root));
}
#[test]
fn test_input_patterns_returns_patterns() {
let task = make_task(vec!["src/**", "Cargo.toml"]);
let patterns = task.input_patterns();
assert_eq!(patterns.len(), 2);
assert!(patterns.contains(&"src/**"));
assert!(patterns.contains(&"Cargo.toml"));
}
}
}