Files
mise/src/task/task_script_parser.rs
T

2002 lines
74 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::config::{Config, Settings};
use crate::env_diff::EnvMap;
use crate::request_exit;
use crate::shell::ShellType;
use crate::task::Task;
use crate::tera::{TeraEngine, contains_template_syntax, get_tera, render_str};
use eyre::{Context, Result};
use heck::ToSnakeCase;
use indexmap::IndexMap;
use itertools::Itertools;
use serde::de::DeserializeOwned;
use serde_json::{Value as JsonValue, json};
use std::collections::{HashMap, HashSet};
use std::iter::once;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
type TeraSpecParsingResult = (
TeraEngine,
Arc<Mutex<HashMap<String, usize>>>,
Arc<Mutex<Vec<usage::SpecArg>>>,
Arc<Mutex<Vec<usage::SpecFlag>>>,
);
type TaskTemplateResult = std::result::Result<JsonValue, String>;
pub struct TaskScriptParser {
dir: Option<PathBuf>,
/// Extra vars to inject into the tera context (for monorepo task vars resolution)
extra_vars: Option<IndexMap<String, String>>,
}
impl TaskScriptParser {
pub fn new(dir: Option<PathBuf>) -> Self {
TaskScriptParser {
dir,
extra_vars: None,
}
}
pub fn with_extra_vars(mut self, vars: IndexMap<String, String>) -> Self {
self.extra_vars = Some(vars);
self
}
fn get_tera(&self) -> TeraEngine {
get_tera(self.dir.as_deref())
}
/// Inject extra vars (from monorepo task config hierarchy) into the tera context
fn inject_extra_vars(&self, tera_ctx: &mut tera::Context) {
if let Some(extra_vars) = &self.extra_vars {
// Merge extra_vars (base config-level vars from the config hierarchy) with any
// vars already set in the context by task.tera_ctx() (which includes per-task
// vars). Per-task vars take precedence over config-level vars.
let existing: IndexMap<String, String> = tera_ctx
.get("vars")
.and_then(|v| serde::Deserialize::deserialize(v.clone()).ok())
.unwrap_or_default();
let mut merged = extra_vars.clone();
merged.extend(existing);
tera_ctx.insert("vars", &merged);
}
}
fn render_script_with_context(
tera: &mut TeraEngine,
script: &str,
ctx: &tera::Context,
) -> Result<String> {
render_str(tera, script.trim(), ctx)
.map_err(Self::task_script_tera_error)
.with_context(|| format!("Failed to render task script: {}", script))
}
fn task_script_tera_error(err: tera::Error) -> eyre::Report {
match err.kind() {
tera::ErrorKind::RenderingError(report) | tera::ErrorKind::SyntaxError(report) => {
eyre::eyre!("{}", report.message())
}
_ => eyre::Report::new(err),
}
}
fn render_usage_with_context(
tera: &mut TeraEngine,
usage: &str,
ctx: &tera::Context,
) -> Result<String> {
render_str(tera, usage.trim(), ctx)
.with_context(|| format!("Failed to render task usage: {}", usage))
}
fn check_tera_args_deprecation(
task_name: &str,
args: &[usage::SpecArg],
flags: &[usage::SpecFlag],
) {
// Check if any args or flags were defined via Tera templates
if args.is_empty() && flags.is_empty() {
return;
}
deprecated_at!(
"2026.5.0",
"2027.5.0",
"tera_template_task_args",
"Task '{}' uses deprecated Tera template functions (arg(), option(), flag()) in run scripts. \
Use the 'usage' field instead. See https://mise.en.dev/tasks/task-arguments.html",
task_name
);
}
fn template_arg<T: DeserializeOwned>(
args: &HashMap<String, JsonValue>,
name: &str,
) -> std::result::Result<Option<T>, String> {
args.get(name)
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|e| format!("invalid `{name}` argument: {e}"))
}
fn string_arg(
args: &HashMap<String, JsonValue>,
name: &str,
) -> std::result::Result<String, String> {
Self::template_arg(args, name)?
.ok_or_else(|| format!("missing required '{name}' parameter"))
}
fn lock_error(e: impl std::fmt::Display) -> String {
format!("failed to lock: {e}")
}
fn register_template_function<F>(tera: &mut TeraEngine, name: &'static str, f: F)
where
F: Fn(&HashMap<String, JsonValue>) -> TaskTemplateResult + Send + Sync + 'static,
{
let f = Arc::new(f);
match tera {
TeraEngine::V2(tera) => {
let f = f.clone();
tera.register_function(name, move |args: tera::Kwargs, _: &tera::State| {
let args = args.deserialize::<HashMap<String, JsonValue>>()?;
let value = f(&args).map_err(tera::Error::message)?;
tera::Value::try_from_serializable(&value)
});
}
TeraEngine::V1(tera) => {
tera.register_function(name, move |args: &HashMap<String, JsonValue>| {
f(args).map_err(tera1::Error::msg)
});
}
}
}
fn register_task_source_files_function(&self, tera: &mut TeraEngine, task: &Task) {
Self::register_template_function(tera, "task_source_files", {
let glob_patterns = Arc::new(crate::task::task_source_checker::source_glob_patterns(
&task.sources,
));
// Anchor the matcher at the task directory. `is_source` handles
// absolute paths outside this root by trusting the glob result,
// so absolute outside-root patterns (e.g. workspace-root paths)
// still flow through.
let root = self
.dir
.clone()
.or_else(|| crate::dirs::CWD.clone())
.unwrap_or_else(|| std::path::PathBuf::from("."));
let matcher = Arc::new(crate::task::task_source_checker::build_source_matcher(
&root,
&root,
&task.sources,
));
move |_| -> TaskTemplateResult {
if glob_patterns.is_empty() {
trace!(
"tera::render::resolve_task_sources `task_source_files` called in task with empty sources array"
);
return Ok(json!([]));
};
let mut resolved = Vec::with_capacity(glob_patterns.len());
let escaped_root = glob::Pattern::escape(root.to_string_lossy().as_ref());
for pattern in glob_patterns.iter() {
if contains_template_syntax(pattern) {
trace!(
"tera::render::resolve_task_sources including tera template string in resolved task sources: {pattern}"
);
resolved.push(pattern.clone());
continue;
}
let pattern_path = Path::new(pattern);
let is_relative = pattern_path.is_relative();
let rooted_pattern = if is_relative {
Path::new(&escaped_root)
.join(pattern_path)
.to_string_lossy()
.to_string()
} else {
pattern.clone()
};
match glob::glob_with(
&rooted_pattern,
glob::MatchOptions {
case_sensitive: false,
require_literal_separator: false,
require_literal_leading_dot: false,
},
) {
Err(error) => {
warn!(
"tera::render::resolve_task_sources including '{pattern}' in resolved task sources, ignoring glob parsing error: {error:#?}"
);
resolved.push(pattern.clone());
}
Ok(expanded) => {
let mut source_found = false;
for path in expanded {
source_found = true;
match path {
Ok(path) => {
if !crate::task::task_source_checker::is_source(
&matcher, &path,
) {
trace!(
"tera::render::resolve_task_sources excluded '{}' due to !-pattern",
path.display()
);
continue;
}
let source = if is_relative {
path.strip_prefix(&root).unwrap_or(&path)
} else {
&path
};
let source = source.display();
trace!(
"tera::render::resolve_task_sources resolved source from pattern '{pattern}': {source}"
);
resolved.push(source.to_string());
}
Err(error) => {
let source = error.path().display();
warn!(
"tera::render::resolve_task_sources omitting '{source}' from resolved task sources due to: {:#?}",
error.error()
);
}
}
}
if !source_found {
warn!(
"tera::render::resolve_task_sources no source file(s) resolved for pattern: '{pattern}'"
);
}
}
}
}
Ok(json!(resolved))
}
});
}
fn setup_tera_for_spec_parsing(&self, task: &Task) -> TeraSpecParsingResult {
let mut tera = self.get_tera();
let arg_order = Arc::new(Mutex::new(HashMap::new()));
let input_args = Arc::new(Mutex::new(vec![]));
let input_flags = Arc::new(Mutex::new(vec![]));
// override throw function to do nothing
Self::register_template_function(&mut tera, "throw", move |_| Ok(JsonValue::Null));
// render args, options, and flags as null
// these functions are only used to collect the spec
Self::register_template_function(&mut tera, "arg", {
let input_args = input_args.clone();
let arg_order = arg_order.clone();
move |args| {
let i = Self::template_arg::<i64>(args, "i")?
.unwrap_or(input_args.lock().map_err(Self::lock_error)?.len() as i64)
as usize;
let required = Self::template_arg::<bool>(args, "required")?.unwrap_or(true);
let var = Self::template_arg::<bool>(args, "var")?.unwrap_or(false);
let name = Self::template_arg::<String>(args, "name")?.unwrap_or(i.to_string());
let mut arg_order = arg_order.lock().map_err(Self::lock_error)?;
if arg_order.contains_key(&name) {
trace!("already seen {name}");
return Ok(json!(""));
}
arg_order.insert(name.clone(), i);
let usage = Self::template_arg::<String>(args, "usage")?.unwrap_or_default();
let help = Self::template_arg::<String>(args, "help")?;
let help_long = Self::template_arg::<String>(args, "help_long")?;
let help_md = Self::template_arg::<String>(args, "help_md")?;
let var_min = Self::template_arg::<i64>(args, "var_min")?.map(|v| v as usize);
let var_max = Self::template_arg::<i64>(args, "var_max")?.map(|v| v as usize);
let hide = Self::template_arg::<bool>(args, "hide")?.unwrap_or(false);
let default = Self::template_arg::<String>(args, "default")?
.map(|s| vec![s])
.unwrap_or_default();
let choices = Self::template_arg::<Vec<String>>(args, "choices")?
.map(usage::SpecChoices::new);
let env = Self::template_arg::<String>(args, "env")?;
let help_first_line = match &help {
Some(h) => {
if h.is_empty() {
None
} else {
h.lines().next().map(|line| line.to_string())
}
}
None => None,
};
let mut arg = usage::SpecArg::builder().name(name.clone()).build();
arg.usage = usage;
arg.help_first_line = help_first_line;
arg.help = help;
arg.help_long = help_long;
arg.help_md = help_md;
arg.required = required;
arg.var = var;
arg.var_min = var_min;
arg.var_max = var_max;
arg.hide = hide;
arg.default = default;
arg.choices = choices;
arg.env = env;
arg.usage = arg.usage();
input_args.lock().map_err(Self::lock_error)?.push(arg);
Ok(json!(""))
}
});
Self::register_template_function(&mut tera, "option", {
let input_flags = input_flags.clone();
move |args| {
let name = Self::string_arg(args, "name")?;
let short = Self::template_arg::<String>(args, "short")?
.map(|s| s.chars().collect())
.unwrap_or_default();
let long = match Self::template_arg::<String>(args, "long")? {
Some(l) => l.split_whitespace().map(|s| s.to_string()).collect(),
None => vec![name.clone()],
};
let default = Self::template_arg::<String>(args, "default")?
.map(|s| vec![s])
.unwrap_or_default();
let var = Self::template_arg::<bool>(args, "var")?.unwrap_or(false);
let deprecated = Self::template_arg::<String>(args, "deprecated")?;
let help = Self::template_arg::<String>(args, "help")?;
let help_long = Self::template_arg::<String>(args, "help_long")?;
let help_md = Self::template_arg::<String>(args, "help_md")?;
let hide = Self::template_arg::<bool>(args, "hide")?.unwrap_or(false);
let global = Self::template_arg::<bool>(args, "global")?.unwrap_or(false);
let count = Self::template_arg::<bool>(args, "count")?.unwrap_or(false);
let usage = Self::template_arg::<String>(args, "usage")?.unwrap_or_default();
let required = Self::template_arg::<bool>(args, "required")?.unwrap_or(false);
let negate = Self::template_arg::<String>(args, "negate")?;
let choices = Self::template_arg::<Vec<String>>(args, "choices")?
.map(usage::SpecChoices::new);
let env = Self::template_arg::<String>(args, "env")?;
let help_first_line = match &help {
Some(h) => {
if h.is_empty() {
None
} else {
h.lines().next().map(|line| line.to_string())
}
}
None => None,
};
let mut flag = usage::SpecFlag::builder().name(name.clone()).build();
flag.short = short;
flag.long = long;
flag.default = default;
flag.var = var;
flag.var_min = None;
flag.var_max = None;
flag.hide = hide;
flag.global = global;
flag.count = count;
flag.deprecated = deprecated;
flag.help_first_line = help_first_line;
flag.help = help;
flag.usage = usage;
flag.help_long = help_long;
flag.help_md = help_md;
flag.required = required;
flag.negate = negate;
flag.env = env.clone();
let mut value_arg = usage::SpecArg::builder().name(name.clone()).build();
value_arg.var = var;
value_arg.choices = choices;
value_arg.env = env;
flag.arg = Some(value_arg);
flag.usage = flag.usage();
input_flags.lock().map_err(Self::lock_error)?.push(flag);
Ok(json!(""))
}
});
Self::register_template_function(&mut tera, "flag", {
let input_flags = input_flags.clone();
move |args| {
let name = Self::string_arg(args, "name")?;
let short = Self::template_arg::<String>(args, "short")?
.map(|s| s.chars().collect())
.unwrap_or_default();
let long = match Self::template_arg::<String>(args, "long")? {
Some(l) => l.split_whitespace().map(|s| s.to_string()).collect(),
None => vec![name.clone()],
};
let default = Self::template_arg::<String>(args, "default")?
.map(|s| vec![s])
.unwrap_or_default();
let var = Self::template_arg::<bool>(args, "var")?.unwrap_or(false);
let deprecated = Self::template_arg::<String>(args, "deprecated")?;
let help = Self::template_arg::<String>(args, "help")?;
let help_long = Self::template_arg::<String>(args, "help_long")?;
let help_md = Self::template_arg::<String>(args, "help_md")?;
let hide = Self::template_arg::<bool>(args, "hide")?.unwrap_or(false);
let global = Self::template_arg::<bool>(args, "global")?.unwrap_or(false);
let count = Self::template_arg::<bool>(args, "count")?.unwrap_or(false);
let usage = Self::template_arg::<String>(args, "usage")?.unwrap_or_default();
let required = Self::template_arg::<bool>(args, "required")?.unwrap_or(false);
let negate = Self::template_arg::<String>(args, "negate")?;
let choices = Self::template_arg::<Vec<String>>(args, "choices")?
.map(usage::SpecChoices::new);
let env = Self::template_arg::<String>(args, "env")?;
let help_first_line = match &help {
Some(h) => {
if h.is_empty() {
None
} else {
h.lines().next().map(|line| line.to_string())
}
}
None => None,
};
// Create SpecArg when any arg-level properties are set (choices, env)
// This matches the behavior of option() which always creates SpecArg
let arg = if choices.is_some() || env.is_some() {
Some({
let mut a = usage::SpecArg::builder().name(name.clone()).build();
a.var = var;
a.choices = choices;
a.env = env.clone();
a
})
} else {
None
};
let mut flag = usage::SpecFlag::builder().name(name.clone()).build();
flag.short = short;
flag.long = long;
flag.default = default;
flag.var = var;
flag.var_min = None;
flag.var_max = None;
flag.hide = hide;
flag.global = global;
flag.count = count;
flag.deprecated = deprecated;
flag.help_first_line = help_first_line;
flag.help = help;
flag.usage = usage;
flag.help_long = help_long;
flag.help_md = help_md;
flag.required = required;
flag.negate = negate;
flag.env = env;
flag.arg = arg;
flag.usage = flag.usage();
input_flags.lock().map_err(Self::lock_error)?.push(flag);
Ok(json!(""))
}
});
self.register_task_source_files_function(&mut tera, task);
(tera, arg_order, input_args, input_flags)
}
pub async fn parse_run_scripts_for_spec_only(
&self,
config: &Arc<Config>,
task: &Task,
scripts: &[String],
) -> Result<usage::Spec> {
let usage_has_template = contains_template_syntax(&task.usage);
let scripts_have_template = scripts
.iter()
.any(|script| contains_template_syntax(script));
if !usage_has_template
&& (!scripts_have_template || Settings::get().task.disable_spec_from_run_scripts)
{
return task.usage.trim().parse().map_err(Into::into);
}
let (mut tera, arg_order, input_args, input_flags) = self.setup_tera_for_spec_parsing(task);
let mut tera_ctx = task.tera_ctx_for_usage(config).await?;
// First render the usage field to collect the spec
let rendered_usage = if usage_has_template {
Self::render_usage_with_context(&mut tera, &task.usage, &tera_ctx)?
} else {
task.usage.trim().to_string()
};
let spec_from_field: usage::Spec = rendered_usage.parse()?;
if Settings::get().task.disable_spec_from_run_scripts {
return Ok(spec_from_field);
}
// Make the arg/flag names available as snake_case in the template context, using
// default values from the spec (or sensible fallbacks when no default is provided).
let usage_ctx = Self::make_usage_ctx_from_spec_defaults(&spec_from_field);
tera_ctx.insert("usage", &usage_ctx);
// Don't insert env for spec-only parsing to avoid expensive environment rendering
// Render scripts to trigger spec collection via Tera template functions
// (arg/option/flag), but discard the results. Ignore rendering errors since we only
// care about collecting arg/flag definitions from the deprecated Tera syntax.
if scripts_have_template {
for script in scripts {
if contains_template_syntax(script) {
let _ = Self::render_script_with_context(&mut tera, script, &tera_ctx);
}
}
}
let mut cmd = usage::SpecCommand::default();
// TODO: ensure no gaps in args, e.g.: 1,2,3,4,5
let arg_order = arg_order.lock().unwrap();
cmd.args = input_args
.lock()
.unwrap()
.iter()
.cloned()
.sorted_by_key(|arg| {
arg_order
.get(&arg.name)
.unwrap_or_else(|| panic!("missing arg order for {}", arg.name.as_str()))
})
.collect();
cmd.flags = input_flags.lock().unwrap().clone();
// Check for deprecated Tera template args usage
Self::check_tera_args_deprecation(&task.name, &cmd.args, &cmd.flags);
let mut spec = usage::Spec::default();
spec.cmd = cmd;
spec.merge(spec_from_field);
Ok(spec)
}
pub async fn parse_run_scripts(
&self,
config: &Arc<Config>,
task: &Task,
scripts: &[String],
env: &EnvMap,
) -> Result<(Vec<String>, usage::Spec)> {
let usage_has_template = contains_template_syntax(&task.usage);
let scripts_have_template = scripts
.iter()
.any(|script| contains_template_syntax(script));
if !usage_has_template && !scripts_have_template {
let scripts = scripts.iter().map(|s| s.trim().to_string()).collect();
return Ok((scripts, task.usage.trim().parse()?));
}
let (mut tera, arg_order, input_args, input_flags) = self.setup_tera_for_spec_parsing(task);
let mut tera_ctx = task.tera_ctx_for_usage(config).await?;
self.inject_extra_vars(&mut tera_ctx);
tera_ctx.insert("env", &env);
// First render the usage field to collect the spec and build a default
// usage map, so that `{{ usage.* }}` references in run scripts do not
// fail during this initial parsing phase (e.g. for inline tasks).
let rendered_usage = if usage_has_template {
Self::render_usage_with_context(&mut tera, &task.usage, &tera_ctx)?
} else {
task.usage.trim().to_string()
};
let spec_from_field: usage::Spec = rendered_usage.parse()?;
let usage_ctx = Self::make_usage_ctx_from_spec_defaults(&spec_from_field);
tera_ctx.insert("usage", &usage_ctx);
let scripts = if scripts_have_template {
scripts
.iter()
.map(|s| {
if contains_template_syntax(s) {
Self::render_script_with_context(&mut tera, s, &tera_ctx)
} else {
Ok(s.trim().to_string())
}
})
.collect::<Result<Vec<String>>>()?
} else {
scripts.iter().map(|s| s.trim().to_string()).collect()
};
let mut cmd = usage::SpecCommand::default();
// TODO: ensure no gaps in args, e.g.: 1,2,3,4,5
let arg_order = arg_order.lock().unwrap();
cmd.args = input_args
.lock()
.unwrap()
.iter()
.cloned()
.sorted_by_key(|arg| {
arg_order
.get(&arg.name)
.unwrap_or_else(|| panic!("missing arg order for {}", arg.name.as_str()))
})
.collect();
cmd.flags = input_flags.lock().unwrap().clone();
// Check for deprecated Tera template args usage
Self::check_tera_args_deprecation(&task.name, &cmd.args, &cmd.flags);
let mut spec = usage::Spec::default();
spec.cmd = cmd;
spec.merge(spec_from_field);
Ok((scripts, spec))
}
pub async fn parse_run_scripts_with_args(
&self,
config: &Arc<Config>,
task: &Task,
scripts: &[String],
env: &EnvMap,
args: &[String],
spec: &usage::Spec,
) -> Result<Vec<String>> {
let args = vec!["".to_string()]
.into_iter()
.chain(args.iter().cloned())
.collect::<Vec<_>>();
// Pass env vars to Parser so it can resolve env= defaults in usage specs
// This is needed for monorepo tasks where child config env vars aren't in the process env
let env_map: std::collections::HashMap<String, String> =
env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
let m = match usage::Parser::new(spec).with_env(env_map).parse(&args) {
Ok(m) => m,
Err(e) => {
// just print exactly what usage returns so the error output isn't double-wrapped
// this could be displaying help or a parse error
eprintln!("{}", format!("{e}").trim_end());
return Err(request_exit(1));
}
};
let mut out: Vec<String> = vec![];
for script in scripts {
if !contains_template_syntax(script) {
out.push(script.trim().to_string());
continue;
}
let shell_type = shell_from_shebang(script)
.or(task.shell()?)
.unwrap_or(Settings::get().default_inline_shell()?)[0]
.parse()
.ok();
let escape = {
move |v: &usage::parse::ParseValue| match v {
usage::parse::ParseValue::MultiString(_) => {
// these are already escaped
v.to_string()
}
_ => match shell_type {
Some(ShellType::Zsh | ShellType::Bash | ShellType::Fish) => {
shell_words::quote(&v.to_string()).to_string()
}
_ => v.to_string(),
},
}
};
let mut tera = self.get_tera();
self.register_task_source_files_function(&mut tera, task);
Self::register_template_function(&mut tera, "arg", {
{
let usage_args = m.args.clone();
move |args| {
let seen_args = Arc::new(Mutex::new(HashSet::new()));
{
let mut seen_args = seen_args.lock().unwrap();
let i = Self::template_arg::<i64>(args, "i")?
.map(|i| i as usize)
.unwrap_or_else(|| seen_args.len());
let name = Self::template_arg::<String>(args, "name")?
.unwrap_or(i.to_string());
seen_args.insert(name.clone());
Ok(json!(
usage_args
.iter()
.find(|(arg, _)| arg.name == name)
.map(|(_, value)| escape(value))
.unwrap_or("".to_string())
))
}
}
}
});
let flag_func = {
|default_value: String| {
let usage_flags = m.flags.clone();
move |args: &HashMap<String, JsonValue>| {
let name = Self::string_arg(args, "name")?;
Ok(json!(
usage_flags
.iter()
.find(|(flag, _)| flag.name == name)
.map(|(_, value)| escape(value))
.unwrap_or(default_value.clone())
))
}
}
};
Self::register_template_function(&mut tera, "option", flag_func("".to_string()));
Self::register_template_function(&mut tera, "flag", flag_func(false.to_string()));
let mut tera_ctx = task.tera_ctx_for_usage(config).await?;
self.inject_extra_vars(&mut tera_ctx);
tera_ctx.insert("env", &env);
let mut usage_map = Self::make_usage_ctx_from_spec_defaults(spec);
usage_map.extend(Self::make_usage_ctx(&m));
tera_ctx.insert("usage", &usage_map);
out.push(Self::render_script_with_context(
&mut tera, script, &tera_ctx,
)?);
}
Ok(out)
}
pub(crate) fn make_usage_ctx(
usage: &usage::parse::ParseOutput,
) -> HashMap<String, tera::Value> {
let mut usage_ctx: HashMap<String, tera::Value> = HashMap::new();
// These values are not escaped or shell-quoted.
let to_tera_value = |val: &usage::parse::ParseValue| -> tera::Value {
use tera::Value;
use usage::parse::ParseValue::*;
match val {
MultiBool(v) => Value::from(v.len()),
MultiString(v) => Value::from(v.to_vec()),
Bool(v) => Value::from(*v),
String(v) => Value::from(v.clone()),
}
};
// The names are converted to snake_case (hyphens become underscores).
// For example, a flag like "--dry-run" becomes accessible as {{ usage.dry_run }}.
for (arg, val) in &usage.args {
let tera_val = to_tera_value(val);
usage_ctx.insert(arg.name.to_snake_case(), tera_val);
}
for (flag, val) in &usage.flags {
let tera_val = to_tera_value(val);
usage_ctx.insert(flag.name.to_snake_case(), tera_val);
}
// expose selected subcommand as {{ usage.cmd }}
if let Some(subcmd) = subcommand_name_from_parse(&usage.cmds) {
usage_ctx.insert("cmd".to_string(), tera::Value::from(subcmd));
}
usage_ctx
}
/// Build a usage context hashmap from a `usage::Spec` using default values
/// or sensible fallbacks. Recurses into subcommands so that `{{ usage.X }}`
/// references don't error during the initial template render (which is only
/// used for deprecated spec collection — actual execution re-renders via
/// `parse_run_scripts_with_args` with real parsed values).
pub fn make_usage_ctx_from_spec_defaults(spec: &usage::Spec) -> HashMap<String, tera::Value> {
let mut usage_ctx: HashMap<String, tera::Value> = HashMap::new();
fn collect_cmd_defaults(cmd: &usage::SpecCommand, ctx: &mut HashMap<String, tera::Value>) {
for arg in &cmd.args {
let name = arg.name.to_snake_case();
if ctx.contains_key(&name) {
continue;
}
let value = if arg.var {
tera::Value::from(arg.default.clone())
} else if let Some(default) = arg.default.first() {
tera::Value::from(default.clone())
} else {
tera::Value::from(String::new())
};
ctx.insert(name, value);
}
for flag in &cmd.flags {
let name = flag.name.to_snake_case();
if ctx.contains_key(&name) {
continue;
}
let value = if flag.var {
tera::Value::from(flag.default.clone())
} else if flag.count {
tera::Value::from(0)
} else if let Some(default) = flag.default.first() {
default
.parse::<bool>()
.map_or_else(|_| tera::Value::from(default.clone()), tera::Value::from)
} else {
tera::Value::from(false)
};
ctx.insert(name, value);
}
// SpecCommand::subcommands is an IndexMap, so iteration order is deterministic
for subcmd in cmd.subcommands.values() {
collect_cmd_defaults(subcmd, ctx);
}
}
collect_cmd_defaults(&spec.cmd, &mut usage_ctx);
if !spec.cmd.subcommands.is_empty() {
usage_ctx.insert("cmd".to_string(), tera::Value::from(String::new()));
}
usage_ctx
}
}
pub fn has_any_args_defined(spec: &usage::Spec) -> bool {
!spec.cmd.args.is_empty() || !spec.cmd.flags.is_empty() || !spec.cmd.subcommands.is_empty()
}
/// Check if the spec has any usage directives at all (args, flags, subcommands, or metadata
/// like long_about/before_help/after_help). Used to decide whether to show usage-based help
/// vs the generic task help.
///
/// Note: before_help_long is excluded because populate_spec_metadata()
/// sets it automatically for tasks with dependencies.
pub fn has_any_usage_spec(spec: &usage::Spec) -> bool {
has_any_args_defined(spec)
|| spec.about.is_some()
|| spec.about_long.is_some()
|| spec.about_md.is_some()
|| spec.cmd.help_long.is_some()
|| spec.cmd.help_md.is_some()
|| spec.cmd.before_help.is_some()
|| spec.cmd.after_help.is_some()
|| spec.cmd.after_help_long.is_some()
|| !spec.cmd.examples.is_empty()
|| !spec.examples.is_empty()
}
/// Extract the selected subcommand name from parsed commands.
/// `cmds[0]` is the root command; subsequent entries are subcommands.
pub fn subcommand_name_from_parse(cmds: &[usage::SpecCommand]) -> Option<String> {
if cmds.len() > 1 {
let names: Vec<String> = cmds.iter().skip(1).map(|c| c.name.clone()).collect();
Some(names.join(" "))
} else {
None
}
}
fn shell_from_shebang(script: &str) -> Option<Vec<String>> {
let shebang = script.lines().next()?.strip_prefix("#!")?;
let shebang = shebang.strip_prefix("/usr/bin/env -S").unwrap_or(shebang);
let shebang = shebang.strip_prefix("/usr/bin/env").unwrap_or(shebang);
let mut parts = shebang.split_whitespace();
let shell = parts.next()?;
let args = parts.map(|s| s.to_string()).collect_vec();
Some(once(shell.to_string()).chain(args).collect())
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
static TEST_SETTINGS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct TeraV1Guard {
_lock: std::sync::MutexGuard<'static, ()>,
}
impl TeraV1Guard {
fn new() -> Self {
let lock = TEST_SETTINGS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
Settings::override_with(|settings| settings.tera_v1 = Some(true));
Self { _lock: lock }
}
}
impl Drop for TeraV1Guard {
fn drop(&mut self) {
Settings::reset(None);
}
}
#[tokio::test]
async fn test_task_parse_arg() {
let config = Config::get().await.unwrap();
let task = Task::default();
let parser = TaskScriptParser::new(None);
let scripts = vec!["echo {{ arg(i=0, name='foo') }}".to_string()];
let (parsed_scripts, spec) = parser
.parse_run_scripts(&config, &task, &scripts, &Default::default())
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo "]);
let arg0 = spec.cmd.args.first().unwrap();
assert_eq!(arg0.name, "foo");
let parsed_scripts = parser
.parse_run_scripts_with_args(
&config,
&task,
&scripts,
&Default::default(),
&["abc".to_string()],
&spec,
)
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo abc"]);
}
#[tokio::test]
async fn test_task_parse_multi_use_arg() {
let config = Config::get().await.unwrap();
let task = Task::default();
let parser = TaskScriptParser::new(None);
let scripts = vec![
"echo {{ arg(name='foo') }}; echo {{ arg(name='bar') }}; echo {{ arg(name='foo') }}"
.to_string(),
];
let (parsed_scripts, spec) = parser
.parse_run_scripts(&config, &task, &scripts, &Default::default())
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo ; echo ; echo "]);
let arg0 = spec.cmd.args.first().unwrap();
let arg1 = spec.cmd.args.get(1).unwrap();
assert_eq!(arg0.name, "foo");
assert_eq!(arg1.name, "bar");
assert_eq!(spec.cmd.args.len(), 2);
let parsed_scripts = parser
.parse_run_scripts_with_args(
&config,
&task,
&scripts,
&Default::default(),
&["abc".to_string(), "def".to_string()],
&spec,
)
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo abc; echo def; echo abc"]);
}
#[tokio::test]
async fn test_task_parse_arg_var() {
let config = Config::get().await.unwrap();
let task = Task::default();
let parser = TaskScriptParser::new(None);
let scripts = vec!["echo {{ arg(var=true) }}".to_string()];
let (parsed_scripts, spec) = parser
.parse_run_scripts(&config, &task, &scripts, &Default::default())
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo "]);
let arg0 = spec.cmd.args.first().unwrap();
assert_eq!(arg0.name, "0");
let parsed_scripts = parser
.parse_run_scripts_with_args(
&config,
&task,
&scripts,
&Default::default(),
&["abc".to_string(), "def".to_string()],
&spec,
)
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo abc def"]);
}
#[tokio::test]
async fn test_task_parse_flag() {
let config = Config::get().await.unwrap();
let task = Task::default();
let parser = TaskScriptParser::new(None);
let scripts = vec!["echo {{ flag(name='foo') }}".to_string()];
let (parsed_scripts, spec) = parser
.parse_run_scripts(&config, &task, &scripts, &Default::default())
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo "]);
let flag = spec.cmd.flags.iter().find(|f| &f.name == "foo").unwrap();
assert_eq!(&flag.name, "foo");
let parsed_scripts = parser
.parse_run_scripts_with_args(
&config,
&task,
&scripts,
&Default::default(),
&["--foo".to_string()],
&spec,
)
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo true"]);
let scripts = vec!["echo {{ flag(name='foo') }}".to_string()];
let (parsed_scripts, spec) = parser
.parse_run_scripts(&config, &task, &scripts, &Default::default())
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo "]);
let parsed_scripts = parser
.parse_run_scripts_with_args(&config, &task, &scripts, &Default::default(), &[], &spec)
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo false"]);
}
#[tokio::test]
async fn test_task_parse_option() {
let config = Config::get().await.unwrap();
let task = Task::default();
let parser = TaskScriptParser::new(None);
let scripts = vec!["echo {{ option(name='foo') }}".to_string()];
let (parsed_scripts, spec) = parser
.parse_run_scripts(&config, &task, &scripts, &Default::default())
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo "]);
let option = spec.cmd.flags.iter().find(|f| &f.name == "foo").unwrap();
assert_eq!(&option.name, "foo");
let parsed_scripts = parser
.parse_run_scripts_with_args(
&config,
&task,
&scripts,
&Default::default(),
&["--foo".to_string(), "abc".to_string()],
&spec,
)
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo abc"]);
let parsed_scripts = parser
.parse_run_scripts_with_args(&config, &task, &scripts, &Default::default(), &[], &spec)
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo "]);
}
#[tokio::test]
async fn test_task_nested_template() {
let config = Config::get().await.unwrap();
let task = Task::default();
let parser = TaskScriptParser::new(None);
let scripts =
vec!["echo {% if flag(name=env.FLAG_NAME) == 'true' %}TRUE{% endif %}".to_string()];
let env = EnvMap::from_iter(vec![("FLAG_NAME".to_string(), "foo".to_string())]);
let (parsed_scripts, spec) = parser
.parse_run_scripts(&config, &task, &scripts, &env)
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo "]);
let flag = spec.cmd.flags.first().unwrap();
assert_eq!(&flag.name, "foo");
let parsed_scripts = parser
.parse_run_scripts_with_args(
&config,
&task,
&scripts,
&env,
&["--foo".to_string()],
&spec,
)
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo TRUE"]);
}
#[tokio::test]
async fn test_task_parse_empty_help() {
let config = Config::get().await.unwrap();
let task = Task::default();
let parser = TaskScriptParser::new(None);
// Test with empty help string for arg
let scripts = vec!["echo {{ arg(name='foo', help='') }}".to_string()];
let (parsed_scripts, spec) = parser
.parse_run_scripts(&config, &task, &scripts, &Default::default())
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo "]);
let arg = spec.cmd.args.first().unwrap();
assert_eq!(arg.name, "foo");
assert_eq!(arg.help, Some("".to_string()));
assert_eq!(arg.help_first_line, None);
// Test with empty help string for option
let scripts = vec!["echo {{ option(name='bar', help='') }}".to_string()];
let (parsed_scripts, spec) = parser
.parse_run_scripts(&config, &task, &scripts, &Default::default())
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo "]);
let option = spec.cmd.flags.iter().find(|f| &f.name == "bar").unwrap();
assert_eq!(&option.name, "bar");
assert_eq!(option.help, Some("".to_string()));
assert_eq!(option.help_first_line, None);
// Test with empty help string for flag
let scripts = vec!["echo {{ flag(name='baz', help='') }}".to_string()];
let (parsed_scripts, spec) = parser
.parse_run_scripts(&config, &task, &scripts, &Default::default())
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo "]);
let flag = spec.cmd.flags.iter().find(|f| &f.name == "baz").unwrap();
assert_eq!(&flag.name, "baz");
assert_eq!(flag.help, Some("".to_string()));
assert_eq!(flag.help_first_line, None);
}
#[tokio::test]
async fn test_task_parse_option_env() {
let config = Config::get().await.unwrap();
let task = Task::default();
let parser = TaskScriptParser::new(None);
let scripts = vec!["echo {{ option(name='profile', env='BUILD_PROFILE') }}".to_string()];
let (parsed_scripts, spec) = parser
.parse_run_scripts(&config, &task, &scripts, &Default::default())
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo "]);
let option = spec
.cmd
.flags
.iter()
.find(|f| &f.name == "profile")
.unwrap();
assert_eq!(&option.name, "profile");
assert_eq!(option.env, Some("BUILD_PROFILE".to_string()));
// Verify the nested SpecArg also has the env field set
let arg = option.arg.as_ref().unwrap();
assert_eq!(arg.env, Some("BUILD_PROFILE".to_string()));
}
#[tokio::test]
async fn test_task_template_tera_contrib_helpers() {
let config = Config::get().await.unwrap();
let task = Task::default();
let parser = TaskScriptParser::new(None);
let scripts = vec!["echo {{ now() | date(format='%Y') }}".to_string()];
let (parsed_scripts, _) = parser
.parse_run_scripts(&config, &task, &scripts, &Default::default())
.await
.unwrap();
let year = parsed_scripts[0].strip_prefix("echo ").unwrap();
assert_eq!(year.len(), 4);
assert!(year.chars().all(|c| c.is_ascii_digit()));
}
#[tokio::test]
async fn test_task_template_uses_tera_v1_when_enabled() {
let config = Config::get().await.unwrap();
let _guard = TeraV1Guard::new();
let task = Task::default();
let parser = TaskScriptParser::new(None);
let scripts = vec![
"{% macro greet(name) %}hi {{ name }}{% endmacro %}echo {{ self::greet(name=arg(name='name', default='mise')) }} {{ now(timestamp=true) }}"
.to_string(),
];
let (_, spec) = parser
.parse_run_scripts(&config, &task, &scripts, &Default::default())
.await
.unwrap();
let parsed_scripts = parser
.parse_run_scripts_with_args(&config, &task, &scripts, &Default::default(), &[], &spec)
.await
.unwrap();
let (greeting, timestamp) = parsed_scripts[0].rsplit_once(' ').unwrap();
assert_eq!(greeting, "echo hi mise");
assert!(timestamp.chars().all(|c| c.is_ascii_digit()));
}
#[tokio::test]
async fn test_task_parse_task_source_files() {
let cases: &[(&[&str], &str, &str)] = &[
(&[], "echo {{ task_source_files() }}", "echo []"),
(
&["**/filetask"],
"echo {{ task_source_files() | first }}",
"echo .mise/tasks/filetask", // created by constructor in `src/test.rs`, guaranteed to exist
),
(
&["nonexistent/*.xyz"],
"echo {{ task_source_files() }}",
"echo []",
),
(
&["../../Cargo.toml"],
"echo {{ task_source_files() | first }}",
"echo ../../Cargo.toml",
),
(
&[concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")],
"echo {{ task_source_files() | first }}",
concat!("echo ", env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"),
),
#[cfg(not(windows))] // TODO: this cases panics on windows currently
(
&["{{ env.HOME }}/file.txt", "src/*.rs"],
"echo {{ task_source_files() | first }}",
"echo {{ env.HOME }}/file.txt",
),
(
&["[invalid"],
"echo {{ task_source_files() | first }}",
"echo [invalid",
),
(
&[
concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"),
concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"),
],
"{% for file in task_source_files() %}echo {{ file }}; {% endfor %}",
concat!(
"echo ",
env!("CARGO_MANIFEST_DIR"),
"/Cargo.toml; echo ",
env!("CARGO_MANIFEST_DIR"),
"/README.md; ",
),
),
// `!` excludes a previously matched file
(
&["**/filetask", "!**/filetask"],
"echo {{ task_source_files() }}",
"echo []",
),
];
for (sources, template, expected) in cases {
let (sources, template, expected) = (*sources, *template, *expected);
let (mut task, scripts, parser, config) = (
Task::default(),
vec![template.into()],
TaskScriptParser::new(None),
Config::get().await.unwrap(),
);
task.sources = sources.iter().map(ToString::to_string).collect();
let (parsed, _) = parser
.parse_run_scripts(&config, &task, &scripts, &Default::default())
.await
.unwrap();
#[cfg(windows)]
let expected = expected.replace("/", r"\"); // 🙄
assert_eq!(parsed, vec![expected]);
}
}
#[tokio::test]
async fn test_task_source_files_resolves_relative_to_parser_dir() {
let config = Config::get().await.unwrap();
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project[1]");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("input.txt"), "test").unwrap();
let task = Task {
sources: vec!["*.txt".to_string()],
..Default::default()
};
let parser = TaskScriptParser::new(Some(root));
let scripts = vec!["echo {{ task_source_files() | first }}".to_string()];
let (parsed, _) = parser
.parse_run_scripts(&config, &task, &scripts, &Default::default())
.await
.unwrap();
assert_eq!(parsed, vec!["echo input.txt"]);
}
#[tokio::test]
async fn test_task_source_files_with_usage_args() {
let config = Config::get().await.unwrap();
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
let task = Task {
usage: r#"arg "[files]" var=#true"#.to_string(),
sources: vec![source.to_string_lossy().to_string()],
..Default::default()
};
let parser = TaskScriptParser::new(None);
let scripts = vec![
"{% if usage.files %}echo {{ usage.files | join(sep=' ') }}{% else %}echo {{ task_source_files() | join(sep=' ') }}{% endif %}".to_string(),
];
let (_, spec) = parser
.parse_run_scripts(&config, &task, &scripts, &Default::default())
.await
.unwrap();
let parsed = parser
.parse_run_scripts_with_args(&config, &task, &scripts, &Default::default(), &[], &spec)
.await
.unwrap();
assert_eq!(parsed, vec![format!("echo {}", source.display())]);
let parsed = parser
.parse_run_scripts_with_args(
&config,
&task,
&scripts,
&Default::default(),
&["mise.toml".to_string()],
&spec,
)
.await
.unwrap();
assert_eq!(parsed, vec!["echo mise.toml"]);
}
#[tokio::test]
async fn test_task_usage_hashmap() {
let task = Task::default();
let parser = TaskScriptParser::new(None);
// Manually construct a spec with one arg ("foo") and one flag ("bar")
// so this test does not rely on run-script parsing.
let mut cmd = usage::SpecCommand::default();
cmd.args.push(usage::SpecArg::builder().name("foo").build());
// Ensure the flag is recognized as `--bar` by the usage parser
cmd.flags
.push(usage::SpecFlag::builder().name("bar").long("bar").build());
let mut spec = usage::Spec::default();
spec.cmd = cmd;
let config = Config::get().await.unwrap();
// Now test that the usage hashmap is accessible in templates when values are provided
let scripts_with_usage = vec!["echo arg:{{ usage.foo }} flag:{{ usage.bar }}".to_string()];
let parsed_scripts = parser
.parse_run_scripts_with_args(
&config,
&task,
&scripts_with_usage,
&Default::default(),
&["test_value".to_string(), "--bar".to_string()],
&spec,
)
.await
.unwrap();
// The usage hashmap should contain the parsed values
// For a string arg, it should be "test_value"
// For a bool flag, it should be "true"
assert_eq!(parsed_scripts, vec!["echo arg:test_value flag:true"]);
// Test without the flag – usage.foo should still be available, but usage.bar
// should be undefined (accessing it in the template would error), so we only
// reference usage.foo here.
let scripts_with_usage_arg_only = vec!["echo arg:{{ usage.foo }}".to_string()];
let parsed_scripts = parser
.parse_run_scripts_with_args(
&config,
&task,
&scripts_with_usage_arg_only,
&Default::default(),
&["test_value2".to_string()],
&spec,
)
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo arg:test_value2"]);
// flag defaults to false when not provided
let scripts_with_missing_flag = vec!["echo flag:{{ usage.bar }}".to_string()];
let parsed_scripts = parser
.parse_run_scripts_with_args(
&config,
&task,
&scripts_with_missing_flag,
&Default::default(),
&["only_arg_value".to_string()], // no --bar flag provided
&spec,
)
.await
.unwrap();
assert_eq!(parsed_scripts, vec!["echo flag:false"]);
}
#[tokio::test]
async fn test_task_usage_multistring() {
let task = Task::default();
let parser = TaskScriptParser::new(None);
// Manually construct a spec with a var=true arg so usage-lib will produce a MultiString value
let mut cmd = usage::SpecCommand::default();
cmd.args
.push(usage::SpecArg::builder().name("tags").var(true).build());
let mut spec = usage::Spec::default();
spec.cmd = cmd;
let config = Config::get().await.unwrap();
// The script only uses the usage map, it does not rely on run-script parsing to build the spec
let scripts_with_usage = vec![
"echo count={{ usage.tags | length }} first={{ usage.tags[0] }} second={{ usage.tags[1] }}"
.to_string(),
];
let parsed_scripts = parser
.parse_run_scripts_with_args(
&config,
&task,
&scripts_with_usage,
&Default::default(),
&["one".to_string(), "two".to_string()],
&spec,
)
.await
.unwrap();
assert_eq!(
parsed_scripts,
vec!["echo count=2 first=one second=two"],
"expected MultiString arg to be exposed as an array in the usage map"
);
}
/// Parse a usage spec and render a template with the given args.
async fn render_usage(usage_kdl: &str, template: &str, args: &[&str]) -> String {
let config = Config::get().await.unwrap();
let task = Task {
usage: usage_kdl.to_string(),
..Default::default()
};
let parser = TaskScriptParser::new(None);
let scripts = vec![template.to_string()];
let (_, spec) = parser
.parse_run_scripts(&config, &task, &scripts, &Default::default())
.await
.unwrap();
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let parsed = parser
.parse_run_scripts_with_args(
&config,
&task,
&scripts,
&Default::default(),
&args,
&spec,
)
.await
.unwrap();
parsed.into_iter().next().unwrap()
}
/// Same as `render_usage` but with a custom env map.
async fn render_usage_with_env(
usage_kdl: &str,
template: &str,
args: &[&str],
env: &EnvMap,
) -> String {
let config = Config::get().await.unwrap();
let task = Task {
usage: usage_kdl.to_string(),
..Default::default()
};
let parser = TaskScriptParser::new(None);
let scripts = vec![template.to_string()];
let (_, spec) = parser
.parse_run_scripts(&config, &task, &scripts, env)
.await
.unwrap();
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
parser
.parse_run_scripts_with_args(&config, &task, &scripts, env, &args, &spec)
.await
.unwrap()
.into_iter()
.next()
.unwrap()
}
#[tokio::test]
async fn test_usage_arg_renders() {
// Required arg
assert_eq!(
render_usage(r#"arg "<file>""#, "echo {{ usage.file }}", &["test.txt"]).await,
"echo test.txt"
);
// Multiple args
assert_eq!(
render_usage(
"arg \"<src>\"\narg \"<dst>\"",
"echo {{ usage.src }} {{ usage.dst }}",
&["a", "b"]
)
.await,
"echo a b"
);
// Default value
assert_eq!(
render_usage(
r#"arg "<file>" default="f.txt""#,
"echo {{ usage.file }}",
&[]
)
.await,
"echo f.txt"
);
assert_eq!(
render_usage(
r#"arg "<file>" default="f.txt""#,
"echo {{ usage.file }}",
&["o.txt"]
)
.await,
"echo o.txt"
);
// Variadic
assert_eq!(
render_usage(
r#"arg "<file>" var=#true"#,
"echo {{ usage.file | join(sep=' ') }}",
&["a", "b", "c"]
)
.await,
"echo a b c"
);
// Double-dash required
assert_eq!(
render_usage(
r#"arg "<file>" double_dash="required""#,
"echo {{ usage.file }}",
&["--", "f.txt"]
)
.await,
"echo f.txt"
);
}
#[tokio::test]
async fn test_usage_arg_defaults_for_unprovided() {
// Optional arg defaults to empty string (not an error)
assert_eq!(
render_usage(r#"arg "[file]""#, "echo '{{ usage.file }}'", &[]).await,
"echo ''"
);
assert_eq!(
render_usage(r#"arg "[file]""#, "echo '{{ usage.file }}'", &["x"]).await,
"echo 'x'"
);
// Variadic optional defaults to empty array
assert_eq!(
render_usage(
r#"arg "[file]" var=#true"#,
"echo {{ usage.file | length }}",
&[]
)
.await,
"echo 0"
);
}
#[tokio::test]
async fn test_usage_arg_env() {
let env = EnvMap::from_iter(vec![("MY_FILE".to_string(), "env.txt".to_string())]);
assert_eq!(
render_usage_with_env(
r#"arg "<file>" env="MY_FILE""#,
"echo {{ usage.file }}",
&[],
&env
)
.await,
"echo env.txt"
);
}
#[tokio::test]
async fn test_usage_flag_renders() {
// Short + long with value
assert_eq!(
render_usage(
r#"flag "-u --user <user>""#,
"echo {{ usage.user }}",
&["--user", "alice"]
)
.await,
"echo alice"
);
assert_eq!(
render_usage(
r#"flag "-u --user <user>""#,
"echo {{ usage.user }}",
&["-u", "bob"]
)
.await,
"echo bob"
);
// Default value
assert_eq!(
render_usage(
r#"flag "--file <file>" default="f.txt""#,
"echo {{ usage.file }}",
&[]
)
.await,
"echo f.txt"
);
// Boolean default true
assert_eq!(
render_usage(
r#"flag "--color" default=#true"#,
"echo {{ usage.color }}",
&[]
)
.await,
"echo true"
);
// Choices
assert_eq!(
render_usage(
"flag \"--shell <shell>\" {\n choices \"bash\" \"zsh\" \"fish\"\n}",
"echo {{ usage.shell }}",
&["--shell", "zsh"]
)
.await,
"echo zsh"
);
// Variadic flag
assert_eq!(
render_usage(
r#"flag "--include <pattern>" var=#true"#,
"echo {{ usage.include | join(sep=',') }}",
&["--include", "*.rs", "--include", "*.toml"]
)
.await,
"echo *.rs,*.toml"
);
// Negate flag
assert_eq!(
render_usage(
r#"flag "--color" negate="--no-color" default=#true"#,
"echo {{ usage.color }}",
&[]
)
.await,
"echo true"
);
assert_eq!(
render_usage(
r#"flag "--color" negate="--no-color" default=#true"#,
"echo {{ usage.color }}",
&["--no-color"]
)
.await,
"echo false"
);
// Hyphenated -> snake_case
assert_eq!(
render_usage(
r#"flag "--dry-run""#,
"echo {{ usage.dry_run }}",
&["--dry-run"]
)
.await,
"echo true"
);
}
#[tokio::test]
async fn test_usage_flag_defaults_for_unprovided() {
// Boolean flag defaults to false (not an error)
assert_eq!(
render_usage(
r#"flag "-f --force""#,
"echo {{ usage.force }}",
&["--force"]
)
.await,
"echo true"
);
assert_eq!(
render_usage(r#"flag "-f --force""#, "echo {{ usage.force }}", &[]).await,
"echo false"
);
// Count flag defaults to 0
assert_eq!(
render_usage(
r#"flag "-v --verbose" count=#true"#,
"echo {{ usage.verbose }}",
&["-vvv"]
)
.await,
"echo 3"
);
}
#[tokio::test]
async fn test_usage_cmd_renders() {
let spec = "cmd \"install\" {\n arg \"<package>\"\n flag \"--force\"\n}\ncmd \"remove\" {\n arg \"<package>\"\n}";
// Subcommand routing
assert_eq!(
render_usage(
spec,
"echo {{ usage.cmd }} {{ usage.package }} {{ usage.force }}",
&["install", "foo", "--force"]
)
.await,
"echo install foo true"
);
// Different subcommand
let spec2 =
"cmd \"build\" {\n arg \"<target>\"\n}\ncmd \"test\" {\n arg \"<target>\"\n}";
assert_eq!(
render_usage(
spec2,
"echo {{ usage.cmd }}:{{ usage.target }}",
&["test", "all"]
)
.await,
"echo test:all"
);
assert_eq!(
render_usage(
spec2,
"echo {{ usage.cmd }}:{{ usage.target }}",
&["build", "release"]
)
.await,
"echo build:release"
);
// No subcommand selected — cmd defaults to empty
let spec3 = "arg \"<name>\"\ncmd \"sub\" {\n arg \"<x>\"\n}";
assert_eq!(
render_usage(
spec3,
"echo cmd={{ usage.cmd }} name={{ usage.name }}",
&["hello"]
)
.await,
"echo cmd= name=hello"
);
}
#[tokio::test]
async fn test_usage_combined() {
let spec = "arg \"<src>\"\narg \"[dst]\" default=\"out.txt\"\nflag \"-f --force\"\nflag \"-v --verbose\" count=#true\nflag \"--mode <mode>\" {\n choices \"copy\" \"move\" \"link\"\n}";
assert_eq!(
render_usage(
spec,
"echo {{ usage.src }} {{ usage.dst }} {{ usage.force }} {{ usage.mode }}",
&["input.txt", "--force", "--mode", "copy"]
)
.await,
"echo input.txt out.txt true copy"
);
}
#[tokio::test]
async fn test_usage_script_directives() {
fn parse_script_from_str(script: &str) -> usage::Spec {
use std::io::Write;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(script.as_bytes()).unwrap();
tmp.flush().unwrap();
usage::Spec::parse_script(tmp.path()).unwrap()
}
// Flag + arg from #USAGE directives
let spec = parse_script_from_str(
"#!/usr/bin/env bash\n#USAGE flag \"--user <user>\"\n#USAGE arg \"<file>\"\necho hello\n",
);
assert_eq!(spec.cmd.flags[0].name, "user");
assert_eq!(spec.cmd.args[0].name, "file");
// Multi-line KDL with choices
let spec = parse_script_from_str(
"#!/usr/bin/env bash\n#USAGE flag \"--shell <shell>\" {\n#USAGE choices \"bash\" \"zsh\" \"fish\"\n#USAGE }\necho hello\n",
);
assert_eq!(
spec.cmd.flags[0]
.arg
.as_ref()
.unwrap()
.choices
.as_ref()
.unwrap()
.choices,
vec!["bash", "zsh", "fish"]
);
}
#[tokio::test]
async fn test_usage_empty_and_spec_only() {
// Empty spec
let config = Config::get().await.unwrap();
let task = Task {
usage: "".to_string(),
..Default::default()
};
let parser = TaskScriptParser::new(None);
let (parsed, spec) = parser
.parse_run_scripts(
&config,
&task,
&["echo hello".to_string()],
&Default::default(),
)
.await
.unwrap();
assert_eq!(parsed, vec!["echo hello"]);
assert!(spec.cmd.args.is_empty() && spec.cmd.flags.is_empty());
// Spec-only parsing
let task = Task {
usage: "arg \"<file>\"\nflag \"--verbose\"".to_string(),
..Default::default()
};
let spec = parser
.parse_run_scripts_for_spec_only(&config, &task, &["echo {{ usage.file }}".to_string()])
.await
.unwrap();
assert_eq!(spec.cmd.args.len(), 1);
assert_eq!(spec.cmd.flags.len(), 1);
}
#[tokio::test]
async fn test_usage_example_directives() {
fn parse_script_from_str(script: &str) -> usage::Spec {
use std::io::Write;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(script.as_bytes()).unwrap();
tmp.flush().unwrap();
usage::Spec::parse_script(tmp.path()).unwrap()
}
// Examples from #USAGE directives are parsed into spec.examples
let spec = parse_script_from_str(
"#!/usr/bin/env bash\n#USAGE flag \"--name <name>\"\n#USAGE example \"mycli --name world\" header=\"Basic usage\"\necho hello\n",
);
assert_eq!(spec.examples.len(), 1);
assert_eq!(spec.examples[0].code, "mycli --name world");
assert_eq!(spec.examples[0].header, Some("Basic usage".to_string()));
// has_any_usage_spec recognizes examples
assert!(has_any_usage_spec(&spec));
// Examples render in help output
let help = usage::docs::cli::render_help(&spec, &spec.cmd, true);
assert!(
help.contains("Examples:"),
"help should contain Examples section"
);
assert!(
help.contains("Basic usage:"),
"help should contain example header"
);
assert!(
help.contains("$ mycli --name world"),
"help should contain example command"
);
}
#[tokio::test]
async fn test_usage_examples_survive_task_script_parser() {
// Verify examples from the task.usage field survive through
// TaskScriptParser::parse_run_scripts (the merge/processing pipeline)
let config = Config::get().await.unwrap();
let task = Task {
usage: "flag \"--name <name>\"\nexample \"mycli --name world\" header=\"Basic usage\""
.to_string(),
..Default::default()
};
let parser = TaskScriptParser::new(None);
let (_scripts, spec) = parser
.parse_run_scripts(
&config,
&task,
&["echo hello".to_string()],
&Default::default(),
)
.await
.unwrap();
// Examples should survive the merge into the final spec
assert_eq!(
spec.examples.len(),
1,
"examples should survive TaskScriptParser pipeline"
);
assert_eq!(spec.examples[0].code, "mycli --name world");
assert_eq!(spec.examples[0].header, Some("Basic usage".to_string()));
// And render in help output
let help = usage::docs::cli::render_help(&spec, &spec.cmd, true);
assert!(
help.contains("Examples:"),
"help should contain Examples section"
);
}
#[test]
fn test_has_any_usage_spec_examples_only() {
// A script with only examples (no flags or args) should be recognized
// as having usage directives. This exercises the spec.examples check in
// has_any_usage_spec (distinct from spec.cmd.examples).
fn parse_script_from_str(script: &str) -> usage::Spec {
use std::io::Write;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(script.as_bytes()).unwrap();
tmp.flush().unwrap();
usage::Spec::parse_script(tmp.path()).unwrap()
}
let spec = parse_script_from_str(
"#!/usr/bin/env bash\n#USAGE example \"mycli hello\" header=\"Greet\"\necho hi\n",
);
assert_eq!(spec.examples.len(), 1);
assert!(
has_any_usage_spec(&spec),
"spec with only examples should be recognized as having usage"
);
}
}