use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use once_cell::sync::Lazy;
use regex::Regex;
use serde::Deserialize;
static INTERPOLATION_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}").unwrap());
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TasksFile {
pub version: u32,
pub tasks: HashMap<String, TaskDef>,
}
impl TasksFile {
pub fn get(&self, task_id: &str) -> Option<&TaskDef> {
self.tasks.get(task_id)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &TaskDef)> {
self.tasks.iter().map(|(k, v)| (k.as_str(), v))
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TaskDef {
Shell(ShellTaskDef),
Sequence(CompositeTaskDef),
Parallel(CompositeTaskDef),
}
impl TaskDef {
pub fn ui(&self) -> Option<&UiMeta> {
match self {
TaskDef::Shell(s) => s.ui.as_ref(),
TaskDef::Sequence(c) | TaskDef::Parallel(c) => c.ui.as_ref(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ShellTaskDef {
pub command: String,
#[serde(default)]
pub queue: Option<String>,
#[serde(default)]
pub cancel: Option<CancelPolicy>,
#[serde(default)]
pub inputs: Option<HashMap<String, InputDef>>,
#[serde(default)]
pub ui: Option<UiMeta>,
}
impl ShellTaskDef {
pub fn effective_queue<'a>(&'a self, task_id: &'a str) -> &'a str {
self.queue.as_deref().unwrap_or(task_id)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CancelPolicy {
Queue,
None,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CompositeTaskDef {
pub steps: Vec<StepRef>,
#[serde(default)]
pub continue_on_error: bool,
#[serde(default)]
pub ui: Option<UiMeta>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum StepRef {
Simple(String),
WithOverrides(HashMap<String, HashMap<String, InputValue>>),
}
impl StepRef {
pub fn task_id(&self) -> Option<&str> {
match self {
Self::Simple(s) => Some(s.as_str()),
Self::WithOverrides(map) => map.keys().next().map(String::as_str),
}
}
pub fn overrides(&self) -> Option<&HashMap<String, InputValue>> {
match self {
Self::Simple(_) => None,
Self::WithOverrides(map) => map.values().next(),
}
}
}
#[derive(Debug, Clone)]
pub enum InputValue {
Bool(bool),
Number(f64),
Str(String),
}
impl<'de> serde::Deserialize<'de> for InputValue {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Visitor;
struct V;
impl<'de> Visitor<'de> for V {
type Value = InputValue;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "a boolean, number, or string")
}
fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<InputValue, E> {
Ok(InputValue::Bool(v))
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<InputValue, E> {
Ok(InputValue::Number(v as f64))
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<InputValue, E> {
Ok(InputValue::Number(v as f64))
}
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<InputValue, E> {
Ok(InputValue::Number(v))
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<InputValue, E> {
Ok(InputValue::Str(v.to_string()))
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<InputValue, E> {
Ok(InputValue::Str(v))
}
}
d.deserialize_any(V)
}
}
impl InputValue {
pub fn to_string_value(&self) -> String {
match self {
InputValue::Str(s) => s.clone(),
InputValue::Number(n) => n.to_string(),
InputValue::Bool(b) => b.to_string(),
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
InputValue::Str(s) => Some(s.as_str()),
_ => None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InputDef {
#[serde(rename = "type")]
pub input_type: InputType,
#[serde(default)]
pub default: Option<InputValue>,
#[serde(default)]
pub placeholder: Option<String>,
#[serde(default)]
pub options: Option<Vec<String>>,
#[serde(default)]
pub validate: Option<ValidationRules>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InputType {
Text,
Select,
Boolean,
Number,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ValidationRules {
#[serde(default)]
pub min_length: Option<usize>,
#[serde(default)]
pub max_length: Option<usize>,
#[serde(default)]
pub pattern: Option<String>,
#[serde(default, deserialize_with = "de_opt_number")]
pub min: Option<f64>,
#[serde(default, deserialize_with = "de_opt_number")]
pub max: Option<f64>,
}
fn de_opt_number<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
use serde::de::Visitor;
struct V;
impl<'de> Visitor<'de> for V {
type Value = f64;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "a number")
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<f64, E> {
Ok(v as f64)
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<f64, E> {
Ok(v as f64)
}
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<f64, E> {
Ok(v)
}
}
d.deserialize_any(V).map(Some)
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UiMeta {
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub category: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ConfigError {
pub path: String,
pub message: String,
}
impl ConfigError {
fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
Self { path: path.into(), message: message.into() }
}
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.path.is_empty() {
write!(f, "{}", self.message)
} else {
write!(f, "{}: {}", self.path, self.message)
}
}
}
pub fn load(path: &Path) -> Result<Option<TasksFile>, Vec<ConfigError>> {
let content = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(vec![ConfigError::new(
"",
format!("could not read {path:?}: {e}"),
)]);
}
};
parse_str(&content).map(Some)
}
pub fn parse_str(content: &str) -> Result<TasksFile, Vec<ConfigError>> {
let owned = content.to_string();
let handle = std::thread::Builder::new()
.name("task_config_parser".into())
.stack_size(8 * 1024 * 1024)
.spawn(move || {
match serde_saphyr::from_str::<TasksFile>(&owned) {
Ok(parsed) => {
let errors = validate(&parsed);
if errors.is_empty() {
Ok(parsed)
} else {
Err(errors)
}
}
Err(e) => Err(vec![ConfigError::new("", e.to_string())]),
}
});
let handle = match handle {
Ok(h) => h,
Err(e) => return Err(vec![ConfigError::new("", format!("failed to spawn parser thread: {}", e))]),
};
match handle.join() {
Ok(res) => res,
Err(_e) => Err(vec![ConfigError::new("", "parser thread panicked".to_string())]),
}
}
fn validate(file: &TasksFile) -> Vec<ConfigError> {
let mut errors = Vec::new();
if file.version != 1 {
errors.push(ConfigError::new(
"version",
format!("expected 1, got {}", file.version),
));
}
for (task_id, task_def) in &file.tasks {
validate_task(file, task_id, task_def, &mut errors);
}
detect_cycles(file, &mut errors);
errors
}
fn validate_task(
file: &TasksFile,
task_id: &str,
task_def: &TaskDef,
errors: &mut Vec<ConfigError>,
) {
match task_def {
TaskDef::Shell(shell) => validate_shell(task_id, shell, errors),
TaskDef::Sequence(seq) => validate_composite(file, task_id, &seq.steps, errors),
TaskDef::Parallel(par) => validate_composite(file, task_id, &par.steps, errors),
}
}
fn validate_shell(task_id: &str, shell: &ShellTaskDef, errors: &mut Vec<ConfigError>) {
let inputs = shell.inputs.as_ref();
if let Some(inputs) = inputs {
for (input_name, input_def) in inputs {
validate_input(task_id, input_name, input_def, errors);
}
}
let empty = HashMap::new();
validate_interpolations(task_id, &shell.command, inputs.unwrap_or(&empty), errors);
}
fn validate_composite(
file: &TasksFile,
task_id: &str,
steps: &[StepRef],
errors: &mut Vec<ConfigError>,
) {
for (i, step) in steps.iter().enumerate() {
let path = || format!("tasks.{task_id}.steps[{i}]");
match step {
StepRef::Simple(ref_id) => {
if !file.tasks.contains_key(ref_id) {
errors.push(ConfigError::new(
path(),
format!("references unknown task '{ref_id}'"),
));
}
}
StepRef::WithOverrides(map) => {
if map.len() != 1 {
errors.push(ConfigError::new(
path(),
format!(
"step with overrides must have exactly one task name, found {}",
map.len()
),
));
continue;
}
let (ref_id, overrides) = map.iter().next().unwrap();
if !file.tasks.contains_key(ref_id) {
errors.push(ConfigError::new(
path(),
format!("references unknown task '{ref_id}'"),
));
continue;
}
if let Some(TaskDef::Shell(shell)) = file.tasks.get(ref_id) {
let declared: HashSet<&str> = shell
.inputs
.as_ref()
.map(|m| m.keys().map(String::as_str).collect())
.unwrap_or_default();
for key in overrides.keys() {
if !declared.contains(key.as_str()) {
errors.push(ConfigError::new(
format!("{}.{ref_id}.{key}", path()),
format!("task '{ref_id}' has no input '{key}'"),
));
}
}
}
}
}
}
}
fn validate_input(
task_id: &str,
input_name: &str,
input_def: &InputDef,
errors: &mut Vec<ConfigError>,
) {
let prefix = format!("tasks.{task_id}.inputs.{input_name}");
match input_def.input_type {
InputType::Select => {
let opts = input_def.options.as_deref().unwrap_or(&[]);
if opts.is_empty() {
errors.push(ConfigError::new(
format!("{prefix}.options"),
"select input must define a non-empty 'options' list",
));
}
if let Some(default) = &input_def.default
&& let Some(s) = default.as_str()
&& !opts.is_empty() && !opts.iter().any(|o| o == s) {
errors.push(ConfigError::new(
format!("{prefix}.default"),
format!("default '{s}' is not listed in options"),
));
}
}
InputType::Text => {
if let Some(v) = &input_def.validate {
if let (Some(min), Some(max)) = (v.min_length, v.max_length)
&& min > max {
errors.push(ConfigError::new(
format!("{prefix}.validate"),
format!("min_length ({min}) must not exceed max_length ({max})"),
));
}
if let Some(pattern) = &v.pattern
&& let Err(e) = Regex::new(pattern) {
errors.push(ConfigError::new(
format!("{prefix}.validate.pattern"),
format!("invalid regular expression: {e}"),
));
}
}
}
InputType::Number => {
if let Some(v) = &input_def.validate
&& let (Some(min), Some(max)) = (v.min, v.max)
&& min > max {
errors.push(ConfigError::new(
format!("{prefix}.validate"),
format!("min ({min}) must not exceed max ({max})"),
));
}
}
InputType::Boolean => {}
}
}
fn validate_interpolations(
task_id: &str,
command: &str,
inputs: &HashMap<String, InputDef>,
errors: &mut Vec<ConfigError>,
) {
for cap in INTERPOLATION_RE.captures_iter(command) {
let name = &cap[1];
if !inputs.contains_key(name) {
errors.push(ConfigError::new(
format!("tasks.{task_id}.command"),
format!("references undeclared input '{{{name}}}'; add it to 'inputs'"),
));
}
}
}
fn detect_cycles(file: &TasksFile, errors: &mut Vec<ConfigError>) {
let mut adj: HashMap<String, Vec<String>> = HashMap::new();
let mut indeg: HashMap<String, usize> = HashMap::new();
for task_id in file.tasks.keys() {
indeg.insert(task_id.clone(), 0);
adj.insert(task_id.clone(), Vec::new());
}
for (task_id, def) in &file.tasks {
for d in task_step_ids(Some(def)) {
if file.tasks.contains_key(&d) {
adj.get_mut(task_id).unwrap().push(d.clone());
*indeg.get_mut(&d).unwrap() += 1;
}
}
}
let mut queue: VecDeque<String> = indeg
.iter()
.filter_map(|(k, &v)| if v == 0 { Some(k.clone()) } else { None })
.collect();
let mut removed = 0usize;
while let Some(node) = queue.pop_front() {
removed += 1;
if let Some(neis) = adj.get(&node) {
for n in neis {
if let Some(c) = indeg.get_mut(n) {
*c -= 1;
if *c == 0 {
queue.push_back(n.clone());
}
}
}
}
}
if removed != file.tasks.len() {
let mut cycle_nodes: Vec<String> = indeg
.into_iter()
.filter_map(|(k, v)| if v > 0 { Some(k) } else { None })
.collect();
cycle_nodes.sort();
let msg = format!("cyclic dependency involving: {}", cycle_nodes.join(" → "));
let path = format!("tasks.{}", cycle_nodes.first().unwrap_or(&"<unknown>".to_string()));
errors.push(ConfigError::new(path, msg));
}
}
fn task_step_ids(def: Option<&TaskDef>) -> Vec<String> {
match def {
None | Some(TaskDef::Shell(_)) => vec![],
Some(TaskDef::Sequence(c)) | Some(TaskDef::Parallel(c)) => {
c.steps.iter().filter_map(|s| s.task_id().map(str::to_owned)).collect()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ok(yaml: &str) -> TasksFile {
parse_str(yaml).unwrap_or_else(|errs| {
panic!(
"expected Ok but got errors:\n{}",
errs.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("\n")
)
})
}
fn err(yaml: &str) -> Vec<ConfigError> {
parse_str(yaml).unwrap_err()
}
fn has_error(errors: &[ConfigError], substr: &str) -> bool {
errors.iter().any(|e| {
e.message.contains(substr) || e.path.contains(substr)
})
}
#[test]
fn minimal_shell_task() {
let f = ok(r#"
version: 1
tasks:
build:
type: shell
command: "cargo build"
"#);
assert_eq!(f.version, 1);
assert!(matches!(f.tasks.get("build"), Some(TaskDef::Shell(_))));
}
#[test]
fn full_shell_task() {
let f = ok(r#"
version: 1
tasks:
build:
type: shell
queue: build
cancel: queue
command: "cargo build --{mode}"
inputs:
mode:
type: select
options: [debug, release]
default: debug
ui:
title: Build
description: Compile the workspace
category: build
"#);
let TaskDef::Shell(shell) = f.tasks.get("build").unwrap() else {
panic!("expected shell");
};
assert_eq!(shell.command, "cargo build --{mode}");
assert_eq!(shell.queue.as_deref(), Some("build"));
let inputs = shell.inputs.as_ref().unwrap();
let mode = inputs.get("mode").unwrap();
assert_eq!(mode.input_type, InputType::Select);
assert_eq!(mode.options.as_ref().unwrap(), &["debug", "release"]);
}
#[test]
fn sequence_task() {
let f = ok(r#"
version: 1
tasks:
build:
type: shell
command: cargo build
test:
type: shell
command: cargo test
ci:
type: sequence
steps:
- build
- test
"#);
let TaskDef::Sequence(seq) = f.tasks.get("ci").unwrap() else {
panic!("expected sequence");
};
assert_eq!(seq.steps.len(), 2);
assert!(!seq.continue_on_error);
assert_eq!(seq.steps[0].task_id(), Some("build"));
assert_eq!(seq.steps[1].task_id(), Some("test"));
}
#[test]
fn parallel_task_with_continue_on_error() {
let f = ok(r#"
version: 1
tasks:
lint:
type: shell
command: cargo clippy
fmt:
type: shell
command: cargo fmt --check
check:
type: parallel
continue_on_error: true
steps:
- lint
- fmt
"#);
let TaskDef::Parallel(par) = f.tasks.get("check").unwrap() else {
panic!("expected parallel");
};
assert!(par.continue_on_error);
assert_eq!(par.steps.len(), 2);
}
#[test]
fn step_with_input_overrides() {
let f = ok(r#"
version: 1
tasks:
build:
type: shell
command: "cargo build --{mode}"
inputs:
mode:
type: select
options: [debug, release]
default: debug
deploy:
type: sequence
steps:
- build:
mode: release
- build
"#);
let TaskDef::Sequence(seq) = f.tasks.get("deploy").unwrap() else {
panic!()
};
let StepRef::WithOverrides(map) = &seq.steps[0] else {
panic!("expected step with overrides");
};
let overrides = map.get("build").unwrap();
assert!(overrides.contains_key("mode"));
assert_eq!(seq.steps[1].task_id(), Some("build"));
}
#[test]
fn text_input_with_validation() {
let f = ok(r#"
version: 1
tasks:
test:
type: shell
command: "cargo test {filter}"
inputs:
filter:
type: text
default: ""
placeholder: "Test name"
validate:
min_length: 0
max_length: 50
pattern: "^[a-zA-Z0-9_:]*$"
"#);
let TaskDef::Shell(shell) = f.tasks.get("test").unwrap() else {
panic!()
};
let filter = shell.inputs.as_ref().unwrap().get("filter").unwrap();
assert_eq!(filter.input_type, InputType::Text);
let v = filter.validate.as_ref().unwrap();
assert_eq!(v.max_length, Some(50));
assert!(v.pattern.is_some());
}
#[test]
fn number_input_with_validation() {
let f = ok(r#"
version: 1
tasks:
scale:
type: shell
command: "scale --factor {factor}"
inputs:
factor:
type: number
default: 1
validate:
min: 1
max: 10
"#);
let TaskDef::Shell(shell) = f.tasks.get("scale").unwrap() else {
panic!()
};
let n = shell.inputs.as_ref().unwrap().get("factor").unwrap();
assert_eq!(n.input_type, InputType::Number);
let v = n.validate.as_ref().unwrap();
assert_eq!(v.min, Some(1.0));
assert_eq!(v.max, Some(10.0));
}
#[test]
fn full_spec_example() {
let f = ok(r#"
version: 1
tasks:
build:
type: shell
queue: build
command: "cargo build --{mode}"
inputs:
mode:
type: select
options: [debug, release]
default: debug
test:
type: shell
queue: build
command: "cargo test {filter}"
inputs:
filter:
type: text
default: ""
check:
type: parallel
steps:
- build
- test
continue_on_error: true
deploy:
type: sequence
steps:
- build:
mode: release
- test
"#);
assert!(f.tasks.contains_key("build"));
assert!(f.tasks.contains_key("test"));
assert!(f.tasks.contains_key("check"));
assert!(f.tasks.contains_key("deploy"));
}
#[test]
fn wrong_version_is_error() {
let errs = err("version: 2\ntasks: {}");
assert!(has_error(&errs, "expected 1"));
}
#[test]
fn unknown_field_in_shell_is_error() {
let errs = err(r#"
version: 1
tasks:
build:
type: shell
command: cargo build
totally_unknown_field: oops
"#);
assert!(!errs.is_empty(), "expected at least one error");
}
#[test]
fn missing_command_is_error() {
let errs = err(r#"
version: 1
tasks:
build:
type: shell
"#);
assert!(!errs.is_empty());
}
#[test]
fn missing_steps_is_error() {
let errs = err(r#"
version: 1
tasks:
ci:
type: sequence
"#);
assert!(!errs.is_empty());
}
#[test]
fn unknown_step_reference_is_error() {
let errs = err(r#"
version: 1
tasks:
ci:
type: sequence
steps:
- build
- nonexistent
"#);
assert!(has_error(&errs, "nonexistent") || has_error(&errs, "unknown"));
}
#[test]
fn direct_self_cycle_is_error() {
let errs = err(r#"
version: 1
tasks:
a:
type: sequence
steps:
- a
"#);
assert!(has_error(&errs, "cyclic") || has_error(&errs, "cycle"));
}
#[test]
fn two_node_cycle_is_error() {
let errs = err(r#"
version: 1
tasks:
a:
type: sequence
steps:
- b
b:
type: sequence
steps:
- a
"#);
assert!(has_error(&errs, "cyclic") || has_error(&errs, "cycle"));
}
#[test]
fn indirect_cycle_is_error() {
let errs = err(r#"
version: 1
tasks:
a:
type: sequence
steps: [b]
b:
type: sequence
steps: [c]
c:
type: sequence
steps: [a]
"#);
assert!(has_error(&errs, "cyclic") || has_error(&errs, "cycle"));
}
#[test]
fn linear_chain_has_no_cycle() {
ok(r#"
version: 1
tasks:
a:
type: shell
command: echo a
b:
type: sequence
steps: [a]
c:
type: sequence
steps: [b]
"#);
}
#[test]
fn select_without_options_is_error() {
let errs = err(r#"
version: 1
tasks:
build:
type: shell
command: "cargo build --{mode}"
inputs:
mode:
type: select
"#);
assert!(has_error(&errs, "options") || has_error(&errs, "select"));
}
#[test]
fn select_default_not_in_options_is_error() {
let errs = err(r#"
version: 1
tasks:
build:
type: shell
command: "cargo build --{mode}"
inputs:
mode:
type: select
options: [debug, release]
default: optimised
"#);
assert!(has_error(&errs, "optimised") || has_error(&errs, "default"));
}
#[test]
fn undeclared_interpolation_is_error() {
let errs = err(r#"
version: 1
tasks:
test:
type: shell
command: "cargo test {ghost}"
"#);
assert!(has_error(&errs, "ghost") || has_error(&errs, "undeclared"));
}
#[test]
fn declared_interpolation_is_ok() {
ok(r#"
version: 1
tasks:
test:
type: shell
command: "cargo test {filter}"
inputs:
filter:
type: text
default: ""
"#);
}
#[test]
fn invalid_regex_pattern_is_error() {
let errs = err(r#"
version: 1
tasks:
test:
type: shell
command: "cargo test {filter}"
inputs:
filter:
type: text
validate:
pattern: "[invalid("
"#);
assert!(has_error(&errs, "regular expression") || has_error(&errs, "pattern"));
}
#[test]
fn step_override_undeclared_input_is_error() {
let errs = err(r#"
version: 1
tasks:
build:
type: shell
command: cargo build
deploy:
type: sequence
steps:
- build:
ghost_input: something
"#);
assert!(has_error(&errs, "ghost_input") || has_error(&errs, "no input"));
}
#[test]
fn inverted_text_length_bounds_is_error() {
let errs = err(r#"
version: 1
tasks:
t:
type: shell
command: "run {x}"
inputs:
x:
type: text
validate:
min_length: 10
max_length: 5
"#);
assert!(has_error(&errs, "min_length") || has_error(&errs, "max_length"));
}
#[test]
fn inverted_number_bounds_is_error() {
let errs = err(r#"
version: 1
tasks:
t:
type: shell
command: "run {factor}"
inputs:
factor:
type: number
validate:
min: 100
max: 1
"#);
assert!(has_error(&errs, "min") || has_error(&errs, "max"));
}
#[test]
fn boolean_input() {
ok(r#"
version: 1
tasks:
test:
type: shell
command: "cargo test {verbose}"
inputs:
verbose:
type: boolean
default: false
"#);
}
#[test]
fn effective_queue_fallback() {
let f = ok(r#"
version: 1
tasks:
build:
type: shell
command: cargo build
"#);
let TaskDef::Shell(shell) = f.tasks.get("build").unwrap() else {
panic!()
};
assert_eq!(shell.effective_queue("build"), "build");
}
#[test]
fn effective_queue_explicit() {
let f = ok(r#"
version: 1
tasks:
build:
type: shell
queue: compilation
command: cargo build
"#);
let TaskDef::Shell(shell) = f.tasks.get("build").unwrap() else {
panic!()
};
assert_eq!(shell.effective_queue("build"), "compilation");
}
}