use crate::cli::args::{BackendArg, ToolArg};
use crate::config::config_file::mise_toml::{EnvList, ParsedToolMap, deserialize_vars};
use crate::config::config_file::toml::{TrackingTomlParser, deserialize_arr};
use crate::config::env_directive::{EnvDirective, EnvResolveOptions, EnvResults, ToolsFilter};
use crate::config::{self, Config};
use crate::path_env::PathEnv;
use crate::task::task_script_parser::TaskScriptParser;
use crate::tera::{TeraEngine, contains_template_syntax, get_tera, render_str};
use crate::ui::tree::TreeItem;
use crate::{dirs, env, file};
use console::{measure_text_width, truncate_str};
use eyre::{Result, bail, eyre};
use globset::{GlobBuilder, GlobMatcher};
use indexmap::IndexMap;
use itertools::Itertools;
use petgraph::prelude::*;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::fmt::{Debug, Display, Formatter};
use std::hash::{Hash, Hasher};
use std::iter::once;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::LazyLock as Lazy;
use std::{ffi, fmt, path};
use xx::regex;
static TASK_VARS_CACHE: Lazy<std::sync::Mutex<IndexMap<PathBuf, IndexMap<String, String>>>> =
Lazy::new(|| std::sync::Mutex::new(IndexMap::new()));
static TASK_ENV_CACHE: Lazy<std::sync::Mutex<IndexMap<PathBuf, EnvMap>>> =
Lazy::new(|| std::sync::Mutex::new(IndexMap::new()));
pub(crate) fn reset() {
TASK_VARS_CACHE.lock().unwrap().clear();
TASK_ENV_CACHE.lock().unwrap().clear();
}
pub(crate) type FailedTasks = Arc<std::sync::Mutex<Vec<(Task, Option<i32>)>>>;
mod deps;
pub(crate) mod task_cache;
mod task_cache_audit;
mod task_cache_store;
pub(crate) mod task_confirm;
pub(crate) mod task_context_builder;
mod task_dep;
pub(crate) mod task_executor;
pub(crate) mod task_fetcher;
pub(crate) mod task_file_providers;
pub(crate) mod task_helpers;
pub(crate) mod task_list;
mod task_load_context;
pub(crate) mod task_output;
pub(crate) mod task_output_handler;
pub(crate) mod task_results_display;
pub(crate) mod task_scheduler;
mod task_script_parser;
pub(crate) mod task_source_checker;
pub(crate) mod task_sources;
pub(crate) mod task_template;
pub(crate) mod task_tool_installer;
#[allow(dead_code)]
pub(crate) mod workspace;
pub(crate) use task_cache::TaskCacheOutput;
pub(crate) use task_cache::{TaskArtifactCache, TaskCacheConfig, TaskCacheMode};
pub(crate) use task_cache_audit::TaskCacheAudit;
pub(crate) use task_confirm::TaskConfirm;
pub(crate) use task_load_context::monorepo_scope;
pub(crate) use task_load_context::{
TaskLoadContext, expand_colon_task_syntax, is_workspace_project_task,
};
pub(crate) use task_output::TaskOutput;
pub(crate) use task_script_parser::{has_any_args_defined, has_any_usage_spec};
pub(crate) use task_template::TaskTemplate;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
#[doc(hidden)]
pub(crate) enum TaskRunPhase {
#[default]
Normal,
Post,
}
pub(crate) struct ResolvedTaskDependencies {
pub depends: Vec<Task>,
pub wait_for: Vec<Task>,
pub depends_post: Vec<Task>,
}
impl Task {
pub(crate) fn with_run_phase(mut self, phase: TaskRunPhase) -> Self {
self.run_phase = phase;
self
}
pub(crate) fn graph_display_name(&self) -> String {
match self.run_phase {
TaskRunPhase::Normal => self.display_name.clone(),
TaskRunPhase::Post => format!("{} (post)", self.display_name),
}
}
}
use crate::config::config_file::ConfigFile;
use crate::env_diff::EnvMap;
use crate::file::display_path;
use crate::fuzzy::{FuzzyMatcher, FuzzyPattern};
use crate::toolset::{ToolRequest, ToolSource, ToolVersionOptions, Toolset};
use crate::ui::style;
pub(crate) use deps::{Deps, TaskCompletionState, TaskCycleError, TaskDependencyState, TaskKey};
use task_dep::TaskDep;
use task_sources::{RawOutputTemplates, TaskOutputs};
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(untagged)]
pub(crate) enum TaskToolValue {
String(String),
Map(TaskToolValueMap),
}
impl<'de> Deserialize<'de> for TaskToolValue {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct TaskToolValueVisitor;
impl<'de> serde::de::Visitor<'de> for TaskToolValueVisitor {
type Value = TaskToolValue;
fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
formatter.write_str("a task tool definition as a string or table")
}
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(TaskToolValue::String(value.to_string()))
}
fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(TaskToolValue::String(value))
}
fn visit_map<M>(self, map: M) -> std::result::Result<Self::Value, M::Error>
where
M: serde::de::MapAccess<'de>,
{
let parsed =
ParsedToolMap::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
Ok(TaskToolValue::Map(parsed.into()))
}
}
deserializer.deserialize_any(TaskToolValueVisitor)
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct TaskToolValueMap {
pub version: String,
#[serde(flatten)]
pub opts: IndexMap<String, toml::Value>,
}
impl From<ParsedToolMap> for TaskToolValueMap {
fn from(parsed: ParsedToolMap) -> Self {
Self {
version: parsed.request,
opts: parsed.options,
}
}
}
impl<'de> Deserialize<'de> for TaskToolValueMap {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(ParsedToolMap::deserialize(deserializer)?.into())
}
}
impl TaskToolValue {
fn value_has_template(value: &toml::Value) -> bool {
match value {
toml::Value::String(value) => contains_template_syntax(value),
toml::Value::Array(values) => values.iter().any(Self::value_has_template),
toml::Value::Table(values) => values.values().any(Self::value_has_template),
_ => false,
}
}
pub(crate) fn to_tool_arg(&self, tool: &str) -> Result<ToolArg> {
match self {
Self::String(version) => format!("{tool}@{version}").parse(),
Self::Map(map) => {
let mut task_options = ToolVersionOptions::default();
for (key, value) in &map.opts {
task_options
.insert_option(key.clone(), value.clone())
.map_err(|err| eyre!(err))?;
}
let mut backend: BackendArg = tool.parse()?;
let mut explicit_options = backend.explicit_opts().cloned().unwrap_or_default();
explicit_options.apply_overrides(&task_options);
if !explicit_options.is_empty() {
backend.set_opts(Some(explicit_options));
}
let backend = Arc::new(backend);
let request =
ToolRequest::new(backend.clone(), &map.version, ToolSource::Argument)?;
Ok(ToolArg {
short: backend.short.clone(),
ba: backend,
version: Some(map.version.clone()),
version_type: map.version.parse()?,
tvr: Some(request),
})
}
}
}
fn has_template(&self) -> bool {
match self {
Self::String(version) => contains_template_syntax(version),
Self::Map(map) => {
contains_template_syntax(&map.version)
|| map.opts.values().any(Self::value_has_template)
}
}
}
fn render_templates(&mut self, tera: &mut TeraEngine, context: &tera::Context) -> Result<()> {
fn render_value(
value: &mut toml::Value,
tera: &mut TeraEngine,
context: &tera::Context,
) -> Result<()> {
match value {
toml::Value::String(value) if contains_template_syntax(value) => {
*value = render_str(tera, value, context)?;
}
toml::Value::Array(values) => {
for value in values {
if TaskToolValue::value_has_template(value) {
render_value(value, tera, context)?;
}
}
}
toml::Value::Table(values) => {
for (_, value) in values.iter_mut() {
if TaskToolValue::value_has_template(value) {
render_value(value, tera, context)?;
}
}
}
_ => {}
}
Ok(())
}
match self {
Self::String(version) => {
if contains_template_syntax(version) {
*version = render_str(tera, version, context)?;
}
}
Self::Map(map) => {
if contains_template_syntax(&map.version) {
map.version = render_str(tera, &map.version, context)?;
}
for value in map.opts.values_mut() {
if Self::value_has_template(value) {
render_value(value, tera, context)?;
}
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub(crate) enum RunEntry {
Script(String),
SingleTask {
task: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
args: Vec<String>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
env: IndexMap<String, String>,
},
TaskGroup { tasks: Vec<String> },
}
impl std::hash::Hash for RunEntry {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
match self {
RunEntry::Script(s) => {
0u8.hash(state);
s.hash(state);
}
RunEntry::SingleTask { task, args, env } => {
1u8.hash(state);
task.hash(state);
args.hash(state);
let mut pairs: Vec<_> = env.iter().collect();
pairs.sort_by_key(|(k, _)| k.as_str());
for (k, v) in pairs {
k.hash(state);
v.hash(state);
}
}
RunEntry::TaskGroup { tasks } => {
2u8.hash(state);
tasks.hash(state);
}
}
}
}
impl RunEntry {
pub(crate) fn render(
&self,
tera: &mut TeraEngine,
tera_ctx: &tera::Context,
) -> crate::Result<Self> {
match self {
RunEntry::Script(s) => Ok(RunEntry::Script(s.clone())),
RunEntry::SingleTask { task, args, env } => {
let task = if contains_template_syntax(task) {
render_str(tera, task, tera_ctx)?
} else {
task.clone()
};
let args = args
.iter()
.map(|a| {
if contains_template_syntax(a) {
render_str(tera, a, tera_ctx)
} else {
Ok(a.clone())
}
})
.collect::<Result<Vec<_>, _>>()?;
let env = env
.iter()
.map(|(k, v)| {
Ok((
k.clone(),
if contains_template_syntax(v) {
render_str(tera, v, tera_ctx)?
} else {
v.clone()
},
))
})
.collect::<Result<IndexMap<_, _>, tera::Error>>()?;
Ok(RunEntry::SingleTask { task, args, env })
}
RunEntry::TaskGroup { tasks } => {
let tasks = tasks
.iter()
.map(|t| {
if contains_template_syntax(t) {
render_str(tera, t, tera_ctx)
} else {
Ok(t.clone())
}
})
.collect::<Result<Vec<_>, _>>()?;
Ok(RunEntry::TaskGroup { tasks })
}
}
}
pub(crate) fn has_tera_template(&self) -> bool {
match self {
RunEntry::Script(_) => false,
RunEntry::SingleTask { task, args, env } => {
contains_template_syntax(task)
|| args.iter().any(|a| contains_template_syntax(a))
|| env.values().any(|v| contains_template_syntax(v))
}
RunEntry::TaskGroup { tasks } => tasks.iter().any(|t| contains_template_syntax(t)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub(crate) enum Silent {
#[default]
Off,
Bool(bool),
Stdout,
Stderr,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct TaskTomlBoolPresence {
hide: bool,
raw: bool,
raw_args: bool,
interactive: bool,
quiet: bool,
silent: bool,
}
impl TaskTomlBoolPresence {
pub(crate) fn record(&mut self, key: &str) {
match key {
"hide" => self.hide = true,
"raw" => self.raw = true,
"raw_args" => self.raw_args = true,
"interactive" => self.interactive = true,
"quiet" => self.quiet = true,
"silent" => self.silent = true,
_ => {}
}
}
}
impl Silent {
pub(crate) fn is_silent(&self) -> bool {
matches!(self, Silent::Bool(true) | Silent::Stdout | Silent::Stderr)
}
pub(crate) fn suppresses_stdout(&self) -> bool {
matches!(self, Silent::Bool(true) | Silent::Stdout)
}
pub(crate) fn suppresses_stderr(&self) -> bool {
matches!(self, Silent::Bool(true) | Silent::Stderr)
}
pub(crate) fn suppresses_both(&self) -> bool {
matches!(self, Silent::Bool(true))
}
}
impl Serialize for Silent {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Silent::Off | Silent::Bool(false) => serializer.serialize_bool(false),
Silent::Bool(true) => serializer.serialize_bool(true),
Silent::Stdout => serializer.serialize_str("stdout"),
Silent::Stderr => serializer.serialize_str("stderr"),
}
}
}
impl From<bool> for Silent {
fn from(b: bool) -> Self {
if b { Silent::Bool(true) } else { Silent::Off }
}
}
impl std::str::FromStr for Silent {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"true" => Ok(Silent::Bool(true)),
"false" => Ok(Silent::Off),
"stdout" => Ok(Silent::Stdout),
"stderr" => Ok(Silent::Stderr),
_ => Err(format!(
"invalid silent value: {}, expected true, false, 'stdout', or 'stderr'",
s
)),
}
}
}
impl<'de> Deserialize<'de> for Silent {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct SilentVisitor;
impl<'de> serde::de::Visitor<'de> for SilentVisitor {
type Value = Silent;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a boolean or a string ('stdout' or 'stderr')")
}
fn visit_bool<E>(self, value: bool) -> Result<Silent, E>
where
E: serde::de::Error,
{
Ok(Silent::from(value))
}
fn visit_str<E>(self, value: &str) -> Result<Silent, E>
where
E: serde::de::Error,
{
match value {
"stdout" => Ok(Silent::Stdout),
"stderr" => Ok(Silent::Stderr),
_ => Err(E::custom(format!(
"invalid silent value: '{}', expected 'stdout' or 'stderr'",
value
))),
}
}
}
deserializer.deserialize_any(SilentVisitor)
}
}
impl std::str::FromStr for RunEntry {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(RunEntry::Script(s.to_string()))
}
}
impl Display for RunEntry {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
RunEntry::Script(s) => write!(f, "{}", s),
RunEntry::SingleTask { task, args, env } => {
for (k, v) in env {
write!(f, "{}={} ", k, v)?;
}
write!(f, "task: {task}")?;
if !args.is_empty() {
write!(f, " {}", args.join(" "))?;
}
Ok(())
}
RunEntry::TaskGroup { tasks } => write!(f, "tasks: {}", tasks.join(", ")),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct TaskWatchOptions {
pub no_vcs_ignore: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct TaskRustCacheOptions {
enabled: bool,
#[serde(rename = "verify")]
_verify: bool,
}
impl Default for TaskRustCacheOptions {
fn default() -> Self {
Self {
enabled: true,
_verify: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TaskRustCacheConfig {
pub enabled: bool,
}
impl Default for TaskRustCacheConfig {
fn default() -> Self {
Self { enabled: true }
}
}
impl<'de> Deserialize<'de> for TaskRustCacheConfig {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct RustCacheVisitor;
impl<'de> serde::de::Visitor<'de> for RustCacheVisitor {
type Value = TaskRustCacheConfig;
fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
formatter.write_str("a boolean or Rust cache options table")
}
fn visit_bool<E>(self, enabled: bool) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(TaskRustCacheConfig { enabled })
}
fn visit_map<M>(self, map: M) -> std::result::Result<Self::Value, M::Error>
where
M: serde::de::MapAccess<'de>,
{
let options = TaskRustCacheOptions::deserialize(
serde::de::value::MapAccessDeserializer::new(map),
)?;
Ok(TaskRustCacheConfig {
enabled: options.enabled,
})
}
}
deserializer.deserialize_any(RustCacheVisitor)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Task {
#[serde(skip)]
pub(crate) run_phase: TaskRunPhase,
#[serde(skip)]
pub name: String,
#[serde(skip)]
pub display_name: String,
#[serde(default)]
pub description: String,
#[serde(default, rename = "alias", deserialize_with = "deserialize_arr")]
pub aliases: Vec<String>,
#[serde(skip)]
pub config_source: PathBuf,
#[serde(skip)]
pub additional_config_sources: Vec<PathBuf>,
#[serde(skip)]
pub cf: Option<Arc<dyn ConfigFile>>,
#[serde(skip)]
pub config_root: Option<PathBuf>,
#[serde(default)]
pub confirm: Option<TaskConfirm>,
#[serde(default, deserialize_with = "deserialize_arr")]
pub depends: Vec<TaskDep>,
#[serde(default, deserialize_with = "deserialize_arr")]
pub depends_post: Vec<TaskDep>,
#[serde(default, deserialize_with = "deserialize_arr")]
pub wait_for: Vec<TaskDep>,
#[serde(default)]
pub env: EnvList,
#[serde(default, deserialize_with = "deserialize_vars")]
pub vars: EnvList,
#[serde(skip)]
pub inherited_env: EnvList,
#[serde(skip)]
pub overlay_env: Vec<(EnvDirective, PathBuf)>,
#[serde(skip)]
pub overlay_vars: Vec<(EnvDirective, PathBuf)>,
#[serde(skip)]
pub(crate) toml_bool_presence: TaskTomlBoolPresence,
#[serde(default)]
pub dir: Option<String>,
#[serde(default)]
pub hide: bool,
#[serde(default)]
pub global: bool,
#[serde(default)]
pub raw: bool,
#[serde(default)]
pub raw_args: bool,
#[serde(default)]
pub interactive: bool,
#[serde(default, deserialize_with = "deserialize_arr")]
pub sources: Vec<String>,
#[serde(default)]
pub watch: Option<TaskWatchOptions>,
#[serde(default)]
pub outputs: TaskOutputs,
#[serde(default)]
pub cache: Option<TaskCacheConfig>,
#[serde(default)]
pub rust_cache: Option<TaskRustCacheConfig>,
#[serde(skip)]
pub raw_outputs: RawOutputTemplates,
#[serde(default)]
pub shell: Option<String>,
#[serde(default)]
pub quiet: bool,
#[serde(default)]
pub silent: Silent,
#[serde(default)]
pub output: Option<TaskOutput>,
#[serde(default)]
pub tools: IndexMap<String, TaskToolValue>,
#[serde(default)]
pub usage: String,
#[serde(default)]
pub timeout: Option<String>,
#[serde(default, deserialize_with = "deserialize_arr")]
pub run: Vec<RunEntry>,
#[serde(default, deserialize_with = "deserialize_arr")]
pub run_windows: Vec<RunEntry>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub file: Option<PathBuf>,
#[serde(skip)]
pub(crate) is_toml_include: bool,
#[serde(skip)]
pub(crate) config_precedence: usize,
#[serde(skip)]
pub remote_file_source: Option<String>,
#[serde(default)]
pub deny_all: bool,
#[serde(default)]
pub deny_read: bool,
#[serde(default)]
pub deny_write: bool,
#[serde(default)]
pub deny_net: bool,
#[serde(default)]
pub deny_env: bool,
#[serde(default)]
pub allow_read: Vec<std::path::PathBuf>,
#[serde(default)]
pub allow_write: Vec<std::path::PathBuf>,
#[serde(default)]
pub allow_net: Vec<String>,
#[serde(default)]
pub allow_env: Vec<String>,
#[serde(default)]
pub pass_through_env: Vec<String>,
#[serde(default)]
pub extends: Option<String>,
#[serde(skip)]
pub show_args_in_prefix: bool,
#[serde(skip)]
pub depends_raw: Option<Vec<TaskDep>>,
#[serde(skip)]
pub depends_post_raw: Option<Vec<TaskDep>>,
#[serde(skip)]
pub wait_for_raw: Option<Vec<TaskDep>>,
#[serde(skip)]
pub(crate) workspace_dependency_error: Option<String>,
#[serde(skip)]
pub trailing_args: Vec<String>,
}
#[derive(Default)]
struct TomlOpenState {
brackets: usize,
braces: usize,
multiline: Option<u8>,
}
impl TomlOpenState {
fn feed(&mut self, line: &str) {
let b = line.as_bytes();
let mut i = 0;
while i < b.len() {
let c = b[i];
if let Some(q) = self.multiline {
if q == b'"' && c == b'\\' {
i += 2;
} else if c == q && b[i + 1..].starts_with(&[q, q]) {
self.multiline = None;
i += 3;
} else {
i += 1;
}
continue;
}
match c {
b'#' => return,
b'[' => self.brackets += 1,
b']' => self.brackets = self.brackets.saturating_sub(1),
b'{' => self.braces += 1,
b'}' => self.braces = self.braces.saturating_sub(1),
b'"' | b'\'' => {
if b[i + 1..].starts_with(&[c, c]) {
self.multiline = Some(c);
i += 3;
continue;
}
let mut j = i + 1;
while j < b.len() {
if c == b'"' && b[j] == b'\\' {
j += 2;
} else if b[j] == c {
break;
} else {
j += 1;
}
}
i = j + 1;
continue;
}
_ => {}
}
i += 1;
}
}
fn is_open(&self) -> bool {
self.brackets > 0 || self.braces > 0 || self.multiline.is_some()
}
}
struct MiseHeaderEntry {
toml: String,
start: usize,
end: usize,
}
impl MiseHeaderEntry {
fn parse_toml(&self) -> Result<toml::Value> {
toml::de::from_str::<toml::Value>(&self.toml).map_err(|e| {
if self.start == self.end {
eyre!("failed to parse task header TOML {:?}: {e}", self.toml)
} else {
eyre!(
"failed to parse task header TOML on lines {}-{}:\n{}\n{e}",
self.start + 1,
self.end + 1,
self.toml
)
}
})
}
}
fn scan_mise_header_entries(body: &str) -> Vec<MiseHeaderEntry> {
let header_regex = regex!(r"^(?:#|//|::)\s*(?:MISE|\[MISE\]) (.*)$");
let entry_regex = regex!(
r#"^\s*(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')(?:\s*\.\s*(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*'))*\s*=\s*[^\n]+$"#
);
let mut entries: Vec<MiseHeaderEntry> = vec![];
let mut open: Option<(MiseHeaderEntry, TomlOpenState)> = None;
for (i, line) in body.lines().enumerate() {
let Some(captures) = header_regex.captures(line) else {
if let Some((entry, _)) = open.take() {
entries.push(entry);
}
continue;
};
let content = captures.get(1).map_or("", |m| m.as_str());
if let Some((mut entry, mut state)) = open.take() {
entry.toml.push('\n');
entry.toml.push_str(content);
entry.end = i;
state.feed(content);
if state.is_open() {
open = Some((entry, state));
} else {
entries.push(entry);
}
continue;
}
if entry_regex.is_match(content) {
let mut state = TomlOpenState::default();
state.feed(content);
let entry = MiseHeaderEntry {
toml: content.to_string(),
start: i,
end: i,
};
if state.is_open() {
open = Some((entry, state));
} else {
entries.push(entry);
}
}
}
if let Some((entry, _)) = open {
entries.push(entry);
}
entries
}
fn merge_header_value(map: &mut toml::Table, key: String, value: toml::Value) {
match (map.get_mut(&key), value) {
(Some(toml::Value::Table(existing)), toml::Value::Table(new)) => {
for (k, v) in new {
merge_header_value(existing, k, v);
}
}
(_, value) => {
map.insert(key, value);
}
}
}
fn parse_mise_header_toml(body: &str) -> Result<Vec<toml::Value>> {
scan_mise_header_entries(body)
.into_iter()
.map(|entry| entry.parse_toml())
.collect()
}
fn parse_task_dependencies(parser: &mut TrackingTomlParser<'_>, key: &str) -> Result<Vec<TaskDep>> {
parser
.get_raw(key)
.map(|value| {
deserialize_arr::<_, Vec<TaskDep>, TaskDep>(value.clone())
.map_err(|e| eyre!("failed to parse {key} field in task header: {e}"))
})
.transpose()
.map(Option::unwrap_or_default)
}
pub(crate) fn file_has_decoded_template(path: &Path, body: &str) -> bool {
use crate::config::config_file::mise_toml::toml_value_has_template;
let body = file::strip_utf8_bom(body);
if path.extension().is_some_and(|e| e == "toml") {
toml::from_str::<toml::Value>(body).is_ok_and(|v| toml_value_has_template(&v))
} else {
scan_mise_header_entries(body)
.into_iter()
.filter_map(|entry| entry.parse_toml().ok())
.any(|value| toml_value_has_template(&value))
}
}
fn parse_task_script_usage(file: &Path) -> usage::Result<usage::Spec> {
let script = std::fs::read_to_string(file)?;
let raw = extract_usage_from_comments(crate::file::strip_utf8_bom(&script));
if raw.trim().is_empty() {
return usage::Spec::parse_script(file);
}
parse_task_usage_raw(file, &hoist_root_usage_mounts(&raw).unwrap_or(raw))
}
fn parse_task_usage_raw(file: &Path, raw: &str) -> usage::Result<usage::Spec> {
let mut spec: usage::Spec = raw.parse()?;
if spec.bin.is_empty()
&& let Some(name) = file.file_name().and_then(|n| n.to_str())
{
spec.bin = name.to_string();
}
if spec.name.is_empty() {
spec.name.clone_from(&spec.bin);
}
if let Some(mount_cmd) = spec.cmd.subcommands.shift_remove("__mise_task_root_mounts") {
spec.cmd.mounts.extend(mount_cmd.mounts);
}
Ok(spec)
}
fn extract_usage_from_comments(full: &str) -> String {
let usage_regex = regex!(r"^(?:#|//|::)\s*(?:(USAGE|MISE)|\[(USAGE|MISE)\])(.*)$");
let blank_comment_regex = regex!(r"^(?:#|//|::)\s*$");
let mise_header_regex = regex!(
r#"^\s*(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')(?:\s*\.\s*(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*'))*\s*="#
);
let header_entries = scan_mise_header_entries(full);
let mut next_entry = 0;
let mut usage = vec![];
let mut found = false;
for (i, line) in full.lines().enumerate() {
while header_entries.get(next_entry).is_some_and(|e| e.end < i) {
next_entry += 1;
}
if header_entries.get(next_entry).is_some_and(|e| e.start <= i) {
continue;
}
if let Some(captures) = usage_regex.captures(line) {
let marker = captures
.get(1)
.or_else(|| captures.get(2))
.map_or("", |m| m.as_str());
let content = captures.get(3).map_or("", |m| m.as_str());
if marker == "MISE" && mise_header_regex.is_match(content.trim()) {
continue;
}
usage.push(content.trim());
found = true;
} else if found {
if blank_comment_regex.is_match(line) {
continue;
}
break;
}
}
usage.join("\n")
}
fn hoist_root_usage_mounts(raw: &str) -> Option<String> {
let mut output = vec![];
let mut mounts = vec![];
let mut depth = 0_i32;
let lines = raw.lines().collect::<Vec<_>>();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim_start();
if depth == 0 && is_mount_node(trimmed) {
let (mount, next) = collect_mount_node(&lines, i);
mounts.push(mount);
i = next;
continue;
} else {
output.push(line.to_string());
}
depth = (depth + structural_brace_delta(line)).max(0);
i += 1;
}
if mounts.is_empty() {
return None;
}
output.push("cmd \"__mise_task_root_mounts\" {".to_string());
for mount in mounts {
output.extend(mount.lines().map(|line| format!(" {line}")));
}
output.push("}".to_string());
Some(output.join("\n"))
}
fn collect_mount_node(lines: &[&str], start: usize) -> (String, usize) {
let mut node = vec![normalize_root_mount_node(lines[start].trim_start())];
let mut depth = structural_brace_delta(lines[start]).max(0);
let mut next = start + 1;
while depth > 0 && next < lines.len() {
node.push(lines[next].trim_start().to_string());
depth = (depth + structural_brace_delta(lines[next])).max(0);
next += 1;
}
(node.join("\n"), next)
}
fn structural_brace_delta(line: &str) -> i32 {
let mut delta = 0;
let mut chars = line.chars().peekable();
let mut in_string = false;
let mut escaped = false;
while let Some(ch) = chars.next() {
if in_string {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
match ch {
'"' => in_string = true,
'/' if chars.peek() == Some(&'/') => break,
'{' => delta += 1,
'}' => delta -= 1,
_ => {}
}
}
delta
}
fn is_mount_node(line: &str) -> bool {
line.strip_prefix("mount")
.is_some_and(|rest| match rest.chars().next() {
None => true,
Some(c) => c.is_whitespace() || c == '{',
})
}
fn normalize_root_mount_node(line: &str) -> String {
let Some(rest) = line.strip_prefix("mount") else {
return line.to_string();
};
let rest = rest.trim_start();
if rest.starts_with('"') || rest.starts_with("r#") {
format!("mount run={rest}")
} else {
line.to_string()
}
}
pub(crate) fn usage_command_for_args<'a>(
spec: &'a usage::Spec,
args: &[String],
) -> &'a usage::SpecCommand {
let mut cmd = &spec.cmd;
let mut idx = 0;
let mut used_default_subcommand = false;
while idx < args.len() {
let arg = &args[idx];
if arg == "-h" || arg == "--help" {
break;
}
if let Some(subcommand) = cmd.find_subcommand(arg) {
cmd = subcommand;
idx += 1;
continue;
}
if arg.starts_with('-') {
let flag_takes_value =
usage_flag_takes_value(&spec.cmd, arg) || usage_flag_takes_value(cmd, arg);
if !flag_takes_value
&& !used_default_subcommand
&& let Some(default_name) = &spec.default_subcommand
&& let Some(subcommand) = cmd.find_subcommand(default_name)
{
cmd = subcommand;
used_default_subcommand = true;
continue;
}
if !arg.contains('=') && flag_takes_value {
idx += 1;
}
idx += 1;
continue;
}
if !used_default_subcommand
&& let Some(default_name) = &spec.default_subcommand
&& let Some(subcommand) = cmd.find_subcommand(default_name)
{
cmd = subcommand;
used_default_subcommand = true;
continue;
}
break;
}
cmd
}
fn usage_flag_takes_value(cmd: &usage::SpecCommand, flag: &str) -> bool {
let flag = flag.split_once('=').map(|(flag, _)| flag).unwrap_or(flag);
if let Some(long) = flag.strip_prefix("--") {
cmd.flags
.iter()
.any(|f| f.arg.is_some() && f.long.iter().any(|f| f == long))
} else if let Some(short) = flag.strip_prefix('-').and_then(|f| f.chars().next()) {
cmd.flags
.iter()
.any(|f| f.arg.is_some() && f.short.contains(&short))
} else {
false
}
}
impl Task {
pub(crate) fn add_config_source(&mut self, source: &Path) {
if source != self.config_source
&& !self
.additional_config_sources
.iter()
.any(|existing| existing == source)
{
self.additional_config_sources.push(source.to_path_buf());
}
}
pub(crate) fn config_sources(&self) -> Vec<&Path> {
once(self.config_source.as_path())
.chain(self.additional_config_sources.iter().map(PathBuf::as_path))
.collect()
}
pub(crate) fn tool_args(&self) -> Result<Vec<ToolArg>> {
self.tools
.iter()
.map(|(tool, value)| value.to_tool_arg(tool))
.collect()
}
pub(crate) fn new(path: &Path, prefix: &Path, config_root: &Path) -> Result<Task> {
Ok(Self {
name: name_from_path(prefix, path)?,
config_source: path.to_path_buf(),
config_root: Some(config_root.to_path_buf()),
..Default::default()
})
}
pub(crate) async fn from_path(
config: &Arc<Config>,
path: &Path,
prefix: &Path,
config_root: &Path,
) -> Result<Task> {
Self::from_path_with_cf(config, path, prefix, config_root, None).await
}
pub(crate) async fn from_path_with_cf(
config: &Arc<Config>,
path: &Path,
prefix: &Path,
config_root: &Path,
cf: Option<Arc<dyn ConfigFile>>,
) -> Result<Task> {
let mut task = Self::from_path_unrendered_with_cf(path, prefix, config_root, cf)?;
task.render(config, config_root).await?;
Ok(task)
}
pub(crate) fn from_path_unrendered_with_cf(
path: &Path,
prefix: &Path,
config_root: &Path,
cf: Option<Arc<dyn ConfigFile>>,
) -> Result<Task> {
let mut task = Task::new(path, prefix, config_root)?;
task.cf = cf;
let body = file::read_to_string(path)?;
let info = parse_mise_header_toml(file::strip_utf8_bom(&body))?
.into_iter()
.filter_map(|toml| toml.as_table().cloned())
.flatten()
.fold(toml::Table::new(), |mut map, (key, value)| {
merge_header_value(&mut map, key, value);
map
});
let info = toml::Value::Table(info);
let mut p = TrackingTomlParser::new(&info);
task.description = p.parse_str("description").unwrap_or_default();
let alias_fields: Vec<&str> = ["alias", "aliases"]
.iter()
.filter(|&field| info.get(field).is_some())
.copied()
.collect();
if alias_fields.len() > 1 {
return Err(eyre::eyre!(
"Cannot define both 'alias' and 'aliases' fields in task file header: {}. Use only one.",
display_path(path)
));
}
task.aliases = p
.parse_array("alias")
.or(p.parse_array("aliases"))
.or(p.parse_str("alias").map(|s| vec![s]))
.or(p.parse_str("aliases").map(|s| vec![s]))
.unwrap_or_default();
task.confirm = p
.get_raw("confirm")
.map(|v| {
TaskConfirm::deserialize(v.clone())
.map_err(|e| eyre!("failed to parse confirm field in task header: {e}"))
})
.transpose()?;
task.depends = parse_task_dependencies(&mut p, "depends")?;
task.depends_post = parse_task_dependencies(&mut p, "depends_post")?;
task.wait_for = parse_task_dependencies(&mut p, "wait_for")?;
task.env = p.parse_env("env")?.unwrap_or_default();
task.dir = p.parse_str("dir");
task.hide = !file::is_executable(path) || p.parse_bool("hide").unwrap_or_default();
task.raw = p.parse_bool("raw").unwrap_or_default();
task.raw_args = p.parse_bool("raw_args").unwrap_or_default();
task.interactive = p.parse_bool("interactive").unwrap_or_default();
task.sources = p
.get_raw("sources")
.map(|v| {
deserialize_arr::<_, Vec<String>, String>(v.clone())
.map_err(|e| eyre!("failed to parse sources field in task header: {e}"))
})
.transpose()?
.unwrap_or_default();
task.watch = p
.get_raw("watch")
.map(|v| {
TaskWatchOptions::deserialize(v.clone())
.map_err(|e| eyre!("failed to parse watch field in task header: {e}"))
})
.transpose()?;
task.outputs = p.get_raw("outputs").map(|to| to.into()).unwrap_or_default();
task.cache = p
.get_raw("cache")
.map(|v| {
TaskCacheConfig::deserialize(v.clone())
.map_err(|e| eyre!("failed to parse cache field in task header: {e}"))
})
.transpose()?;
task.rust_cache = p
.get_raw("rust_cache")
.map(|value| {
TaskRustCacheConfig::deserialize(value.clone())
.map_err(|error| eyre!("failed to parse rust_cache field: {error}"))
})
.transpose()?;
task.file = Some(path.to_path_buf());
task.shell = p.parse_str("shell");
task.quiet = p.parse_bool("quiet").unwrap_or_default();
task.silent = p
.get_raw("silent")
.and_then(|v| Silent::deserialize(v.clone()).ok())
.unwrap_or_default();
task.output = p
.get_raw("output")
.and_then(|v| TaskOutput::deserialize(v.clone()).ok());
task.pass_through_env = p.parse_array("pass_through_env").unwrap_or_default();
task.tools = p
.parse_table("tools")
.map(|t| {
t.into_iter()
.map(|(tool, value)| {
TaskToolValue::deserialize(value)
.map(|value| (tool.clone(), value))
.map_err(|err| eyre!("failed to parse task tool `{tool}`: {err}"))
})
.collect::<Result<IndexMap<_, _>>>()
})
.transpose()?
.unwrap_or_default();
let mut unparsed = p.unparsed_keys();
unparsed.sort();
if !unparsed.is_empty() {
warn!(
"unknown field(s) {:?} in task file header, ignoring: {}",
unparsed,
display_path(path)
);
}
#[cfg(test)]
{
let fields: Vec<String> = p.parsed_keys().map(|s| s.to_string()).collect();
tests::capture_parsed_fields(fields);
}
Ok(task)
}
pub(crate) fn derive_env(&self, env_directives: &[EnvDirective]) -> Self {
let mut new_task = self.clone();
new_task.inherited_env.0.extend_from_slice(env_directives);
new_task
}
pub(crate) fn with_dependency_env(&self, env_directives: &[EnvDirective]) -> Self {
let mut new_task = self.clone();
new_task.env.0.extend_from_slice(env_directives);
new_task
}
pub(crate) fn display_name(&self, all_tasks: &BTreeMap<String, Task>) -> String {
let display_name = if let Some((prefix, task_part)) = self.name.rsplit_once(':') {
let task_without_ext = task_part.rsplitn(2, '.').last().unwrap_or_default();
format!("{}:{}", prefix, task_without_ext)
} else {
self.name
.rsplitn(2, '.')
.last()
.unwrap_or_default()
.to_string()
};
if all_tasks.contains_key(&display_name) {
self.name.clone()
} else {
display_name
}
}
pub(crate) fn is_match(&self, pat: &str) -> bool {
if self.name == pat || self.aliases.contains(&pat.to_string()) {
return true;
}
let matches = if let Some((prefix, task_part)) = self.name.rsplit_once(':') {
let task_stripped = task_part.rsplitn(2, '.').last().unwrap_or_default();
if let Some((pat_prefix, pat_task)) = pat.rsplit_once(':') {
let pat_task_stripped = pat_task.rsplitn(2, '.').last().unwrap_or_default();
prefix == pat_prefix && task_stripped == pat_task_stripped
} else {
let pat_stripped = pat.rsplitn(2, '.').last().unwrap_or_default();
task_stripped == pat_stripped
}
} else {
let name_stripped = self.name.rsplitn(2, '.').last().unwrap_or_default();
let pat_stripped = pat.rsplitn(2, '.').last().unwrap_or_default();
name_stripped == pat_stripped
};
matches || self.aliases.contains(&pat.to_string())
}
pub(crate) async fn task_dir() -> Result<PathBuf> {
let config = Config::get().await?;
let cwd = dirs::CWD.clone().unwrap_or_default();
let project_root = config.project_root.clone().unwrap_or(cwd);
config::task_creation_dir_for_dir(&project_root, &config.config_files)
}
pub(crate) fn with_args(mut self, args: Vec<String>) -> Self {
self.args = args;
self
}
pub(crate) fn prefix(&self) -> String {
let max_width = 40;
let inner = if self.show_args_in_prefix && !self.args.is_empty() {
let s = format!("{} {}", self.display_name, self.args.join(" "));
s.trim().to_string()
} else {
self.display_name.clone()
};
format!("[{}]", console::truncate_str(&inner, max_width, "…"))
}
pub(crate) fn run(&self) -> &Vec<RunEntry> {
if cfg!(windows) && !self.run_windows.is_empty() {
&self.run_windows
} else {
&self.run
}
}
pub(crate) fn run_script_strings(&self) -> Vec<String> {
self.run()
.iter()
.filter_map(|e| match e {
RunEntry::Script(s) => Some(s.clone()),
_ => None,
})
.collect()
}
pub(crate) fn all_depends(&self, tasks: &BTreeMap<String, Task>) -> Result<Vec<Task>> {
let tasks_ref = build_task_ref_map(tasks.iter());
let mut visited = HashSet::from([self.name.clone()]);
self.all_depends_recursive(&tasks_ref, &mut visited)
}
fn all_depends_recursive(
&self,
tasks: &BTreeMap<String, &Task>,
visited: &mut HashSet<String>,
) -> Result<Vec<Task>> {
if let Some(err) = &self.workspace_dependency_error {
bail!("{err}");
}
let mut depends: Vec<Task> = self
.depends
.iter()
.chain(self.depends_post.iter())
.filter(|td| !dep_has_usage_ref(td))
.map(|td| match_tasks_with_context(tasks, td, Some(self)))
.flatten_ok()
.filter_ok(|t| t.name != self.name)
.collect::<Result<Vec<_>>>()?;
for dep in depends.clone() {
if !visited.insert(dep.name.clone()) {
continue;
}
let mut extra = dep.all_depends_recursive(tasks, visited)?;
extra.retain(|t| t.name != self.name); depends.extend(extra);
}
let depends = depends.into_iter().unique().collect();
Ok(depends)
}
pub(crate) async fn resolve_depends(
&self,
config: &Arc<Config>,
tasks_to_run: &[Task],
) -> Result<ResolvedTaskDependencies> {
use crate::task::TaskLoadContext;
if let Some(err) = &self.workspace_dependency_error {
bail!("{err}");
}
let tasks_to_run: HashSet<&Task> = tasks_to_run.iter().collect();
let path_hints: Vec<String> = once(&self.name)
.chain(tasks_to_run.iter().map(|t| &t.name))
.filter_map(|name| extract_monorepo_path(name))
.chain(
self.depends
.iter()
.chain(self.wait_for.iter())
.chain(self.depends_post.iter())
.map(|td| resolve_task_pattern(&td.task, Some(self)))
.filter_map(|resolved| extract_monorepo_path(&resolved)),
)
.unique()
.collect();
let ctx = if !path_hints.is_empty() {
Some(TaskLoadContext {
path_hints,
load_all: false,
})
} else {
None
};
let all_tasks = config.tasks_with_context(ctx.as_ref()).await?;
let tasks = build_task_ref_map(all_tasks.iter());
let depends = self
.depends
.iter()
.filter(|td| !dep_has_usage_ref(td))
.map(|td| match_tasks_with_context(&tasks, td, Some(self)))
.flatten_ok()
.collect_vec();
let wait_for = self
.wait_for
.iter()
.filter(|td| !dep_has_usage_ref(td))
.map(|td| {
match_tasks_with_context(&tasks, td, Some(self))
.map(|tasks| tasks.into_iter().map(|t| (t, td)).collect_vec())
})
.flatten_ok()
.filter_map_ok(|(t, td)| {
if td.env.is_empty() && td.args.is_empty() {
tasks_to_run
.iter()
.find(|tr| tr.name == t.name)
.map(|tr| (*tr).clone())
} else {
tasks_to_run.contains(&t).then_some(t)
}
})
.collect_vec();
let depends_post = self
.depends_post
.iter()
.filter(|td| !dep_has_usage_ref(td))
.map(|td| match_tasks_with_context(&tasks, td, Some(self)))
.flatten_ok()
.filter_ok(|t| t.name != self.name)
.collect::<Result<Vec<_>>>()?;
let depends = depends
.into_iter()
.filter_ok(|t| t.name != self.name)
.collect::<Result<_>>()?;
let wait_for = wait_for
.into_iter()
.filter_ok(|t| t.name != self.name)
.collect::<Result<_>>()?;
Ok(ResolvedTaskDependencies {
depends,
wait_for,
depends_post,
})
}
pub(crate) fn resolve_workspace_task_dependencies(
&mut self,
graph: &workspace::WorkspaceProjectGraph,
project_ids_by_root: &BTreeMap<PathBuf, BTreeSet<workspace::ProjectId>>,
) -> Result<()> {
if self
.depends_post
.iter()
.chain(&self.wait_for)
.any(|dep| dep.task.starts_with('^'))
{
bail!("^task dependencies are supported only in depends");
}
if !self.depends.iter().any(|dep| dep.task.starts_with('^')) {
return Ok(());
}
let mut project_ids = BTreeSet::new();
let stable_task_names = once(self.name.as_str())
.chain(self.aliases.iter().map(String::as_str))
.filter(|name| is_workspace_project_task(name))
.collect_vec();
for name in stable_task_names {
let (project_id, _) = name
.split_once('#')
.expect("workspace project task contains #");
if let Ok(project_id) = project_id.parse::<workspace::ProjectId>()
&& graph.get(&project_id).is_some()
{
project_ids.insert(project_id);
}
}
if project_ids.is_empty()
&& let Some(config_root) = self.config_root.as_deref()
{
let config_root = file::desymlink_path(config_root);
project_ids.extend(
project_ids_by_root
.get(&config_root)
.into_iter()
.flatten()
.cloned(),
);
}
if project_ids.is_empty() {
if let Some(error) = graph.provider_discovery_error() {
self.workspace_dependency_error = Some(format!(
"failed to resolve upstream task dependencies because workspace provider \
discovery failed: {error}"
));
return Ok(());
}
self.depends
.retain(|dependency| !dependency.task.starts_with('^'));
if let Some(raw) = &mut self.depends_raw {
raw.retain(|dependency| !dependency.task.starts_with('^'));
}
return Ok(());
}
let mut upstream_roots = BTreeSet::new();
for project_id in &project_ids {
upstream_roots.extend(
graph
.matching_dependency_projects(project_id, |_| true)?
.into_iter()
.map(|project| project.root.clone()),
);
}
fn expand(dependencies: &mut Vec<TaskDep>, upstream_roots: &BTreeSet<PathBuf>) {
let mut expanded = Vec::new();
for dependency in dependencies.iter() {
let Some(task_name) = dependency
.task
.strip_prefix('^')
.filter(|task_name| !task_name.is_empty())
else {
expanded.push(dependency.clone());
continue;
};
expanded.extend(upstream_roots.iter().map(|root| {
let mut dependency = dependency.clone();
let scope = if root.as_os_str().is_empty() || root == Path::new(".") {
"//".to_string()
} else {
format!("//{}", root.to_string_lossy().replace('\\', "/"))
};
dependency.task = format!("{scope}:{task_name}");
dependency.optional = true;
dependency
}));
}
*dependencies = expanded;
}
expand(&mut self.depends, &upstream_roots);
if let Some(raw) = &mut self.depends_raw {
expand(raw, &upstream_roots);
}
Ok(())
}
pub(crate) fn set_workspace_task_dependency_error(&mut self, error: &eyre::Report) {
if self
.depends_post
.iter()
.chain(&self.wait_for)
.any(|dep| dep.task.starts_with('^'))
{
self.workspace_dependency_error =
Some("^task dependencies are supported only in depends".to_string());
} else if self.depends.iter().any(|dep| dep.task.starts_with('^')) {
self.workspace_dependency_error = Some(format!(
"failed to resolve upstream task dependencies because the workspace project graph \
could not be loaded: {error:#}"
));
}
}
pub(crate) fn should_bypass_usage_parser(&self) -> bool {
if self.raw_args {
return true;
}
self.trailing_args
.iter()
.any(|a| a == "--help" || a == "-h")
}
pub(crate) fn args_for_usage_parser(&self, spec: &usage::Spec, args: &[String]) -> Vec<String> {
if self.trailing_args.is_empty() {
return args.to_vec();
}
debug_assert!(
args.ends_with(&self.trailing_args),
"task trailing_args must be a suffix of the arguments passed to usage"
);
let Some(prefix) = args.strip_suffix(self.trailing_args.as_slice()) else {
return args.to_vec();
};
debug_assert!(
self.args.ends_with(&self.trailing_args),
"task trailing_args must be a suffix of task args"
);
let Some(task_prefix) = self.args.strip_suffix(self.trailing_args.as_slice()) else {
return args.to_vec();
};
if !usage_command_for_args(spec, task_prefix)
.args
.iter()
.any(|arg| {
matches!(
arg.double_dash,
usage::SpecDoubleDashChoices::Required | usage::SpecDoubleDashChoices::Preserve
)
})
{
return args.to_vec();
}
prefix
.iter()
.cloned()
.chain(once("--".to_string()))
.chain(self.trailing_args.iter().cloned())
.collect()
}
fn populate_spec_metadata(&self, spec: &mut usage::Spec) {
spec.name = self.display_name.clone();
spec.bin = self.display_name.clone();
if spec.cmd.help.is_none() {
spec.cmd.help = Some(self.description.clone());
}
spec.cmd.name = self.display_name.clone();
spec.cmd.aliases = self.aliases.clone();
if spec.cmd.before_help.is_none()
&& spec.cmd.before_help_long.is_none()
&& !self.depends.is_empty()
{
spec.cmd.before_help_long =
Some(format!("- Depends: {}", self.depends.iter().join(", ")));
}
spec.cmd.usage = spec.cmd.usage();
}
fn populate_usage_about(&self, spec: &mut usage::Spec) {
let has_usage_spec = has_any_args_defined(spec)
|| has_any_usage_spec(spec)
|| !self.usage.trim().is_empty()
|| !spec.cmd.usage.is_empty();
if has_usage_spec
&& !self.description.is_empty()
&& spec.cmd.help.as_deref() == Some(self.description.as_str())
{
if spec.about.is_none() {
spec.about = Some(
self.description
.lines()
.next()
.unwrap_or_default()
.to_string(),
);
}
if spec.about_long.is_none() && self.description.contains('\n') {
spec.about_long = Some(self.description.clone());
}
}
}
pub(crate) async fn parse_usage_spec_with_vars(
&self,
config: &Arc<Config>,
cwd: Option<PathBuf>,
env: &EnvMap,
extra_vars: Option<IndexMap<String, String>>,
) -> Result<(usage::Spec, Vec<String>)> {
let mut env = env.clone();
if !self.raw_args {
clear_usage_env(&mut env);
}
let (mut spec, scripts) = if let Some(file) = self.file_path(config).await? {
let spec = parse_task_script_usage(&file)
.inspect_err(|e| {
warn!(
"failed to parse task file {} with usage: {e:?}",
file::display_path(&file)
)
})
.unwrap_or_default();
(spec, vec![])
} else {
let scripts_only = self.run_script_strings();
let parser_dir = match cwd {
Some(cwd) => Some(cwd),
None => self.dir(config).await?,
};
let (scripts, spec) = self
.make_script_parser(config, parser_dir, extra_vars)
.await
.parse_run_scripts(config, self, &scripts_only, &env)
.await?;
(spec, scripts)
};
self.populate_spec_metadata(&mut spec);
self.populate_usage_about(&mut spec);
Ok((spec, scripts))
}
async fn make_script_parser(
&self,
config: &Arc<Config>,
cwd: Option<PathBuf>,
extra_vars: Option<IndexMap<String, String>>,
) -> TaskScriptParser {
let parser = TaskScriptParser::new(cwd);
let parser = if self.sources.is_empty() {
parser
} else {
match task_source_checker::source_baseline_path(self, config).await {
Ok(baseline) => parser.with_baseline(baseline),
Err(err) => {
trace!(
"could not resolve source baseline for task {}: {err:?}",
self.name
);
parser
}
}
};
match extra_vars {
Some(vars) => parser.with_extra_vars(vars),
None => parser,
}
}
pub(crate) async fn parse_usage_spec_for_display(
&self,
config: &Arc<Config>,
) -> Result<usage::Spec> {
let dir = self.dir(config).await?;
let mut spec = if let Some(file) = self.file_path(config).await? {
parse_task_script_usage(&file)
.inspect_err(|e| {
warn!(
"failed to parse task file {} with usage: {e:?}",
file::display_path(&file)
)
})
.unwrap_or_default()
} else {
let scripts_only = self.run_script_strings();
TaskScriptParser::new(dir)
.parse_run_scripts_for_spec_only(config, self, &scripts_only)
.await?
};
self.populate_spec_metadata(&mut spec);
self.populate_usage_about(&mut spec);
Ok(spec)
}
pub(crate) async fn parse_usage_spec_for_preflight(
&self,
config: &Arc<Config>,
) -> Result<usage::Spec> {
let mut spec = if let Some(file) = self.file_path_raw() {
parse_task_script_usage(&file)
.inspect_err(|e| {
warn!(
"failed to parse task file {} with usage: {e:?}",
file::display_path(&file)
)
})
.unwrap_or_default()
} else {
let scripts_only = self.run_script_strings();
TaskScriptParser::new(self.config_root.clone())
.parse_run_scripts_for_preflight(config, self, &scripts_only)
.await?
};
self.populate_spec_metadata(&mut spec);
self.populate_usage_about(&mut spec);
Ok(spec)
}
pub(crate) fn validate_template_syntax_for_preflight(&self, input: &str) -> Result<()> {
TaskScriptParser::new(self.config_root.clone()).validate_template_syntax(self, input)
}
pub(crate) async fn render_run_scripts_with_args(
&self,
config: &Arc<Config>,
cwd: Option<PathBuf>,
args: &[String],
env: &EnvMap,
extra_vars: Option<IndexMap<String, String>>,
) -> Result<Vec<(String, Vec<String>)>> {
let (spec, scripts) = self
.parse_usage_spec_with_vars(config, cwd.clone(), env, extra_vars.clone())
.await?;
if !self.should_bypass_usage_parser() && has_any_args_defined(&spec) {
let mut env = env.clone();
clear_usage_env(&mut env);
let args = self.args_for_usage_parser(&spec, args);
let parser_dir = match cwd {
Some(cwd) => Some(cwd),
None => self.dir(config).await?,
};
let scripts_only = self.run_script_strings();
let scripts = self
.make_script_parser(config, parser_dir, extra_vars)
.await
.parse_run_scripts_with_args(config, self, &scripts_only, &env, &args, &spec)
.await?;
Ok(scripts.into_iter().map(|s| (s, vec![])).collect())
} else {
Ok(scripts
.iter()
.enumerate()
.map(|(i, script)| {
match i == self.run_script_strings().len() - 1 {
true => (script.clone(), args.iter().cloned().collect_vec()),
false => (script.clone(), vec![]),
}
})
.collect())
}
}
pub(crate) async fn render_markdown(&self, config: &Arc<Config>) -> Result<String> {
let mut spec = self.parse_usage_spec_for_display(config).await?;
if spec.about.is_some() && spec.cmd.help.as_deref() == Some(self.description.as_str()) {
spec.cmd.help = None;
}
let ctx = usage::docs::markdown::MarkdownRenderer::new(spec)
.with_replace_pre_with_code_fences(true)
.with_header_level(2);
Ok(ctx.render_spec()?)
}
pub(crate) fn estyled_prefix(&self) -> String {
style::prefix(self.prefix(), &self.display_name, true)
}
pub(crate) async fn dir(&self, config: &Arc<Config>) -> Result<Option<PathBuf>> {
if let Some(dir) = self.dir.clone().or_else(|| {
self.cf(config)
.as_ref()
.and_then(|cf| cf.task_config().dir.clone())
}) {
let dir = if contains_template_syntax(&dir) {
let config_root = self.config_root.clone().unwrap_or_default();
let mut tera = get_tera(Some(&config_root));
let tera_ctx = self.tera_ctx(config).await?;
render_str(&mut tera, &dir, &tera_ctx)?
} else {
dir
};
let dir = file::replace_path(&dir);
if dir.is_absolute() {
Ok(Some(dir.to_path_buf()))
} else if let Some(root) = &self.config_root {
Ok(Some(root.join(dir)))
} else {
Ok(Some(dir.clone()))
}
} else {
Ok(self.config_root.clone())
}
}
pub(crate) async fn file_path(&self, config: &Arc<Config>) -> Result<Option<PathBuf>> {
if let Some(file) = &self.file {
let file_str = file.to_string_lossy().to_string();
let rendered = if contains_template_syntax(&file_str) {
let config_root = self.config_root.clone().unwrap_or_default();
let mut tera = get_tera(Some(&config_root));
let tera_ctx = self.tera_ctx(config).await?;
render_str(&mut tera, &file_str, &tera_ctx)?
} else {
file_str
};
let rendered_path = file::replace_path(&rendered);
if rendered_path.is_absolute() {
Ok(Some(rendered_path))
} else if let Some(root) = &self.config_root {
Ok(Some(root.join(rendered_path)))
} else {
Ok(Some(rendered_path))
}
} else {
Ok(None)
}
}
pub(crate) fn file_path_raw(&self) -> Option<PathBuf> {
self.file.as_ref().map(|file| {
if file.is_absolute() {
file.clone()
} else if let Some(root) = &self.config_root {
root.join(file)
} else {
file.clone()
}
})
}
pub(crate) async fn tera_ctx(&self, config: &Arc<Config>) -> Result<tera::Context> {
self.build_tera_ctx(config, false).await
}
pub(crate) async fn tera_ctx_for_usage(&self, config: &Arc<Config>) -> Result<tera::Context> {
self.build_tera_ctx(config, !self.raw_args).await
}
pub(crate) fn tera_ctx_for_usage_preflight(&self, config: &Config) -> tera::Context {
let mut tera_ctx = config.tera_ctx.clone();
tera_ctx.insert("env", &EnvMap::new());
tera_ctx.insert("vars", &IndexMap::<String, String>::new());
tera_ctx.insert("config_root", &self.config_root);
tera_ctx
}
async fn build_tera_ctx(
&self,
config: &Arc<Config>,
sanitize_usage_env: bool,
) -> Result<tera::Context> {
let ts = config.get_toolset().await?;
let mut tera_ctx = ts.tera_ctx(config).await?.clone();
if sanitize_usage_env {
clear_usage_env_from_tera_ctx(&mut tera_ctx);
}
let mut vars = self.resolve_base_vars(config).await?;
tera_ctx.insert("vars", &vars);
self.resolve_base_env(config, &mut tera_ctx).await?;
if sanitize_usage_env {
clear_usage_env_from_tera_ctx(&mut tera_ctx);
}
vars.extend(self.resolve_task_vars(config, &tera_ctx).await?);
tera_ctx.insert("vars", &vars);
tera_ctx.insert("config_root", &self.config_root);
Ok(tera_ctx)
}
async fn resolve_base_vars(&self, config: &Arc<Config>) -> Result<IndexMap<String, String>> {
let Some(task_cf) = self.cf(config) else {
return Ok(config.vars.clone());
};
if task_cf.project_root() == config.project_root {
return Ok(config.vars.clone());
}
let config_path = task_cf.get_path().to_path_buf();
if let Some(vars) = TASK_VARS_CACHE.lock().unwrap().get(&config_path) {
return Ok(vars.clone());
}
let task_dir = task_cf.get_path().parent().unwrap_or(task_cf.get_path());
let (config_paths, idiomatic_filenames) =
crate::config::load_config_hierarchy_from_dir(task_dir).await?;
let task_config_files =
crate::config::load_config_files_from_paths(&config_paths, &idiomatic_filenames)
.await?;
let vars_results =
crate::config::resolve_vars_from_config_files(config, &task_config_files).await?;
let vars: IndexMap<String, String> = vars_results
.vars
.iter()
.map(|(k, (v, _))| (k.clone(), v.clone()))
.collect();
config.add_redactions_excluding(
vars_results.redactions.iter().cloned(),
&vars.clone().into_iter().collect(),
&vars_results.redaction_exclusions,
);
TASK_VARS_CACHE
.lock()
.unwrap()
.insert(config_path, vars.clone());
Ok(vars)
}
async fn resolve_base_env(
&self,
config: &Arc<Config>,
tera_ctx: &mut tera::Context,
) -> Result<()> {
if config::Settings::no_env() || config::Settings::get().no_env.unwrap_or(false) {
return Ok(());
}
if self.is_remote() {
return Ok(());
}
let Some(task_cf) = self.cf(config) else {
return Ok(());
};
let Some(task_project_root) = task_cf.project_root() else {
return Ok(());
};
if Some(&task_project_root) == config.project_root.as_ref() {
return Ok(());
}
let config_path = task_cf.get_path().to_path_buf();
if let Some(env) = TASK_ENV_CACHE.lock().unwrap().get(&config_path) {
tera_ctx.insert("env", env);
return Ok(());
}
let task_dir = task_cf.get_path().parent().unwrap_or(task_cf.get_path());
let (config_paths, idiomatic_filenames) =
crate::config::load_config_hierarchy_from_dir(task_dir).await?;
let task_config_files =
crate::config::load_config_files_from_paths(&config_paths, &idiomatic_filenames)
.await?;
let entries: Vec<(EnvDirective, PathBuf)> = task_config_files
.iter()
.rev()
.map(|(source, cf)| {
cf.env_entries()
.map(|ee| ee.into_iter().map(|e| (e, source.clone())))
})
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect();
let mut env: EnvMap = env::PRISTINE_ENV.clone();
let mut resolve_ctx = tera_ctx.clone();
resolve_ctx.insert("config_root", &task_project_root);
let results = EnvResults::resolve(
config,
resolve_ctx,
&env,
entries,
EnvResolveOptions {
vars: false,
tools: ToolsFilter::NonToolsOnly,
warn_on_missing_required: false,
},
)
.await?;
for (k, (v, _)) in results.env {
env.insert(k, v);
}
for key in &results.env_remove {
env.remove(key);
}
if !results.env_paths.is_empty() {
let mut path_env = PathEnv::from_iter(env::split_paths(
&env.get(&*env::PATH_KEY).cloned().unwrap_or_default(),
));
for path in results.env_paths {
path_env.add(path);
}
env.insert(env::PATH_KEY.to_string(), path_env.to_string());
}
if !results.redactions.is_empty() {
config.add_redactions_excluding(
results.redactions.iter().cloned(),
&env,
&results.redaction_exclusions,
);
}
TASK_ENV_CACHE
.lock()
.unwrap()
.insert(config_path, env.clone());
tera_ctx.insert("env", &env);
Ok(())
}
async fn resolve_task_vars(
&self,
config: &Arc<Config>,
tera_ctx: &tera::Context,
) -> Result<IndexMap<String, String>> {
if self.vars.0.is_empty() && self.overlay_vars.is_empty() {
return Ok(IndexMap::new());
}
let mut directives: Vec<(EnvDirective, PathBuf)> = self
.vars
.0
.iter()
.cloned()
.map(|directive| (directive, self.config_source.clone()))
.collect();
directives.extend(self.overlay_vars.iter().cloned());
let template_env: EnvMap = tera_ctx
.get("env")
.and_then(|v| serde::Deserialize::deserialize(v.clone()).ok())
.unwrap_or_else(|| env::PRISTINE_ENV.clone());
let results = EnvResults::resolve(
config,
tera_ctx.clone(),
&template_env,
directives,
EnvResolveOptions {
vars: true,
tools: ToolsFilter::NonToolsOnly,
warn_on_missing_required: false,
},
)
.await?;
let vars: IndexMap<String, String> = results
.vars
.iter()
.map(|(k, (v, _))| (k.clone(), v.clone()))
.collect();
let mut redaction_vars: EnvMap = tera_ctx
.get("vars")
.and_then(|v| serde::Deserialize::deserialize(v.clone()).ok())
.unwrap_or_default();
redaction_vars.extend(vars.clone());
config.add_redactions_excluding(
results.redactions.iter().cloned(),
&redaction_vars,
&results.redaction_exclusions,
);
Ok(vars)
}
pub(crate) fn cf<'a>(&'a self, config: &'a Config) -> Option<&'a Arc<dyn ConfigFile>> {
if let Some(ref cf) = self.cf {
return Some(cf);
}
config.config_files.get(&self.config_source)
}
pub(crate) fn is_remote(&self) -> bool {
if let Some(source) = &self.remote_file_source {
return source.starts_with("git::")
|| source.starts_with("http://")
|| source.starts_with("https://");
}
false
}
pub(crate) fn shell(&self) -> eyre::Result<Option<Vec<String>>> {
let Some(shell) = self.shell.as_ref() else {
return Ok(None);
};
let mut shell_cmd = crate::path::split_shell_command(shell)?;
if shell_cmd.is_empty() || shell_cmd[0].trim().is_empty() {
warn!("invalid shell '{shell}', expected '<program> <argument>' (e.g. sh -c)");
Ok(None)
} else {
config::Settings::get().maybe_no_profile(&mut shell_cmd);
Ok(Some(shell_cmd))
}
}
pub(crate) fn merge_toml_overlay(&mut self, other: Task) {
for source in other.config_sources() {
self.add_config_source(source);
}
fn merge_bool(base: &mut bool, overlay: bool, explicit: bool) {
if explicit || overlay {
*base = overlay;
}
}
if !other.description.is_empty() {
self.description = other.description;
}
for alias in other.aliases {
if !self.aliases.contains(&alias) {
self.aliases.push(alias);
}
}
let overlay_src = other.config_source.clone();
self.overlay_env
.extend(other.env.0.into_iter().map(|d| (d, overlay_src.clone())));
self.overlay_vars
.extend(other.vars.0.into_iter().map(|d| (d, overlay_src.clone())));
let other_depends_raw = other
.depends_raw
.clone()
.unwrap_or_else(|| other.depends.clone());
let other_depends_post_raw = other
.depends_post_raw
.clone()
.unwrap_or_else(|| other.depends_post.clone());
let other_wait_for_raw = other
.wait_for_raw
.clone()
.unwrap_or_else(|| other.wait_for.clone());
self.depends_raw
.get_or_insert_with(|| self.depends.clone())
.extend(other_depends_raw);
self.depends_post_raw
.get_or_insert_with(|| self.depends_post.clone())
.extend(other_depends_post_raw);
self.wait_for_raw
.get_or_insert_with(|| self.wait_for.clone())
.extend(other_wait_for_raw);
self.depends.extend(other.depends);
self.depends_post.extend(other.depends_post);
self.wait_for.extend(other.wait_for);
if other.dir.is_some() {
self.dir = other.dir;
}
merge_bool(&mut self.hide, other.hide, other.toml_bool_presence.hide);
merge_bool(&mut self.raw, other.raw, other.toml_bool_presence.raw);
merge_bool(
&mut self.raw_args,
other.raw_args,
other.toml_bool_presence.raw_args,
);
merge_bool(
&mut self.interactive,
other.interactive,
other.toml_bool_presence.interactive,
);
merge_bool(&mut self.quiet, other.quiet, other.toml_bool_presence.quiet);
if other.toml_bool_presence.silent || !matches!(other.silent, Silent::Off) {
self.silent = other.silent;
}
if other.output.is_some() {
self.output = other.output;
}
self.sources.extend(other.sources);
if other.watch.is_some() {
self.watch = other.watch;
}
if !other.outputs.is_empty() {
self.outputs = other.outputs;
}
if other.cache.is_some() {
self.cache = other.cache;
}
if other.rust_cache.is_some() {
self.rust_cache = other.rust_cache;
}
if other.raw_outputs.templates.is_some() {
self.raw_outputs = other.raw_outputs;
}
if other.shell.is_some() {
self.shell = other.shell;
}
if other.timeout.is_some() {
self.timeout = other.timeout;
}
if other.confirm.is_some() {
self.confirm = other.confirm;
}
for (k, v) in other.tools {
self.tools.insert(k, v);
}
if !other.usage.is_empty() {
self.usage = other.usage;
}
self.deny_all |= other.deny_all;
self.deny_read |= other.deny_read;
self.deny_write |= other.deny_write;
self.deny_net |= other.deny_net;
self.deny_env |= other.deny_env;
self.allow_read.extend(other.allow_read);
self.allow_write.extend(other.allow_write);
self.allow_net.extend(other.allow_net);
self.allow_env.extend(other.allow_env);
self.pass_through_env.extend(other.pass_through_env);
}
fn has_render_templates(&self) -> bool {
fn path_contains_template(path: &Path) -> bool {
path.to_str().is_some_and(contains_template_syntax)
}
let deps_have_template = |deps: &[TaskDep]| {
deps.iter().any(|dep| {
contains_template_syntax(&dep.task)
|| dep.args.iter().any(|arg| contains_template_syntax(arg))
|| dep
.env
.values()
.any(|value| contains_template_syntax(value))
})
};
let tools_have_template = self.tools.values().any(TaskToolValue::has_template);
self.aliases.iter().any(|s| contains_template_syntax(s))
|| contains_template_syntax(&self.description)
|| self.sources.iter().any(|s| contains_template_syntax(s))
|| self.outputs.has_tera_template()
|| deps_have_template(&self.depends)
|| deps_have_template(&self.depends_post)
|| deps_have_template(&self.wait_for)
|| self
.dir
.as_ref()
.is_some_and(|s| contains_template_syntax(s))
|| self
.shell
.as_ref()
.is_some_and(|s| contains_template_syntax(s))
|| self
.timeout
.as_ref()
.is_some_and(|s| contains_template_syntax(s))
|| self.allow_read.iter().any(|p| path_contains_template(p))
|| self.allow_write.iter().any(|p| path_contains_template(p))
|| tools_have_template
}
fn store_raw_render_inputs(&mut self) {
if !self.sources.is_empty() && self.outputs.is_empty() {
self.outputs = TaskOutputs::Auto;
}
self.raw_outputs = self.outputs.raw_templates_without_env();
self.depends_raw = Some(self.depends.clone());
self.depends_post_raw = Some(self.depends_post.clone());
self.wait_for_raw = Some(self.wait_for.clone());
}
fn parse_plain_depends(&mut self) -> Result<()> {
for d in &mut self.depends {
d.parse_shell_style_env()?;
}
for d in &mut self.depends_post {
d.parse_shell_style_env()?;
}
for d in &mut self.wait_for {
d.parse_shell_style_env()?;
}
Ok(())
}
pub(crate) async fn render(&mut self, config: &Arc<Config>, config_root: &Path) -> Result<()> {
if !self.has_render_templates() {
self.store_raw_render_inputs();
self.parse_plain_depends()?;
return Ok(());
}
let mut tera = get_tera(Some(config_root));
let tera_ctx = self.tera_ctx(config).await?;
for a in &mut self.aliases {
if contains_template_syntax(a) {
*a = render_str(&mut tera, a, &tera_ctx)?;
}
}
if contains_template_syntax(&self.description) {
self.description = render_str(&mut tera, &self.description, &tera_ctx)?;
}
for s in &mut self.sources {
if contains_template_syntax(s) {
*s = render_str(&mut tera, s, &tera_ctx)?;
}
}
self.store_raw_render_inputs();
self.raw_outputs = self.outputs.render(&mut tera, &tera_ctx)?;
render_task_deps(&mut self.depends, &mut tera, &tera_ctx, true)?;
render_task_deps(&mut self.depends_post, &mut tera, &tera_ctx, true)?;
render_task_deps(&mut self.wait_for, &mut tera, &tera_ctx, true)?;
if let Some(dir) = &mut self.dir
&& contains_template_syntax(dir)
{
*dir = render_str(&mut tera, dir, &tera_ctx)?;
}
if let Some(shell) = &mut self.shell
&& contains_template_syntax(shell)
{
*shell = render_str(&mut tera, shell, &tera_ctx)?;
}
if let Some(timeout) = &mut self.timeout
&& contains_template_syntax(timeout)
{
*timeout = render_str(&mut tera, timeout, &tera_ctx)?;
}
let mut render_sandbox_paths = |paths: &mut Vec<PathBuf>| -> Result<()> {
let mut rendered = Vec::with_capacity(paths.len());
for p in paths.drain(..) {
if let Some(path) = p.to_str()
&& contains_template_syntax(path)
{
let path = render_str(&mut tera, path, &tera_ctx)?;
if !path.trim().is_empty() {
rendered.push(PathBuf::from(path));
}
} else {
rendered.push(p);
}
}
*paths = rendered;
Ok(())
};
render_sandbox_paths(&mut self.allow_read)?;
render_sandbox_paths(&mut self.allow_write)?;
for tool in self.tools.values_mut() {
tool.render_templates(&mut tera, &tera_ctx)?;
}
Ok(())
}
pub(crate) async fn render_depends_with_usage(
&mut self,
config: &Arc<Config>,
usage_values: &IndexMap<String, tera::Value>,
) -> Result<()> {
if usage_values.is_empty() {
return Ok(());
}
let has_usage_deps = |raw: &Option<Vec<_>>| {
raw.as_ref()
.is_some_and(|deps| deps.iter().any(dep_has_usage_ref))
};
if !has_usage_deps(&self.depends_raw)
&& !has_usage_deps(&self.depends_post_raw)
&& !has_usage_deps(&self.wait_for_raw)
{
return Ok(());
}
let config_root = self.config_root.clone().unwrap_or_default();
let mut tera = get_tera(Some(&config_root));
let mut tera_ctx = self.tera_ctx(config).await?;
tera_ctx.insert("usage", usage_values);
if !self.depends.is_empty()
&& let Some(raw) = &self.depends_raw
{
self.depends = raw.clone();
render_task_deps(&mut self.depends, &mut tera, &tera_ctx, false)?;
}
if !self.depends_post.is_empty()
&& let Some(raw) = &self.depends_post_raw
{
self.depends_post = raw.clone();
render_task_deps(&mut self.depends_post, &mut tera, &tera_ctx, false)?;
}
if !self.wait_for.is_empty()
&& let Some(raw) = &self.wait_for_raw
{
self.wait_for = raw.clone();
render_task_deps(&mut self.wait_for, &mut tera, &tera_ctx, false)?;
}
Ok(())
}
pub(crate) fn name_to_path(&self) -> PathBuf {
self.name.replace(':', path::MAIN_SEPARATOR_STR).into()
}
pub(crate) fn display_name_to_path(&self) -> PathBuf {
self.display_name
.replace(':', path::MAIN_SEPARATOR_STR)
.into()
}
pub(crate) async fn render_env(
&self,
config: &Arc<Config>,
ts: &Toolset,
) -> Result<(EnvMap, Vec<(String, String)>, BTreeSet<String>)> {
let mut tera_ctx = ts.tera_ctx(config).await?.clone();
let (mut env, mut env_remove) = ts.full_env_with_removals(config).await?;
if let Some(root) = &config.project_root {
tera_ctx.insert("config_root", &root);
}
let mut env_directives: Vec<_> = self
.inherited_env
.0
.iter()
.chain(self.env.0.iter())
.map(|directive| (directive.clone(), self.config_source.clone()))
.collect();
env_directives.extend(self.overlay_env.iter().cloned());
let env_results = EnvResults::resolve(
config,
tera_ctx.clone(),
&env,
env_directives,
EnvResolveOptions {
vars: false,
tools: ToolsFilter::Both,
warn_on_missing_required: false,
},
)
.await?;
let redact_keys = config
.redaction_keys()
.into_iter()
.chain(env_results.redactions.iter().cloned());
let mut redaction_exclusions = config.env_results().await?.redaction_exclusions.clone();
for key in env_results.env.keys() {
redaction_exclusions.remove(key);
}
redaction_exclusions.extend(env_results.redaction_exclusions.iter().cloned());
config.add_redactions_excluding(
redact_keys,
&env_results.redactable_env(&env),
&redaction_exclusions,
);
let task_env = env_results.env.into_iter().map(|(k, (v, _))| (k, v));
for (key, _) in task_env.clone() {
env_remove.remove(&key);
}
env.extend(task_env.clone());
for key in &env_results.env_remove {
env.remove(key);
}
env_remove.extend(env_results.env_remove);
if !env_results.env_paths.is_empty() {
let mut path_env = PathEnv::from_iter(env::split_paths(
&env.get(&*env::PATH_KEY).cloned().unwrap_or_default(),
));
for path in env_results.env_paths {
path_env.add(path);
}
env.insert(env::PATH_KEY.to_string(), path_env.to_string());
}
Ok((env, task_env.collect(), env_remove))
}
}
pub(crate) fn clear_usage_env(env: &mut EnvMap) {
env.retain(|key, _| !is_usage_env_key(key));
}
fn clear_usage_env_from_tera_ctx(tera_ctx: &mut tera::Context) {
let mut env: EnvMap = tera_ctx
.get("env")
.and_then(|value| serde::Deserialize::deserialize(value.clone()).ok())
.unwrap_or_default();
clear_usage_env(&mut env);
tera_ctx.insert("env", &env);
}
pub(crate) fn is_usage_env_key(key: &str) -> bool {
#[cfg(windows)]
{
key.get(.."usage_".len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("usage_"))
}
#[cfg(not(windows))]
{
key.starts_with("usage_")
}
}
pub(crate) fn env_contains_key(env: &EnvMap, key: &str) -> bool {
#[cfg(windows)]
{
env.keys()
.any(|candidate| candidate.eq_ignore_ascii_case(key))
}
#[cfg(not(windows))]
{
env.contains_key(key)
}
}
fn name_from_path(prefix: impl AsRef<Path>, path: impl AsRef<Path>) -> Result<String> {
let name = path
.as_ref()
.strip_prefix(prefix)
.map(|p| match p {
p if p.starts_with("mise-tasks") => p.strip_prefix("mise-tasks"),
p if p.starts_with(".mise-tasks") => p.strip_prefix(".mise-tasks"),
p if p.starts_with(".mise/tasks") => p.strip_prefix(".mise/tasks"),
p if p.starts_with("mise/tasks") => p.strip_prefix("mise/tasks"),
p if p.starts_with(".config/mise/tasks") => p.strip_prefix(".config/mise/tasks"),
_ => Ok(p),
})??
.components()
.map(path::Component::as_os_str)
.map(ffi::OsStr::to_string_lossy)
.map(|s| s.replace(':', "_"))
.join(":");
if let Some((parent, last)) = name.rsplit_once(':')
&& strip_extension(last) == "_default"
{
return Ok(parent.to_string());
}
Ok(name)
}
pub(crate) fn extract_monorepo_path(name: &str) -> Option<String> {
name.strip_prefix("//").and_then(|stripped| {
stripped.find(':').map(|idx| stripped[..idx].to_string())
})
}
pub(crate) fn build_task_ref_map<'a, I>(tasks: I) -> BTreeMap<String, &'a Task>
where
I: Iterator<Item = (&'a String, &'a Task)> + 'a,
{
tasks
.flat_map(|(_, t)| {
t.aliases
.iter()
.flat_map(|a| {
if let Some(path) = extract_monorepo_path(&t.name) {
vec![(format!("//{}:{}", path, a), t), (a.to_string(), t)]
} else {
vec![(a.to_string(), t)]
}
})
.chain(once((t.name.clone(), t)))
.collect::<Vec<_>>()
})
.collect()
}
pub(crate) fn resolve_task_pattern(pattern: &str, parent_task: Option<&Task>) -> String {
let is_relative_path = pattern.starts_with("./");
let is_bare_name = !is_relative_path
&& !pattern.starts_with("//")
&& !pattern.starts_with("::")
&& !pattern.starts_with(':')
&& !is_workspace_project_task(pattern);
let parent_is_scoped = parent_task.is_some_and(|parent| {
parent.name.starts_with("//") || is_workspace_project_task(&parent.name)
});
let should_resolve_relatively = pattern.starts_with(':') && !pattern.starts_with("::")
|| (is_relative_path && parent_task.is_some_and(|parent| parent.name.starts_with("//")))
|| (is_bare_name && parent_is_scoped);
if should_resolve_relatively && let Some(parent) = parent_task {
if let Some((project, _)) = parent
.name
.split_once('#')
.filter(|_| is_workspace_project_task(&parent.name))
{
return format!("{project}#{}", pattern.strip_prefix(':').unwrap_or(pattern));
}
if let Some(stripped) = parent.name.strip_prefix("//") {
if let Some(colon_idx) = stripped.find(':') {
let parent_path = &stripped[..colon_idx];
if let Some(relative_path) = pattern.strip_prefix("./") {
let separator = if parent_path.is_empty() || relative_path.starts_with(':') {
""
} else {
"/"
};
return format!("//{parent_path}{separator}{relative_path}");
}
let path = format!("//{parent_path}");
return if is_bare_name {
format!("{}:{}", path, pattern)
} else {
format!("{}{}", path, pattern)
};
}
} else if let Some((path, _)) = parent.name.rsplit_once(':') {
return format!("{}{}", path, pattern);
}
}
pattern.to_string()
}
fn match_tasks_with_context(
tasks: &BTreeMap<String, &Task>,
td: &TaskDep,
parent_task: Option<&Task>,
) -> Result<Vec<Task>> {
let resolved_pattern = resolve_task_pattern(&td.task, parent_task);
let matches = tasks
.get_matching(&resolved_pattern)?
.into_iter()
.map(|t| {
let mut t = (*t).clone();
t.args = td.args.clone();
if !td.env.is_empty() {
let env_directives: Vec<EnvDirective> = td
.env
.iter()
.map(|(k, v)| EnvDirective::Val(k.clone(), v.clone(), Default::default()))
.collect();
t = t.with_dependency_env(&env_directives);
if let Some(config_root) = &t.config_root {
let config_root = config_root.clone();
t.outputs
.re_render_with_env(&t.raw_outputs.clone(), &td.env, &config_root)?;
}
}
Ok(t)
})
.collect::<Result<Vec<_>>>()?;
if matches.is_empty() && !td.optional {
let mut err_msg = format!("task not found: {}", td.task);
if resolved_pattern.starts_with("//") {
let mut matcher = FuzzyMatcher::default();
let resolved_pattern = resolved_pattern.to_lowercase();
let pattern = FuzzyPattern::new(&resolved_pattern);
let similar: Vec<String> = tasks
.keys()
.filter(|k| k.starts_with("//"))
.filter_map(|k| {
matcher
.score_pattern(&k.to_lowercase(), &pattern)
.map(|score| (score, k.clone()))
})
.sorted_by_key(|(score, _)| std::cmp::Reverse(*score))
.take(5)
.map(|(_, k)| k)
.collect();
if !similar.is_empty() {
err_msg.push_str("\n\nDid you mean one of these?");
for task_name in similar {
err_msg.push_str(&format!("\n - {}", task_name));
}
}
}
return Err(eyre!(err_msg));
};
Ok(matches)
}
impl Default for Task {
fn default() -> Self {
Task {
run_phase: TaskRunPhase::Normal,
name: "".to_string(),
display_name: "".to_string(),
description: "".to_string(),
aliases: vec![],
config_source: PathBuf::new(),
additional_config_sources: vec![],
cf: None,
config_root: None,
confirm: None,
depends: vec![],
depends_post: vec![],
wait_for: vec![],
env: Default::default(),
vars: Default::default(),
inherited_env: Default::default(),
overlay_env: vec![],
overlay_vars: vec![],
toml_bool_presence: Default::default(),
dir: None,
hide: false,
global: false,
raw: false,
raw_args: false,
trailing_args: vec![],
interactive: false,
sources: vec![],
watch: None,
outputs: Default::default(),
cache: Default::default(),
rust_cache: Default::default(),
raw_outputs: Default::default(),
shell: None,
silent: Silent::Off,
output: None,
run: vec![],
run_windows: vec![],
args: vec![],
file: None,
is_toml_include: false,
config_precedence: usize::MAX,
quiet: false,
tools: Default::default(),
usage: "".to_string(),
timeout: None,
remote_file_source: None,
deny_all: false,
deny_read: false,
deny_write: false,
deny_net: false,
deny_env: false,
allow_read: vec![],
allow_write: vec![],
allow_net: vec![],
allow_env: vec![],
pass_through_env: vec![],
extends: None,
show_args_in_prefix: false,
depends_raw: None,
depends_post_raw: None,
wait_for_raw: None,
workspace_dependency_error: None,
}
}
}
impl Display for Task {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let cmd = self
.run()
.iter()
.map(|e| e.to_string())
.next()
.or_else(|| self.file_path_raw().as_ref().map(display_path));
if let Some(cmd) = cmd {
let cmd = cmd.lines().next().unwrap_or_default();
let prefix = self.prefix();
let prefix_len = measure_text_width(&prefix);
let available_width = (*env::TERM_WIDTH).saturating_sub(prefix_len + 4); let max_width = available_width.max(20); let truncated_cmd = truncate_str(cmd, max_width, "…");
write!(f, "{} {}", prefix, truncated_cmd)
} else {
write!(f, "{}", self.prefix())
}
}
}
impl PartialOrd for Task {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
fn env_key(task: &Task) -> Vec<(&String, &String)> {
task.env
.0
.iter()
.filter_map(|d| match d {
EnvDirective::Val(k, v, _) => Some((k, v)),
_ => None,
})
.sorted()
.collect()
}
impl Ord for Task {
fn cmp(&self, other: &Self) -> Ordering {
match self.name.cmp(&other.name) {
Ordering::Equal => match self.args.cmp(&other.args) {
Ordering::Equal => match env_key(self).cmp(&env_key(other)) {
Ordering::Equal => self.run_phase.cmp(&other.run_phase),
o => o,
},
o => o,
},
o => o,
}
}
}
impl Hash for Task {
fn hash<H: Hasher>(&self, state: &mut H) {
self.name.hash(state);
self.args.iter().for_each(|arg| arg.hash(state));
for (k, v) in env_key(self) {
k.hash(state);
v.hash(state);
}
self.run_phase.hash(state);
}
}
impl Eq for Task {}
impl PartialEq for Task {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
&& self.args == other.args
&& env_key(self) == env_key(other)
&& self.run_phase == other.run_phase
}
}
impl TreeItem for (&Graph<Task, ()>, NodeIndex) {
type Child = Self;
fn write_self(&self) -> std::io::Result<()> {
if let Some(w) = self.0.node_weight(self.1) {
miseprint!("{}", w.graph_display_name())?;
}
Ok(())
}
fn children(&self) -> Cow<'_, [Self::Child]> {
let v: Vec<_> = self.0.neighbors(self.1).map(|i| (self.0, i)).collect();
Cow::from(v)
}
}
pub(crate) trait GetMatchingExt<T> {
fn get_matching(&self, pat: &str) -> Result<Vec<&T>>;
}
fn task_name_glob(pattern: &str) -> std::result::Result<GlobMatcher, globset::Error> {
GlobBuilder::new(&pattern.replace(':', "/"))
.literal_separator(true)
.build()
.map(|glob| glob.compile_matcher())
}
fn task_name_matches(matcher: &GlobMatcher, name: &str, allow_ext_strip: bool) -> bool {
matcher.is_match(name.replace(':', "/"))
|| (allow_ext_strip && matcher.is_match(strip_extension(name).replace(':', "/")))
}
pub(crate) fn strip_extension(name: &str) -> &str {
let result = name.rsplitn(2, '.').last().unwrap_or(name);
if result.is_empty() { name } else { result }
}
impl<T> GetMatchingExt<T> for BTreeMap<String, T>
where
T: Eq + Hash,
{
fn get_matching(&self, pat: &str) -> Result<Vec<&T>> {
if let Some(exact) = self.get(pat) {
return Ok(vec![exact]);
}
if is_workspace_project_task(pat) {
let (project_pattern, task_pattern) = pat.split_once('#').unwrap();
let project_matcher = GlobBuilder::new(project_pattern)
.literal_separator(false)
.build()
.map_err(|err| eyre!("invalid workspace task pattern {pat:?}: {err}"))?
.compile_matcher();
let task_matcher = task_name_glob(task_pattern)
.map_err(|err| eyre!("invalid workspace task pattern {pat:?}: {err}"))?;
return Ok(self
.iter()
.filter(|(name, _)| {
name.split_once('#').is_some_and(|(project, task)| {
project_matcher.is_match(project)
&& task_name_matches(&task_matcher, task, false)
})
})
.map(|(_, task)| task)
.unique()
.collect());
}
if !pat.starts_with("//") && !pat.starts_with(':') {
if pat.contains('/') && pat.contains(':') {
bail!(
"relative path syntax '{}' is not supported, use '//{}' or ':task' for current directory",
pat,
pat
)
}
if !pat.contains(['*', '?', '[', '{']) && !pat.contains("...") && !pat.contains(':') {
let exact: Vec<&T> = self
.iter()
.filter(|(k, _)| k.as_str() == pat)
.map(|(_, v)| v)
.collect();
if !exact.is_empty() {
return Ok(exact);
}
return Ok(self
.iter()
.filter(|(k, _)| strip_extension(k) == pat)
.map(|(_, v)| v)
.collect());
}
let Some(matcher) = task_name_glob(pat).ok() else {
return Ok(vec![]);
};
let exact: Vec<&T> = self
.iter()
.filter(|(name, _)| task_name_matches(&matcher, name, false))
.map(|(_, task)| task)
.unique()
.collect();
if !exact.is_empty() {
return Ok(exact);
}
let ext_stripped: Vec<&T> = self
.iter()
.filter(|(name, _)| task_name_matches(&matcher, name, true))
.map(|(_, task)| task)
.unique()
.collect();
if !ext_stripped.is_empty() {
return Ok(ext_stripped);
}
if self.keys().any(|k| k.starts_with("//")) {
return self.get_matching(&format!("//{pat}"));
}
return Ok(vec![]);
}
let normalized_pat = if pat.starts_with("//") {
pat.to_string()
} else if pat.starts_with(':') {
bail!("':task' pattern should be expanded before matching")
} else {
pat.to_string()
};
let parts: Vec<&str> = normalized_pat.splitn(2, ':').collect();
if pat.starts_with("//") && parts.len() == 1 {
bail!(
"missing task name in monorepo path '{}', use '{}:<task>' or '{}:*' to run all tasks in that path",
pat,
pat,
pat
);
}
let (path_pattern, task_pattern) = match parts.as_slice() {
[path, task] => (*path, *task),
[path] => (*path, "*"),
_ => (normalized_pat.as_str(), "*"),
};
let path_glob = path_pattern.replace("...", "**");
let trailing_ellipsis_base = path_pattern
.strip_suffix("/...")
.map(|base| if base == "/" { "//" } else { base });
let task_glob = task_pattern;
let path_matcher = GlobBuilder::new(&path_glob)
.literal_separator(true)
.build()
.ok()
.map(|b| b.compile_matcher());
let trailing_ellipsis_base_matcher = trailing_ellipsis_base
.and_then(|base| GlobBuilder::new(base).literal_separator(true).build().ok())
.map(|glob| glob.compile_matcher());
let task_matcher = task_name_glob(task_glob).ok();
let entry_matches = |k: &str, allow_ext_strip: bool| -> bool {
let key_parts: Vec<&str> = k.splitn(2, ':').collect();
let (key_path, key_task) = match key_parts.as_slice() {
[path, task] => (*path, *task),
[path] => (*path, ""),
_ => (k, ""),
};
let path_matches = if let Some(ref matcher) = path_matcher {
matcher.is_match(key_path)
|| trailing_ellipsis_base_matcher
.as_ref()
.is_some_and(|base_matcher| base_matcher.is_match(key_path))
} else {
false
};
let task_matches = task_matcher
.as_ref()
.is_some_and(|matcher| task_name_matches(matcher, key_task, allow_ext_strip));
path_matches && task_matches
};
let exact: Vec<&T> = self
.iter()
.filter(|(k, _)| entry_matches(k.as_str(), false))
.map(|(_, t)| t)
.unique()
.collect();
if !exact.is_empty() {
return Ok(exact);
}
Ok(self
.iter()
.filter(|(k, _)| entry_matches(k.as_str(), true))
.map(|(_, t)| t)
.unique()
.collect())
}
}
pub(crate) fn dep_has_usage_ref(dep: &TaskDep) -> bool {
tera_template_has_usage_ref(&dep.task)
|| dep.args.iter().any(|a| tera_template_has_usage_ref(a))
|| dep.env.values().any(|v| tera_template_has_usage_ref(v))
}
fn render_task_deps(
deps: &mut Vec<TaskDep>,
tera: &mut TeraEngine,
tera_ctx: &tera::Context,
defer_usage: bool,
) -> Result<()> {
let mut rendered = Vec::with_capacity(deps.len());
for mut dep in std::mem::take(deps) {
if (defer_usage && dep_has_usage_ref(&dep)) || dep.render(tera, tera_ctx)? {
rendered.push(dep);
}
}
*deps = rendered;
Ok(())
}
fn tera_template_has_usage_ref(s: &str) -> bool {
const TAGS: [(&str, &str); 2] = [("{{", "}}"), ("{%", "%}")];
for (open, close) in TAGS {
let mut rest = s;
while let Some(start) = rest.find(open) {
rest = &rest[start + open.len()..];
let Some(end) = rest.find(close) else {
break;
};
if tera_tag_has_usage_ref(&rest[..end]) {
return true;
}
rest = &rest[end + close.len()..];
}
}
false
}
fn tera_tag_has_usage_ref(tag: &str) -> bool {
["usage.", "usage["].iter().any(|needle| {
tag.match_indices(needle).any(|(idx, _)| {
tag[..idx]
.chars()
.next_back()
.is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '.')
})
})
}
pub(crate) async fn parse_usage_values_from_task(
config: &Arc<Config>,
task: &Task,
) -> Result<IndexMap<String, tera::Value>> {
let ts = config.get_toolset().await?;
let env = ts.full_env(config).await?;
let (spec, _) = task
.parse_usage_spec_with_vars(config, None, &env, None)
.await?;
if spec.cmd.args.is_empty() && spec.cmd.flags.is_empty() && spec.cmd.subcommands.is_empty() {
return Ok(IndexMap::new());
}
let args: Vec<String> = once(String::new())
.chain(task.args_for_usage_parser(&spec, &task.args))
.collect();
let po = match usage::Parser::new(&spec).parse(&args) {
Ok(po) => po,
Err(e) => {
debug!("usage parse failed for task '{}': {e}", task.name);
return Ok(IndexMap::new());
}
};
let mut values: IndexMap<String, tera::Value> =
TaskScriptParser::make_usage_ctx(&po).into_iter().collect();
if !spec.cmd.subcommands.is_empty() && !values.contains_key("cmd") {
values.insert("cmd".to_string(), tera::Value::from(String::new()));
}
Ok(values)
}
#[cfg(test)]
mod tests {
mod header_key_paths {
use super::super::{
extract_usage_from_comments, merge_header_value, parse_mise_header_toml,
};
fn keys(body: &str) -> Vec<String> {
parse_mise_header_toml(body)
.unwrap()
.into_iter()
.filter_map(|v| v.as_table().cloned())
.flat_map(|t| t.keys().cloned().collect::<Vec<_>>())
.collect()
}
#[test]
fn an_uppercase_dotted_key_is_config() {
let body = "#!/usr/bin/env bash\n#MISE env.FOO = \"bar\"\n";
assert_eq!(keys(body), ["env"]);
}
#[test]
fn a_quoted_segment_is_config() {
let body = "#!/usr/bin/env bash\n#MISE tools.\"http:ruff\".version = \"0.11.0\"\n";
assert_eq!(keys(body), ["tools"]);
}
#[test]
fn a_config_line_is_not_usage_text() {
let body = "#!/usr/bin/env bash\n#MISE tools.\"http:ruff\".version = \"0.11.0\"\n";
assert_eq!(extract_usage_from_comments(body), "");
}
#[test]
fn whitespace_around_the_dots_is_config() {
let body = "#!/usr/bin/env bash\n#MISE tools . \"http:ruff\" . version = \"0.11.0\"\n";
assert_eq!(keys(body), ["tools"]);
}
#[test]
fn an_escaped_quote_inside_a_key_is_config() {
let body = "#!/usr/bin/env bash\n#MISE tools.\"a\\\"b\".version = \"1\"\n";
assert_eq!(keys(body), ["tools"]);
}
#[test]
fn a_usage_directive_is_still_usage_text() {
let body = "#!/usr/bin/env bash\n#MISE flag \"--jobs\" default=\"4\"\n";
assert!(keys(body).is_empty());
assert_eq!(
extract_usage_from_comments(body),
"flag \"--jobs\" default=\"4\""
);
}
#[test]
fn splitting_one_tool_across_lines_keeps_every_field() {
let mut map = toml::Table::new();
for value in parse_mise_header_toml(
"#!/usr/bin/env bash\n#MISE tools.jq.version = \"1.8.1\"\n#MISE tools.jq.os = [\"macos\"]\n",
)
.unwrap()
{
for (k, v) in value.as_table().unwrap().clone() {
merge_header_value(&mut map, k, v);
}
}
let jq = map["tools"].as_table().unwrap()["jq"].as_table().unwrap();
assert_eq!(jq["version"].as_str(), Some("1.8.1"));
assert!(
jq.contains_key("os"),
"the later line must not replace the table"
);
}
#[test]
fn extra_space_after_the_marker_is_still_config() {
let body = "#!/usr/bin/env bash\n#MISE env.FOO = \"bar\"\n";
assert_eq!(keys(body), ["env"]);
assert_eq!(extract_usage_from_comments(body), "");
}
#[test]
fn a_spaced_marker_with_a_quoted_key_is_still_config() {
let body = "#!/usr/bin/env bash\n# MISE tools.\"http:ruff\".version = \"0.11.0\"\n";
assert_eq!(keys(body), ["tools"]);
assert_eq!(extract_usage_from_comments(body), "");
}
}
use std::collections::BTreeMap;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::task::workspace;
use crate::task::{RunEntry, Task, TaskRustCacheConfig, TaskWatchOptions};
use crate::{config::Config, dirs};
use indexmap::IndexMap;
use pretty_assertions::assert_eq;
#[cfg(unix)]
use super::TaskConfirm;
#[cfg(unix)]
use super::{TaskCacheConfig, TaskOutput};
use super::{
clear_usage_env, env_contains_key, name_from_path, tera_tag_has_usage_ref,
tera_template_has_usage_ref,
};
#[derive(Debug)]
struct BrokenWorkspaceProvider;
impl workspace::WorkspaceProvider for BrokenWorkspaceProvider {
fn id(&self) -> &str {
"broken"
}
fn discover(
&self,
_workspace_root: &Path,
) -> eyre::Result<Vec<workspace::WorkspaceProject>> {
eyre::bail!("broken workspace metadata")
}
}
#[derive(Debug)]
struct WorkingWorkspaceProvider;
impl workspace::WorkspaceProvider for WorkingWorkspaceProvider {
fn id(&self) -> &str {
"node"
}
fn discover(
&self,
_workspace_root: &Path,
) -> eyre::Result<Vec<workspace::WorkspaceProject>> {
Ok(vec![workspace::WorkspaceProject::new(
workspace::ProjectId::new("node", "app")?,
"packages/app",
)])
}
}
#[test]
fn test_merge_toml_overlay_tracks_definition_sources() {
let mut file_task = Task {
config_source: PathBuf::from(".mise/tasks/build"),
file: Some(PathBuf::from(".mise/tasks/build")),
..Default::default()
};
let overlay = Task {
config_source: PathBuf::from("mise.toml"),
depends: vec!["lint".to_string().into()],
..Default::default()
};
file_task.merge_toml_overlay(overlay);
assert_eq!(
file_task.config_sources(),
vec![Path::new(".mise/tasks/build"), Path::new("mise.toml")]
);
}
#[test]
fn test_task_watch_options_deserialize() {
let task: Task = toml::from_str(
r#"
run = "echo build"
watch = { no_vcs_ignore = true }
"#,
)
.unwrap();
assert_eq!(
task.watch,
Some(TaskWatchOptions {
no_vcs_ignore: true
})
);
}
#[test]
fn test_task_rust_cache_deserializes_boolean_and_table() {
let enabled: Task = toml::from_str(
r#"
run = "cargo build"
rust_cache = true
"#,
)
.unwrap();
let table: Task = toml::from_str(
r#"
run = "cargo build"
rust_cache = {}
"#,
)
.unwrap();
assert_eq!(enabled.rust_cache, Some(TaskRustCacheConfig::default()));
assert_eq!(table.rust_cache, Some(TaskRustCacheConfig::default()));
let verify: Task = toml::from_str(
r#"
run = "cargo build"
rust_cache = { verify = true }
"#,
)
.unwrap();
assert_eq!(verify.rust_cache, Some(TaskRustCacheConfig::default()));
}
#[test]
fn test_task_rust_cache_deserializes_disabled() {
let disabled: Task = toml::from_str(
r#"
run = "cargo build"
rust_cache = false
"#,
)
.unwrap();
let table: Task = toml::from_str(
r#"
run = "cargo build"
rust_cache = { enabled = false }
"#,
)
.unwrap();
assert_eq!(
disabled.rust_cache,
Some(TaskRustCacheConfig { enabled: false })
);
assert_eq!(
table.rust_cache,
Some(TaskRustCacheConfig { enabled: false })
);
}
#[test]
fn test_task_language_cache_rejects_unknown_option() {
let error = toml::from_str::<Task>(
r#"
run = "cargo build"
rust_cache = { unknown = true }
"#,
)
.unwrap_err();
assert!(error.to_string().contains("unknown field `unknown`"));
}
#[test]
fn test_merge_toml_overlay_replaces_watch_options() {
let mut file_task = Task {
watch: Some(TaskWatchOptions {
no_vcs_ignore: true,
}),
..Default::default()
};
let overlay = Task {
watch: Some(TaskWatchOptions {
no_vcs_ignore: false,
}),
..Default::default()
};
file_task.merge_toml_overlay(overlay);
assert_eq!(
file_task.watch,
Some(TaskWatchOptions {
no_vcs_ignore: false
})
);
}
thread_local! {
static CAPTURED_PARSER_FIELDS: Mutex<Option<Vec<String>>> = const { Mutex::new(None) };
}
pub(super) fn capture_parsed_fields(fields: Vec<String>) {
CAPTURED_PARSER_FIELDS.with(|captured| {
*captured.lock().unwrap() = Some(fields);
});
}
#[test]
fn test_clear_usage_env_uses_platform_key_semantics() {
let mut env = [
("usage_model".to_string(), "lower".to_string()),
("USAGE_TARGET".to_string(), "upper".to_string()),
("OTHER".to_string(), "keep".to_string()),
]
.into_iter()
.collect();
assert!(env_contains_key(&env, "usage_model"));
#[cfg(windows)]
assert!(env_contains_key(&env, "Usage_Model"));
clear_usage_env(&mut env);
assert!(!env.contains_key("usage_model"));
#[cfg(windows)]
assert!(!env.contains_key("USAGE_TARGET"));
#[cfg(not(windows))]
assert_eq!(env.get("USAGE_TARGET").map(String::as_str), Some("upper"));
assert_eq!(env.get("OTHER").map(String::as_str), Some("keep"));
}
#[test]
fn test_file_has_decoded_template() {
use super::file_has_decoded_template;
let toml = Path::new("ci.toml");
let script = Path::new("script.sh");
assert!(!file_has_decoded_template(
toml,
"[hello]\nrun = \"echo hi\"\ndescription = \"a plain task\"\n"
));
assert!(!file_has_decoded_template(
script,
"#!/usr/bin/env bash\n#MISE description=\"a plain task\"\necho hi\n"
));
assert!(file_has_decoded_template(
toml,
"[hello]\nrun = \"echo hi\"\ndescription = \"\\u007b\\u007b exec(command='x') \\u007d\\u007d\"\n"
));
assert!(file_has_decoded_template(
script,
"#!/usr/bin/env bash\n#MISE description=\"\\u007b\\u007b exec(command='x') \\u007d\\u007d\"\necho hi\n"
));
assert!(file_has_decoded_template(
script,
"\u{feff}#MISE description=\"\\u007b\\u007b exec(command='x') \\u007d\\u007d\"\necho hi\n"
));
assert!(file_has_decoded_template(
toml,
"\u{feff}[hello]\nrun = \"echo hi\"\ndescription = \"\\u007b\\u007b exec(command='x') \\u007d\\u007d\"\n"
));
assert!(!file_has_decoded_template(
script,
"\u{feff}#MISE description=\"a plain task\"\necho hi\n"
));
}
#[test]
fn test_parse_task_script_usage_hoists_root_mount() {
use std::io::Write;
for marker in ["#USAGE", "# USAGE"] {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(
format!(
r#"#!/usr/bin/env bash
{marker} flag "--verbose" help="Show extra output"
{marker} mount "shapeme --usage-spec"
exec shapeme "$@"
"#
)
.as_bytes(),
)
.unwrap();
let spec = super::parse_task_script_usage(tmp.path()).unwrap();
assert_eq!(spec.cmd.flags.len(), 1);
assert_eq!(spec.cmd.mounts.len(), 1);
assert_eq!(spec.cmd.mounts[0].run, "shapeme --usage-spec");
assert!(!spec.cmd.subcommands.contains_key("__mise_task_root_mounts"));
}
}
#[test]
fn test_parse_task_script_usage_reads_a_marked_first_line() {
use std::io::Write;
let directives = "#USAGE flag \"-f --force\" help=\"force it\"\nexec tool \"$@\"\n";
for (label, body) in [
("marked", format!("\u{feff}{directives}")),
("unmarked", directives.to_string()),
] {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(body.as_bytes()).unwrap();
let spec = super::parse_task_script_usage(tmp.path()).unwrap();
assert_eq!(spec.cmd.flags.len(), 1, "{label}");
assert_eq!(&spec.cmd.flags[0].name, "force", "{label}");
}
}
#[test]
fn test_parse_task_script_usage_hoists_mise_root_mount() {
use std::io::Write;
for marker in ["#MISE", "# MISE"] {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(
format!(
r#"#!/usr/bin/env bash
#USAGE flag "--verbose" help="Show extra output"
{marker} flag "--mise" help="MISE flag"
{marker} description="Run the mounted CLI"
{marker} mount "shapeme --usage-spec"
exec shapeme "$@"
"#
)
.as_bytes(),
)
.unwrap();
let spec = super::parse_task_script_usage(tmp.path()).unwrap();
assert_eq!(spec.cmd.flags.len(), 2);
assert_eq!(spec.cmd.mounts.len(), 1);
assert_eq!(spec.cmd.mounts[0].run, "shapeme --usage-spec");
}
}
#[test]
fn test_parse_task_script_usage_hoists_root_mount_block() {
use std::io::Write;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(
r#"#!/usr/bin/env bash
#USAGE flag "--template <template>" help="Use {name} template"
#USAGE mount {
#USAGE run "first --usage-spec"
#USAGE }
#USAGE mount "second --usage-spec"
exec shapeme "$@"
"#
.as_bytes(),
)
.unwrap();
let spec = super::parse_task_script_usage(tmp.path()).unwrap();
assert_eq!(spec.cmd.flags.len(), 1);
assert_eq!(spec.cmd.mounts.len(), 2);
assert_eq!(spec.cmd.mounts[0].run, "first --usage-spec");
assert_eq!(spec.cmd.mounts[1].run, "second --usage-spec");
}
#[test]
fn test_task_usage_comment_extraction_matches_usage_lib() {
use std::io::Write;
let script = r#"#!/usr/bin/env bash
#USAGE flag "--verbose" help="Show extra output"
#USAGE arg "<file>" help="Input file"
exec tool "$@"
"#;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(script.as_bytes()).unwrap();
let parsed_by_usage_lib = usage::Spec::parse_script(tmp.path()).unwrap();
let raw = super::extract_usage_from_comments(script);
let parsed_by_fallback: usage::Spec = raw.parse().unwrap();
assert_eq!(
parsed_by_usage_lib.cmd.flags.len(),
parsed_by_fallback.cmd.flags.len()
);
assert_eq!(
parsed_by_usage_lib.cmd.args.len(),
parsed_by_fallback.cmd.args.len()
);
assert_eq!(
parsed_by_usage_lib.cmd.flags[0].usage(),
parsed_by_fallback.cmd.flags[0].usage()
);
assert_eq!(
parsed_by_usage_lib.cmd.args[0].usage,
parsed_by_fallback.cmd.args[0].usage
);
}
#[test]
fn test_parse_task_script_usage_preserves_nested_mount() {
use std::io::Write;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(
r#"#!/usr/bin/env bash
#USAGE cmd "proxy" {
#USAGE mount run="proxy --usage-spec"
#USAGE }
exec proxy "$@"
"#
.as_bytes(),
)
.unwrap();
let spec = super::parse_task_script_usage(tmp.path()).unwrap();
assert!(spec.cmd.mounts.is_empty());
assert_eq!(
spec.cmd.subcommands["proxy"].mounts[0].run,
"proxy --usage-spec"
);
}
#[cfg(unix)]
fn take_captured_fields() -> Option<Vec<String>> {
CAPTURED_PARSER_FIELDS.with(|captured| captured.lock().unwrap().take())
}
#[test]
fn test_tera_template_has_usage_ref() {
assert!(tera_template_has_usage_ref("{{ usage.app }}"));
assert!(tera_template_has_usage_ref(
"{%- if usage.run_post -%}post{%- endif -%}"
));
assert!(tera_template_has_usage_ref("{{ usage['app'] }}"));
assert!(!tera_template_has_usage_ref(
"{{ env.DEPLOY_ENV }} usage.docs"
));
assert!(!tera_template_has_usage_ref("{{ config.usage.something }}"));
assert!(!tera_template_has_usage_ref("{# usage.app #}"));
assert!(tera_tag_has_usage_ref("if(usage.run_post)"));
assert!(!tera_tag_has_usage_ref("ifusage.run_post"));
}
#[test]
fn workspace_task_dependencies_reject_non_prerequisite_fields() {
let graph = workspace::WorkspaceProjectGraph::default();
let project_ids_by_root = BTreeMap::new();
for task in [
Task {
depends_post: vec!["^build".to_string().into()],
..Default::default()
},
Task {
wait_for: vec!["^build".to_string().into()],
..Default::default()
},
] {
let mut task = task;
let err = task
.resolve_workspace_task_dependencies(&graph, &project_ids_by_root)
.unwrap_err();
assert_eq!(
err.to_string(),
"^task dependencies are supported only in depends"
);
}
let mut task = Task {
wait_for: vec!["^build".to_string().into()],
..Default::default()
};
task.set_workspace_task_dependency_error(&eyre::eyre!("invalid graph"));
let err = task.all_depends(&BTreeMap::new()).unwrap_err();
assert_eq!(
err.to_string(),
"^task dependencies are supported only in depends"
);
}
#[test]
fn workspace_task_dependencies_preserve_lenient_discovery_errors() {
let graph = workspace::WorkspaceProjectGraph::discover_all_with_overrides_lenient(
&[&BrokenWorkspaceProvider, &WorkingWorkspaceProvider],
Path::new("/workspace"),
&BTreeMap::new(),
)
.unwrap();
let project_ids_by_root = BTreeMap::new();
let mut discovered_task = Task {
name: "node:app#build".to_string(),
depends: vec!["^build".to_string().into()],
..Default::default()
};
discovered_task
.resolve_workspace_task_dependencies(&graph, &project_ids_by_root)
.unwrap();
assert!(discovered_task.workspace_dependency_error.is_none());
assert!(discovered_task.depends.is_empty());
let mut unresolved_task = Task {
name: "//packages/missing:build".to_string(),
depends: vec!["^build".to_string().into()],
..Default::default()
};
unresolved_task
.resolve_workspace_task_dependencies(&graph, &project_ids_by_root)
.unwrap();
assert_eq!(unresolved_task.depends[0].task, "^build");
assert_eq!(
unresolved_task.workspace_dependency_error.as_deref(),
Some(
"failed to resolve upstream task dependencies because workspace provider \
discovery failed: broken: broken workspace metadata"
)
);
}
#[tokio::test]
async fn test_from_path() {
let test_cases = [(".mise/tasks/filetask", "filetask", vec!["ft"])];
let config = Config::get().await.unwrap();
for (path, name, aliases) in test_cases {
let t = Task::from_path(
&config,
Path::new(path),
Path::new(".mise/tasks"),
Path::new(dirs::CWD.as_ref().unwrap()),
)
.await
.unwrap();
assert_eq!(t.name, name);
assert_eq!(t.aliases, aliases);
}
}
#[tokio::test]
async fn test_render_sandbox_allow_paths() {
let config = Config::get().await.unwrap();
let mut task = Task {
allow_read: vec![Path::new("{{ env.HOME }}/read").into()],
allow_write: vec![
Path::new("{{ \"\" }}").into(),
Path::new("{{ env.HOME }}/write").into(),
],
..Default::default()
};
task.render(&config, Path::new(".")).await.unwrap();
assert_eq!(task.allow_read, vec![crate::env::HOME.join("read")]);
assert_eq!(task.allow_write, vec![crate::env::HOME.join("write")]);
}
#[tokio::test]
async fn test_usage_task_description_populates_help_metadata() {
let config = Config::get().await.unwrap();
let description = indoc::indoc! {"
Format the changed files
If you just want to check the files without automatically fixing them, use the check task.
"}
.trim()
.to_string();
let task = Task {
name: "format".to_string(),
display_name: "format".to_string(),
description: description.clone(),
usage: r#"arg "<file>""#.to_string(),
run: vec![RunEntry::Script("echo {{ usage.file }}".to_string())],
..Default::default()
};
let spec = task.parse_usage_spec_for_display(&config).await.unwrap();
assert_eq!(spec.about.as_deref(), Some("Format the changed files"));
assert_eq!(spec.about_long.as_deref(), Some(description.as_str()));
assert_eq!(spec.cmd.help.as_deref(), Some(description.as_str()));
let help = usage::docs::cli::render_help(&spec, &spec.cmd, true);
assert!(help.contains("Format the changed files"));
assert!(help.contains("If you just want to check the files"));
}
#[test]
#[cfg(unix)]
fn test_name_from_path() {
let test_cases = [
(("/.mise/tasks", "/.mise/tasks/a"), "a"),
(("/.mise/tasks", "/.mise/tasks/a/b"), "a:b"),
(("/.mise/tasks", "/.mise/tasks/a/b/c"), "a:b:c"),
(("/.mise/tasks", "/.mise/tasks/a:b"), "a_b"),
(("/.mise/tasks", "/.mise/tasks/a:b/c"), "a_b:c"),
(("/.mise/tasks", "/.mise/tasks/a/_default"), "a"),
(("/.mise/tasks", "/.mise/tasks/a/_default.sh"), "a"),
(("/.mise/tasks", "/.mise/tasks/a/_default.js"), "a"),
(("/.mise/tasks", "/.mise/tasks/a/b/_default"), "a:b"),
(("/.mise/tasks", "/.mise/tasks/a/b/_default.sh"), "a:b"),
];
for ((root, path), expected) in test_cases {
assert_eq!(name_from_path(root, path).unwrap(), expected)
}
}
#[test]
fn test_name_from_path_invalid() {
let test_cases = [("/some/other/dir", "/.mise/tasks/a")];
for (root, path) in test_cases {
assert!(name_from_path(root, path).is_err())
}
}
#[test]
fn test_shell_parses_and_validates() {
let mut task = Task {
shell: Some("bash -c".to_string()),
..Default::default()
};
assert_eq!(
task.shell().unwrap(),
Some(vec!["bash".to_string(), "-c".to_string()])
);
task.shell = Some(" ".to_string());
assert_eq!(task.shell().unwrap(), None);
task.shell = None;
assert_eq!(task.shell().unwrap(), None);
}
#[test]
fn test_shell_unbalanced_quote_fails_loudly() {
let task = Task {
shell: Some("\"unterminated".to_string()),
..Default::default()
};
assert!(task.shell().is_err());
}
#[test]
#[cfg(windows)]
fn test_shell_parses_explicit_windows_path() {
let task = Task {
shell: Some(r"C:\msys64\usr\bin\bash.exe -c".to_string()),
..Default::default()
};
assert_eq!(
task.shell().unwrap(),
Some(vec![
r"C:\msys64\usr\bin\bash.exe".to_string(),
"-c".to_string()
])
);
let task = Task {
shell: Some("\"C:\\Program Files\\Git\\bin\\bash.exe\" -c".to_string()),
..Default::default()
};
assert_eq!(
task.shell().unwrap(),
Some(vec![
r"C:\Program Files\Git\bin\bash.exe".to_string(),
"-c".to_string()
])
);
}
#[tokio::test]
async fn test_resolve_depends_post_uses_self_only() {
use crate::task::task_dep::TaskDep;
let task_with_post_deps = Task {
name: "task_with_post".to_string(),
depends_post: vec![
TaskDep {
task: "post1".to_string(),
args: vec![],
env: Default::default(),
optional: false,
},
TaskDep {
task: "post2".to_string(),
args: vec![],
env: Default::default(),
optional: false,
},
],
..Default::default()
};
let other_task = Task {
name: "other_task".to_string(),
depends_post: vec![TaskDep {
task: "other_post".to_string(),
args: vec![],
env: Default::default(),
optional: false,
}],
..Default::default()
};
assert_eq!(task_with_post_deps.depends_post.len(), 2);
assert_eq!(task_with_post_deps.depends_post[0].task, "post1");
assert_eq!(task_with_post_deps.depends_post[1].task, "post2");
assert_eq!(other_task.depends_post.len(), 1);
assert_eq!(other_task.depends_post[0].task, "other_post");
}
#[tokio::test]
async fn test_from_path_toml_headers() {
use std::fs;
use tempfile::tempdir;
let config = Config::get().await.unwrap();
let temp_dir = tempdir().unwrap();
let task_path = temp_dir.path().join("test_task");
fs::write(
&task_path,
r#"#!/bin/bash
#MISE description="Build the CLI"
# MISE alias="b"
# [MISE] sources=["Cargo.toml", "src/**/*.rs"]
echo "hello world"
"#,
)
.unwrap();
let result = Task::from_path(&config, &task_path, temp_dir.path(), temp_dir.path()).await;
let mut expected = Task::new(&task_path, temp_dir.path(), temp_dir.path()).unwrap();
expected.description = "Build the CLI".to_string();
expected.aliases = vec!["b".to_string()];
expected.sources = vec!["Cargo.toml".to_string(), "src/**/*.rs".to_string()];
assert_eq!(result.unwrap(), expected);
}
#[tokio::test]
async fn test_from_path_sources_single_string() {
use std::fs;
use tempfile::tempdir;
let config = Config::get().await.unwrap();
let temp_dir = tempdir().unwrap();
let task_path = temp_dir.path().join("test_task");
fs::write(
&task_path,
r#"#!/bin/bash
#MISE sources="src/**/*.rs"
echo "hello world"
"#,
)
.unwrap();
let result = Task::from_path(&config, &task_path, temp_dir.path(), temp_dir.path()).await;
assert_eq!(result.unwrap().sources, vec!["src/**/*.rs".to_string()]);
}
#[tokio::test]
#[cfg(unix)]
async fn test_from_path_env_file_with_spaces_around_equals() {
use std::fs;
use tempfile::tempdir;
let config = Config::get().await.unwrap();
let ts = config.get_toolset().await.unwrap();
let temp_dir = tempdir().unwrap();
let task_path = temp_dir.path().join("hello");
let env_path = temp_dir.path().join("env.yaml");
fs::write(&env_path, "USR: World!\n").unwrap();
fs::write(
&task_path,
r#"#!/usr/bin/env bash
#MISE env._.file = "env.yaml"
echo "Hello $USR"
"#,
)
.unwrap();
let task = Task::from_path(&config, &task_path, temp_dir.path(), temp_dir.path())
.await
.unwrap();
let (env, task_env, _) = task.render_env(&config, ts).await.unwrap();
assert_eq!(task_env, vec![("USR".to_string(), "World!".to_string())]);
assert_eq!(env.get("USR"), Some(&"World!".to_string()));
}
#[tokio::test]
async fn test_from_path_invalid_toml() {
use std::fs;
use tempfile::tempdir;
let config = Config::get().await.unwrap();
let temp_dir = tempdir().unwrap();
let task_path = temp_dir.path().join("test_task");
fs::write(
&task_path,
r#"#!/bin/bash
#MISE description="test task"
#MISE env={invalid=toml=here}
echo "hello world"
"#,
)
.unwrap();
let result = Task::from_path(&config, &task_path, temp_dir.path(), temp_dir.path()).await;
assert!(result.is_err());
let error = result.unwrap_err();
assert!(
error
.to_string()
.contains("failed to parse task header TOML")
);
}
#[tokio::test]
async fn test_from_path_unknown_header_field_is_ignored() {
use std::fs;
use tempfile::tempdir;
let config = Config::get().await.unwrap();
let temp_dir = tempdir().unwrap();
let task_path = temp_dir.path().join("test_task");
fs::write(
&task_path,
r#"#!/usr/bin/env bash
#MISE description="still parsed"
#MISE run_windows="echo nope"
echo "hello world"
"#,
)
.unwrap();
let task = Task::from_path(&config, &task_path, temp_dir.path(), temp_dir.path())
.await
.unwrap();
assert_eq!(task.description, "still parsed");
assert!(task.run_windows.is_empty());
}
#[test]
fn test_resolve_task_pattern() {
use super::resolve_task_pattern;
let parent_task = Task {
name: "//projects/frontend:test".to_string(),
..Default::default()
};
assert_eq!(
resolve_task_pattern(":build", Some(&parent_task)),
"//projects/frontend:build"
);
let parent_task = Task {
name: "//libs/shared:lint".to_string(),
..Default::default()
};
assert_eq!(
resolve_task_pattern(":compile", Some(&parent_task)),
"//libs/shared:compile"
);
let parent_task = Task {
name: "//projects/frontend:test".to_string(),
..Default::default()
};
assert_eq!(
resolve_task_pattern("//projects/backend:build", Some(&parent_task)),
"//projects/backend:build"
);
assert_eq!(
resolve_task_pattern("build", Some(&parent_task)),
"//projects/frontend:build"
);
assert_eq!(resolve_task_pattern(":build", None), ":build");
let parent_task = Task {
name: "test".to_string(),
..Default::default()
};
assert_eq!(resolve_task_pattern(":build", Some(&parent_task)), ":build");
assert_eq!(resolve_task_pattern("build", Some(&parent_task)), "build");
let parent_task = Task {
name: "//:root-task".to_string(),
..Default::default()
};
assert_eq!(
resolve_task_pattern(":other", Some(&parent_task)),
"//:other"
);
let parent_task = Task {
name: "//projects/frontend:test".to_string(),
..Default::default()
};
assert_eq!(
resolve_task_pattern("::global", Some(&parent_task)),
"::global"
);
let parent_task = Task {
name: "node:@scope/app#build".to_string(),
..Default::default()
};
assert_eq!(
resolve_task_pattern(":test", Some(&parent_task)),
"node:@scope/app#test"
);
assert_eq!(
resolve_task_pattern("lint", Some(&parent_task)),
"node:@scope/app#lint"
);
assert_eq!(
resolve_task_pattern("node:@scope/other#test", Some(&parent_task)),
"node:@scope/other#test"
);
let parent_task = Task {
name: "//projects/frontend:test".to_string(),
..Default::default()
};
assert_eq!(
resolve_task_pattern(":test*", Some(&parent_task)),
"//projects/frontend:test*"
);
let parent_task = Task {
name: "//a/b/c/d:task".to_string(),
..Default::default()
};
assert_eq!(
resolve_task_pattern(":dep", Some(&parent_task)),
"//a/b/c/d:dep"
);
let parent_task = Task {
name: "//submodule:do:item-1".to_string(),
..Default::default()
};
assert_eq!(
resolve_task_pattern(":before", Some(&parent_task)),
"//submodule:before"
);
let parent_task = Task {
name: "//project:test:unit:fast".to_string(),
..Default::default()
};
assert_eq!(
resolve_task_pattern(":setup", Some(&parent_task)),
"//project:setup"
);
assert_eq!(resolve_task_pattern("build", None), "build");
let parent_task = Task {
name: "//libs/shared:lint".to_string(),
..Default::default()
};
assert_eq!(
resolve_task_pattern("compile", Some(&parent_task)),
"//libs/shared:compile"
);
let parent_task = Task {
name: "//:root-task".to_string(),
..Default::default()
};
assert_eq!(
resolve_task_pattern("other", Some(&parent_task)),
"//:other"
);
let parent_task = Task {
name: "//submodule:do:item-1".to_string(),
..Default::default()
};
assert_eq!(
resolve_task_pattern("before", Some(&parent_task)),
"//submodule:before"
);
let parent_task = Task {
name: "//projects/frontend:test".to_string(),
..Default::default()
};
assert_eq!(
resolve_task_pattern("//other/module:task", Some(&parent_task)),
"//other/module:task"
);
assert_eq!(
resolve_task_pattern("::global", Some(&parent_task)),
"::global"
);
assert_eq!(
resolve_task_pattern("./...:test:*", Some(&parent_task)),
"//projects/frontend/...:test:*"
);
assert_eq!(
resolve_task_pattern("./child:build", Some(&parent_task)),
"//projects/frontend/child:build"
);
assert_eq!(
resolve_task_pattern("./:build", Some(&parent_task)),
"//projects/frontend:build"
);
let root_task = Task {
name: "//:test".to_string(),
..Default::default()
};
assert_eq!(
resolve_task_pattern("./...:test:*", Some(&root_task)),
"//...:test:*"
);
assert_eq!(
resolve_task_pattern("./:build", Some(&root_task)),
"//:build"
);
let regular_task = Task {
name: "test".to_string(),
..Default::default()
};
assert_eq!(
resolve_task_pattern("./...:test:*", Some(®ular_task)),
"./...:test:*"
);
assert_eq!(resolve_task_pattern("./...:test:*", None), "./...:test:*");
}
#[test]
fn test_extract_monorepo_path() {
use super::extract_monorepo_path;
assert_eq!(
extract_monorepo_path("//projects/frontend:test"),
Some("projects/frontend".to_string())
);
assert_eq!(extract_monorepo_path("//:root-task"), Some("".to_string()));
assert_eq!(
extract_monorepo_path("//a/b/c/d:task"),
Some("a/b/c/d".to_string())
);
assert_eq!(extract_monorepo_path("regular-task"), None);
assert_eq!(
extract_monorepo_path("//submodule:do:item-1"),
Some("submodule".to_string())
);
assert_eq!(
extract_monorepo_path("//project:test:unit:fast"),
Some("project".to_string())
);
assert_eq!(
extract_monorepo_path("//apps/backend:build:prod"),
Some("apps/backend".to_string())
);
}
#[test]
fn test_strip_extension() {
use super::strip_extension;
assert_eq!(strip_extension("task.sh"), "task");
assert_eq!(strip_extension("build.js"), "build");
assert_eq!(strip_extension("test.py"), "test");
assert_eq!(strip_extension("backup.test.js"), "backup.test");
assert_eq!(strip_extension("file.tar.gz"), "file.tar");
assert_eq!(strip_extension("archive.tar.bz2"), "archive.tar");
assert_eq!(strip_extension("task"), "task");
assert_eq!(strip_extension("build"), "build");
assert_eq!(strip_extension(".hidden"), ".hidden");
assert_eq!(strip_extension(".gitignore"), ".gitignore");
assert_eq!(strip_extension(".hidden.sh"), ".hidden");
assert_eq!(strip_extension(".config.json"), ".config");
assert_eq!(strip_extension(""), "");
assert_eq!(strip_extension("."), ".");
assert_eq!(strip_extension("my.task.name.js"), "my.task.name");
assert_eq!(strip_extension("path/to/task.sh"), "path/to/task");
assert_eq!(strip_extension("path/task"), "path/task");
assert_eq!(strip_extension("test.unit"), "test");
assert_eq!(strip_extension("build.prod.js"), "build.prod");
}
#[test]
fn test_circular_dependency_resolution_terminates() {
use super::Task;
use std::collections::BTreeMap;
let mut tasks = BTreeMap::new();
let task_a = Task {
name: "task_a".to_string(),
depends: vec![crate::task::task_dep::TaskDep {
task: "task_b".to_string(),
args: vec![],
env: Default::default(),
optional: false,
}],
..Default::default()
};
let task_b = Task {
name: "task_b".to_string(),
depends: vec![crate::task::task_dep::TaskDep {
task: "task_a".to_string(),
args: vec![],
env: Default::default(),
optional: false,
}],
..Default::default()
};
tasks.insert("task_a".to_string(), task_a.clone());
tasks.insert("task_b".to_string(), task_b);
let deps = task_a.all_depends(&tasks).unwrap();
assert_eq!(deps.iter().map(|t| &t.name).collect::<Vec<_>>(), ["task_b"]);
}
#[test]
fn test_transitive_circular_dependency_resolution_terminates() {
use super::Task;
use std::collections::BTreeMap;
let mut tasks = BTreeMap::new();
let task_a = Task {
name: "task_a".to_string(),
depends: vec![crate::task::task_dep::TaskDep {
task: "task_b".to_string(),
args: vec![],
env: Default::default(),
optional: false,
}],
..Default::default()
};
let task_b = Task {
name: "task_b".to_string(),
depends: vec![crate::task::task_dep::TaskDep {
task: "task_c".to_string(),
args: vec![],
env: Default::default(),
optional: false,
}],
..Default::default()
};
let task_c = Task {
name: "task_c".to_string(),
depends: vec![crate::task::task_dep::TaskDep {
task: "task_a".to_string(),
args: vec![],
env: Default::default(),
optional: false,
}],
..Default::default()
};
tasks.insert("task_a".to_string(), task_a.clone());
tasks.insert("task_b".to_string(), task_b);
tasks.insert("task_c".to_string(), task_c);
let deps = task_a.all_depends(&tasks).unwrap();
assert_eq!(
deps.iter().map(|t| &t.name).collect::<Vec<_>>(),
["task_b", "task_c"]
);
}
#[test]
fn test_no_false_positive_for_diamond_dependency() {
use super::Task;
use std::collections::BTreeMap;
let mut tasks = BTreeMap::new();
let root = Task {
name: "root".to_string(),
depends: vec![
crate::task::task_dep::TaskDep {
task: "task_a".to_string(),
args: vec![],
env: Default::default(),
optional: false,
},
crate::task::task_dep::TaskDep {
task: "task_b".to_string(),
args: vec![],
env: Default::default(),
optional: false,
},
],
..Default::default()
};
let task_a = Task {
name: "task_a".to_string(),
depends: vec![crate::task::task_dep::TaskDep {
task: "common".to_string(),
args: vec![],
env: Default::default(),
optional: false,
}],
..Default::default()
};
let task_b = Task {
name: "task_b".to_string(),
depends: vec![crate::task::task_dep::TaskDep {
task: "common".to_string(),
args: vec![],
env: Default::default(),
optional: false,
}],
..Default::default()
};
let common = Task {
name: "common".to_string(),
..Default::default()
};
tasks.insert("root".to_string(), root.clone());
tasks.insert("task_a".to_string(), task_a);
tasks.insert("task_b".to_string(), task_b);
tasks.insert("common".to_string(), common);
let result = root.all_depends(&tasks);
assert!(result.is_ok());
let deps = result.unwrap();
assert_eq!(deps.len(), 3);
}
#[test]
fn test_file_path_raw_absolute() {
use std::path::PathBuf;
let task = Task {
name: "test".to_string(),
file: Some(PathBuf::from("/absolute/path/script.sh")),
config_root: Some(PathBuf::from("/project/root")),
..Default::default()
};
let result = task.file_path_raw();
assert_eq!(result, Some(PathBuf::from("/absolute/path/script.sh")));
}
#[test]
fn test_file_path_raw_relative() {
use std::path::PathBuf;
let task = Task {
name: "test".to_string(),
file: Some(PathBuf::from("scripts/test.sh")),
config_root: Some(PathBuf::from("/project/root")),
..Default::default()
};
let result = task.file_path_raw();
assert_eq!(result, Some(PathBuf::from("/project/root/scripts/test.sh")));
}
#[test]
fn test_file_path_raw_relative_no_config_root() {
use std::path::PathBuf;
let task = Task {
name: "test".to_string(),
file: Some(PathBuf::from("scripts/test.sh")),
config_root: None,
..Default::default()
};
let result = task.file_path_raw();
assert_eq!(result, Some(PathBuf::from("scripts/test.sh")));
}
#[test]
fn test_file_path_raw_none() {
let task = Task {
name: "test".to_string(),
file: None,
config_root: None,
..Default::default()
};
let result = task.file_path_raw();
assert_eq!(result, None);
}
#[tokio::test]
async fn test_file_path_absolute() {
use std::path::PathBuf;
let config = Config::get().await.unwrap();
let task = Task {
name: "test".to_string(),
file: Some(PathBuf::from("/absolute/path/script.sh")),
config_root: Some(PathBuf::from("/project/root")),
..Default::default()
};
let result = task.file_path(&config).await.unwrap();
assert_eq!(result, Some(PathBuf::from("/absolute/path/script.sh")));
}
#[tokio::test]
async fn test_file_path_relative() {
use std::path::PathBuf;
let config = Config::get().await.unwrap();
let task = Task {
name: "test".to_string(),
file: Some(PathBuf::from("scripts/test.sh")),
config_root: Some(PathBuf::from("/project/root")),
..Default::default()
};
let result = task.file_path(&config).await.unwrap();
assert_eq!(result, Some(PathBuf::from("/project/root/scripts/test.sh")));
}
#[tokio::test]
async fn test_file_path_none() {
let config = Config::get().await.unwrap();
let task = Task {
name: "test".to_string(),
file: None,
config_root: None,
..Default::default()
};
let result = task.file_path(&config).await.unwrap();
assert_eq!(result, None);
}
#[tokio::test]
async fn test_file_path_with_templating() {
use std::path::PathBuf;
let config = Config::get().await.unwrap();
let task = Task {
name: "test".to_string(),
file: Some(PathBuf::from("scripts/{{config_root}}/test.sh")),
config_root: Some(PathBuf::from("/project/root")),
..Default::default()
};
let result = task.file_path(&config).await;
assert!(result.is_ok());
}
#[tokio::test]
#[cfg(unix)]
async fn test_parses_all_fields() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let tasks_dir = temp_dir.path().join("tasks");
fs::create_dir(&tasks_dir).unwrap();
let task_file = tasks_dir.join("test-task");
let script_content = r#"#!/usr/bin/env bash
#MISE description="Test task with all fields"
#MISE aliases=["alias1", "alias2"]
#MISE depends=["dep1", "dep2"]
#MISE depends_post=["post1"]
#MISE wait_for=["wait1"]
#MISE env={TEST_VAR="value"}
#MISE dir="/some/dir"
#MISE hide=true
#MISE raw=true
#MISE raw_args=true
#MISE interactive=true
#MISE sources=["src1.txt", "src2.txt"]
#MISE watch={no_vcs_ignore=true}
#MISE outputs=["out1.txt"]
#MISE cache={enabled=true,env=["PROFILE"]}
#MISE rust_cache=true
#MISE pass_through_env=["DEPLOY_TOKEN"]
#MISE shell="bash -c"
#MISE quiet=true
#MISE silent=true
#MISE output="prefix"
#MISE tools={node={prefix="20"}, python="3.11"}
#MISE confirm="Are you sure?"
echo "test"
"#;
fs::write(&task_file, script_content).unwrap();
fs::set_permissions(&task_file, std::fs::Permissions::from_mode(0o755)).unwrap();
let config = Config::get().await.unwrap();
let task = Task::from_path(&config, &task_file, &tasks_dir, temp_dir.path())
.await
.unwrap();
assert_eq!(task.description, "Test task with all fields");
assert_eq!(task.aliases, vec!["alias1", "alias2"]);
assert_eq!(task.depends.len(), 2);
assert_eq!(task.depends_post.len(), 1);
assert_eq!(task.wait_for.len(), 1);
assert_eq!(task.dir, Some("/some/dir".to_string()));
assert_eq!(task.hide, true);
assert_eq!(task.raw, true);
assert_eq!(task.raw_args, true);
assert_eq!(task.interactive, true);
assert_eq!(task.sources, vec!["src1.txt", "src2.txt"]);
assert_eq!(
task.watch,
Some(TaskWatchOptions {
no_vcs_ignore: true
})
);
assert_eq!(
task.cache,
Some(TaskCacheConfig {
enabled: true,
audit: false,
env: vec!["PROFILE".to_string()],
command_inputs: vec![],
})
);
assert_eq!(task.rust_cache, Some(TaskRustCacheConfig::default()));
assert_eq!(task.pass_through_env, ["DEPLOY_TOKEN"]);
assert_eq!(task.shell, Some("bash -c".to_string()));
assert_eq!(task.quiet, true);
assert_eq!(task.output, Some(TaskOutput::Prefix));
assert!(!task.tools.is_empty());
assert_eq!(
task.tools.get("node"),
Some(&super::TaskToolValue::Map(super::TaskToolValueMap {
version: "prefix:20".to_string(),
opts: IndexMap::new(),
}))
);
assert_eq!(
task.confirm,
Some(TaskConfirm::Message("Are you sure?".to_string()))
);
let mut parsed_fields =
take_captured_fields().expect("Parser fields should have been captured");
let has_alias = parsed_fields.iter().any(|k| k == "alias");
parsed_fields.retain(|k| k != "aliases" || !has_alias);
let script_lines = script_content.lines().count() - 2;
assert_eq!(
parsed_fields.len(),
script_lines,
"Parser looks for {} properties but test script has {} field lines.\n\
If you added (or removed) parseable fields, add it to the test script.\n\
Parser fields: {:?}",
parsed_fields.len(),
script_lines,
parsed_fields
);
}
#[tokio::test]
#[cfg(unix)]
async fn test_parses_structured_file_task_dependencies() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let tasks_dir = temp_dir.path().join("tasks");
fs::create_dir(&tasks_dir).unwrap();
let task_file = tasks_dir.join("structured-dependencies");
fs::write(
&task_file,
r#"#!/usr/bin/env bash
#MISE depends=["simple", {task="structured", args=["--flag"], env={MODE="test"}}]
#MISE depends_post=[["cleanup", "--all"], {task="notify"}]
#MISE wait_for=["setup", {task="service", env={PORT="3000"}}]
echo "test"
"#,
)
.unwrap();
fs::set_permissions(&task_file, std::fs::Permissions::from_mode(0o755)).unwrap();
let config = Config::get().await.unwrap();
let task = Task::from_path(&config, &task_file, &tasks_dir, temp_dir.path())
.await
.unwrap();
assert_eq!(task.depends.len(), 2);
assert_eq!(task.depends[0].task, "simple");
assert_eq!(task.depends[1].task, "structured");
assert_eq!(task.depends[1].args, ["--flag"]);
assert_eq!(task.depends[1].env.get("MODE").unwrap(), "test");
assert_eq!(task.depends_post.len(), 2);
assert_eq!(task.depends_post[0].task, "cleanup");
assert_eq!(task.depends_post[0].args, ["--all"]);
assert_eq!(task.depends_post[1].task, "notify");
assert_eq!(task.wait_for.len(), 2);
assert_eq!(task.wait_for[0].task, "setup");
assert_eq!(task.wait_for[1].task, "service");
assert_eq!(task.wait_for[1].env.get("PORT").unwrap(), "3000");
}
#[tokio::test]
#[cfg(unix)]
async fn test_file_task_reports_tool_selector_errors() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let tasks_dir = temp_dir.path().join("tasks");
fs::create_dir(&tasks_dir).unwrap();
let task_file = tasks_dir.join("invalid-tools");
fs::write(
&task_file,
"#!/usr/bin/env bash\n#MISE tools={node={version=\"20\", prefix=\"20\"}}\n",
)
.unwrap();
fs::set_permissions(&task_file, std::fs::Permissions::from_mode(0o755)).unwrap();
let config = Config::get().await.unwrap();
let err = Task::from_path(&config, &task_file, &tasks_dir, temp_dir.path())
.await
.unwrap_err();
assert!(
err.to_string().contains(
"failed to parse task tool `node`: tool definition cannot specify both `version` and `prefix`"
),
"{err}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn test_multi_line_tools_merge() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
use super::TaskToolValue;
let temp_dir = tempdir().unwrap();
let tasks_dir = temp_dir.path().join("tasks");
fs::create_dir(&tasks_dir).unwrap();
let task_file = tasks_dir.join("multi-tools-task");
let script_content = r#"#!/usr/bin/env bash
#MISE tools.node="20"
#MISE tools.python="3.11"
#MISE tools.ruby="3.2"
echo "test"
"#;
fs::write(&task_file, script_content).unwrap();
fs::set_permissions(&task_file, std::fs::Permissions::from_mode(0o755)).unwrap();
let config = Config::get().await.unwrap();
let task = Task::from_path(&config, &task_file, &tasks_dir, temp_dir.path())
.await
.unwrap();
assert_eq!(
task.tools.len(),
3,
"Expected 3 tools, got: {:?}",
task.tools
);
assert!(
task.tools.contains_key("node"),
"Expected 'node' in tools: {:?}",
task.tools
);
assert!(
task.tools.contains_key("python"),
"Expected 'python' in tools: {:?}",
task.tools
);
assert!(
task.tools.contains_key("ruby"),
"Expected 'ruby' in tools: {:?}",
task.tools
);
assert_eq!(
task.tools.get("node").unwrap(),
&TaskToolValue::String("20".to_string())
);
assert_eq!(
task.tools.get("python").unwrap(),
&TaskToolValue::String("3.11".to_string())
);
assert_eq!(
task.tools.get("ruby").unwrap(),
&TaskToolValue::String("3.2".to_string())
);
}
#[test]
fn test_scan_mise_header_entries() {
let body = r#"#!/usr/bin/env bash
#MISE description="hi"
#MISE depends=[
#MISE "lint",
#MISE ]
#MISE flag "--verbose" help="not a header"
#MISE tools.node="20"
echo hi
"#;
let entries = super::scan_mise_header_entries(body);
let got = entries
.iter()
.map(|e| (e.start, e.end, e.toml.as_str()))
.collect::<Vec<_>>();
assert_eq!(
got,
vec![
(1, 1, "description=\"hi\""),
(2, 4, "depends=[\n \"lint\",\n]"),
(6, 6, "tools.node=\"20\""),
]
);
}
#[test]
fn test_scan_mise_header_entries_multiline_basic_string() {
let entries = super::scan_mise_header_entries(
"#MISE description=\"\"\"abc \\\"\"\" def\n#MISE ghi\"\"\"\n",
);
assert_eq!(entries.len(), 1);
assert_eq!((entries[0].start, entries[0].end), (0, 1));
assert_eq!(
entries[0].parse_toml().unwrap()["description"].as_str(),
Some("abc \"\"\" def\nghi")
);
}
#[test]
fn test_scan_mise_header_entries_ignores_brackets_in_strings() {
let entries =
super::scan_mise_header_entries("#MISE description=\"see [1]\"\n#MISE alias=\"b\"\n");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].end, 0);
assert_eq!(entries[1].start, 1);
}
#[tokio::test]
async fn test_from_path_multi_line_array_header() {
use std::fs;
use tempfile::tempdir;
let config = Config::get().await.unwrap();
let temp_dir = tempdir().unwrap();
let task_path = temp_dir.path().join("multi-line");
fs::write(
&task_path,
r#"#!/usr/bin/env bash
#MISE description="multi-line arrays"
#MISE depends=[
#MISE "lint",
#MISE "test",
#MISE ]
#MISE sources=[
#MISE "src/**/*.rs"
#MISE ]
echo "test"
"#,
)
.unwrap();
let task = Task::from_path(&config, &task_path, temp_dir.path(), temp_dir.path())
.await
.unwrap();
assert_eq!(task.description, "multi-line arrays");
assert_eq!(task.depends.len(), 2);
assert_eq!(task.depends[0].task, "lint");
assert_eq!(task.depends[1].task, "test");
assert_eq!(task.sources, vec!["src/**/*.rs".to_string()]);
}
#[tokio::test]
async fn test_from_path_multi_line_array_of_inline_tables() {
use std::fs;
use tempfile::tempdir;
let config = Config::get().await.unwrap();
let temp_dir = tempdir().unwrap();
let task_path = temp_dir.path().join("structured-multi-line");
fs::write(
&task_path,
r#"#!/usr/bin/env node
//MISE depends=[
//MISE { task = "lint", args = ["--fix"] },
//MISE "test",
//MISE ]
console.log("hi");
"#,
)
.unwrap();
let task = Task::from_path(&config, &task_path, temp_dir.path(), temp_dir.path())
.await
.unwrap();
assert_eq!(task.depends.len(), 2);
assert_eq!(task.depends[0].task, "lint");
assert_eq!(task.depends[0].args, ["--fix"]);
assert_eq!(task.depends[1].task, "test");
}
#[tokio::test]
async fn test_from_path_unterminated_multi_line_header() {
use std::fs;
use tempfile::tempdir;
let config = Config::get().await.unwrap();
let temp_dir = tempdir().unwrap();
let task_path = temp_dir.path().join("unterminated");
fs::write(
&task_path,
r#"#!/usr/bin/env bash
#MISE depends=[
#MISE "lint"
echo "test"
"#,
)
.unwrap();
let err = Task::from_path(&config, &task_path, temp_dir.path(), temp_dir.path())
.await
.unwrap_err()
.to_string();
assert!(err.contains("failed to parse task header TOML"), "{err}");
assert!(err.contains("lines 2-3"), "{err}");
assert!(err.contains("\"lint\""), "{err}");
}
#[tokio::test]
async fn test_from_path_multi_line_inline_table() {
use super::TaskToolValue;
use std::fs;
use tempfile::tempdir;
let config = Config::get().await.unwrap();
let temp_dir = tempdir().unwrap();
let task_path = temp_dir.path().join("inline-table");
fs::write(
&task_path,
r#"#!/usr/bin/env bash
#MISE tools={
#MISE node="20",
#MISE python="3.11"
#MISE }
echo "test"
"#,
)
.unwrap();
let task = Task::from_path(&config, &task_path, temp_dir.path(), temp_dir.path())
.await
.unwrap();
assert_eq!(
task.tools.get("node").unwrap(),
&TaskToolValue::String("20".to_string())
);
assert_eq!(
task.tools.get("python").unwrap(),
&TaskToolValue::String("3.11".to_string())
);
}
#[test]
fn test_extract_usage_skips_multi_line_headers() {
let script = r#"#!/usr/bin/env bash
#MISE depends=[
#MISE "lint",
#MISE ]
#USAGE flag "--verbose" help="Show extra output"
echo hi
"#;
assert_eq!(
super::extract_usage_from_comments(script),
r#"flag "--verbose" help="Show extra output""#
);
}
#[test]
fn test_file_has_decoded_template_multi_line_header() {
use super::file_has_decoded_template;
let script = Path::new("script.sh");
assert!(file_has_decoded_template(
script,
"#!/usr/bin/env bash\n#MISE depends=[\n#MISE \"\\u007b\\u007b exec(command='x') \\u007d\\u007d\"\n#MISE ]\necho hi\n"
));
assert!(file_has_decoded_template(
script,
"#!/usr/bin/env bash\n#MISE env={invalid=toml=here}\n#MISE description=\"\\u007b\\u007b exec(command='x') \\u007d\\u007d\"\necho hi\n"
));
}
#[tokio::test]
#[cfg(unix)]
async fn test_hyphenated_and_numeric_tool_names() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
use super::TaskToolValue;
let temp_dir = tempdir().unwrap();
let tasks_dir = temp_dir.path().join("tasks");
fs::create_dir(&tasks_dir).unwrap();
let task_file = tasks_dir.join("hyphenated-tools-task");
let script_content = r#"#!/usr/bin/env bash
#MISE tools.git-cliff="1.0"
#MISE tools.1password-cli="2.0"
echo "test"
"#;
fs::write(&task_file, script_content).unwrap();
fs::set_permissions(&task_file, std::fs::Permissions::from_mode(0o755)).unwrap();
let config = Config::get().await.unwrap();
let task = Task::from_path(&config, &task_file, &tasks_dir, temp_dir.path())
.await
.unwrap();
assert_eq!(
task.tools.len(),
2,
"Expected 2 tools, got: {:?}",
task.tools
);
assert!(
task.tools.contains_key("git-cliff"),
"Expected 'git-cliff' in tools: {:?}",
task.tools
);
assert!(
task.tools.contains_key("1password-cli"),
"Expected '1password-cli' in tools: {:?}",
task.tools
);
assert_eq!(
task.tools.get("git-cliff").unwrap(),
&TaskToolValue::String("1.0".to_string())
);
assert_eq!(
task.tools.get("1password-cli").unwrap(),
&TaskToolValue::String("2.0".to_string())
);
}
#[tokio::test]
async fn test_to_tool_arg_preserves_scalar_options() {
use indexmap::IndexMap;
use crate::config::Config;
use super::{TaskToolValue, TaskToolValueMap};
let _config = Config::get().await.unwrap();
let mut opts = IndexMap::new();
opts.insert(
"query".to_string(),
toml::Value::String("first,second=value".to_string()),
);
opts.insert(
"pattern".to_string(),
toml::Value::String(r#"a"b"#.to_string()),
);
opts.insert(
"bin_path".to_string(),
toml::Value::String("bin[debug]".to_string()),
);
opts.insert("strip_components".to_string(), toml::Value::Integer(1));
opts.insert(
"github_attestations".to_string(),
toml::Value::Boolean(true),
);
opts.insert("allow_builds".to_string(), toml::Value::Boolean(true));
opts.insert(
"numeric_string".to_string(),
toml::Value::String("1e2".to_string()),
);
opts.insert("os".to_string(), toml::Value::String("linux".to_string()));
opts.insert(
"depends".to_string(),
toml::Value::String("node".to_string()),
);
opts.insert(
"targets".to_string(),
toml::Value::Array(vec![toml::Value::String("x86_64".to_string())]),
);
opts.insert(
"platforms".to_string(),
toml::Value::Table(toml::map::Map::new()),
);
let tool = TaskToolValue::Map(TaskToolValueMap {
version: "1.0.0".to_string(),
opts,
});
let request = tool.to_tool_arg("http:hello").unwrap().tvr.unwrap();
let options = request.options();
assert_eq!(options.get("query"), Some("first,second=value"));
assert_eq!(options.get("pattern"), Some(r#"a"b"#));
assert_eq!(options.get("bin_path"), Some("bin[debug]"));
assert_eq!(options.get_string("strip_components"), Some("1".into()));
assert_eq!(
options.opts.get("github_attestations"),
Some(&toml::Value::String("true".to_string()))
);
assert_eq!(
options.opts.get("allow_builds"),
Some(&toml::Value::Boolean(true))
);
assert_eq!(options.get("numeric_string"), Some("1e2"));
assert_eq!(
options.opts.get("targets"),
Some(&toml::Value::Array(vec![toml::Value::String(
"x86_64".to_string()
)]))
);
assert_eq!(
options.opts.get("platforms"),
Some(&toml::Value::Table(toml::map::Map::new()))
);
assert_eq!(options.os, Some(vec!["linux".to_string()]));
assert_eq!(options.depends, Some(vec!["node".to_string()]));
}
#[test]
fn test_task_tool_map_selectors() {
use serde::Deserialize;
use super::TaskToolValue;
#[derive(Deserialize)]
struct TaskTools {
tools: IndexMap<String, TaskToolValue>,
}
let parsed: TaskTools = toml::from_str(
r#"
[tools]
node = { version = "20", backend_options = { nested = [1, true] } }
go = { prefix = "1.22" }
python = { ref = "main" }
shellcheck = { path = "/opt/shellcheck" }
"#,
)
.unwrap();
for (tool, expected) in [
("node", "20"),
("go", "prefix:1.22"),
("python", "ref:main"),
("shellcheck", "path:/opt/shellcheck"),
] {
let TaskToolValue::Map(value) = &parsed.tools[tool] else {
panic!("expected mapped task tool for {tool}");
};
assert_eq!(value.version, expected);
}
let TaskToolValue::Map(node) = &parsed.tools["node"] else {
panic!("expected mapped node task tool");
};
assert_eq!(
node.opts["backend_options"]["nested"].as_array(),
Some(&vec![toml::Value::Integer(1), toml::Value::Boolean(true)])
);
for (invalid, expected) in [
(
"[tools]\nnode = { version = \"20\", prefix = \"20\" }\n",
"tool definition cannot specify both `version` and `prefix`",
),
(
"[tools]\nnode = { os = \"linux\" }\n",
"tool definition must include exactly one of `version`, `path`, `prefix`, or `ref`",
),
(
"[tools]\nnode = { prefix = 20 }\n",
"tool selector `prefix` must be a string",
),
] {
let err = match toml::from_str::<TaskTools>(invalid) {
Ok(_) => panic!("expected task tool selector validation to fail"),
Err(err) => err,
};
assert!(err.to_string().contains(expected), "{err}");
}
}
#[tokio::test]
async fn test_to_tool_arg_preserves_structured_core_options() {
use indexmap::IndexMap;
use crate::config::Config;
use super::{TaskToolValue, TaskToolValueMap};
let _config = Config::get().await.unwrap();
let tool = TaskToolValue::Map(TaskToolValueMap {
version: "1.0.0".to_string(),
opts: IndexMap::from([
(
"os".to_string(),
toml::Value::Array(vec![toml::Value::String("linux".to_string())]),
),
(
"depends".to_string(),
toml::Value::Array(vec![toml::Value::String("node".to_string())]),
),
]),
});
let options = tool
.to_tool_arg("http:task-tool-options")
.unwrap()
.tvr
.unwrap()
.options();
assert_eq!(options.os, Some(vec!["linux".to_string()]));
assert_eq!(options.depends, Some(vec!["node".to_string()]));
}
#[tokio::test]
async fn test_task_tool_options_survive_toolset_build() {
use indexmap::IndexMap;
use crate::config::Config;
use crate::toolset::ToolsetBuilder;
use super::{TaskToolValue, TaskToolValueMap};
let config = Config::get().await.unwrap();
let tool = TaskToolValue::Map(TaskToolValueMap {
version: "1.0.0".to_string(),
opts: IndexMap::from([(
"query".to_string(),
toml::Value::String("first,second=value".to_string()),
)]),
});
let arg = tool.to_tool_arg("http:task-tool-options").unwrap();
let toolset = ToolsetBuilder::new()
.with_args(&[arg])
.build(&config)
.await
.unwrap();
let request = toolset
.list_current_requests()
.into_iter()
.find(|request| request.ba().short == "http:task-tool-options")
.unwrap();
assert_eq!(request.options().get("query"), Some("first,second=value"));
}
#[tokio::test]
async fn test_to_tool_arg_preserves_options_for_all_request_types() {
use indexmap::IndexMap;
use crate::config::Config;
use super::{TaskToolValue, TaskToolValueMap};
let _config = Config::get().await.unwrap();
for version in [
"1.0.0",
"prefix:1",
"ref:main",
"path:/tmp/task-tool-options",
"sub-foo:1.0.0",
"system",
] {
let tool = TaskToolValue::Map(TaskToolValueMap {
version: version.to_string(),
opts: IndexMap::from([(
"query".to_string(),
toml::Value::String("value".to_string()),
)]),
});
let request = tool
.to_tool_arg("http:task-tool-options")
.unwrap()
.tvr
.unwrap();
assert_eq!(request.options().get("query"), Some("value"), "{version}");
}
}
#[tokio::test]
async fn test_task_tool_renders_nested_options() {
use indexmap::IndexMap;
use super::{TaskToolValue, TaskToolValueMap};
use crate::config::Config;
use crate::tera::TeraEngine;
let _config = Config::get().await.unwrap();
let mut platform = toml::map::Map::new();
platform.insert(
"bin".to_string(),
toml::Value::String("bin/{{ target }}".to_string()),
);
let mut platforms = toml::map::Map::new();
platforms.insert("linux-x64".to_string(), toml::Value::Table(platform));
let mut opts = IndexMap::new();
opts.insert("platforms".to_string(), toml::Value::Table(platforms));
opts.insert(
"matching".to_string(),
toml::Value::Array(vec![
toml::Value::String("{{ target }}".to_string()),
toml::Value::String("static".to_string()),
]),
);
let mut tool = TaskToolValue::Map(TaskToolValueMap {
version: "{{ version }}".to_string(),
opts,
});
let mut context = tera::Context::new();
context.insert("target", "tool");
context.insert("version", "1.0.0");
let mut tera = TeraEngine::V2(Box::default());
tool.render_templates(&mut tera, &context).unwrap();
let request = tool.to_tool_arg("http:hello").unwrap().tvr.unwrap();
assert_eq!(request.version(), "1.0.0");
assert_eq!(
request
.options()
.get_nested_string("platforms.linux-x64.bin"),
Some("bin/tool".to_string())
);
assert_eq!(
request.options().opts.get("matching"),
Some(&toml::Value::Array(vec![
toml::Value::String("tool".to_string()),
toml::Value::String("static".to_string()),
]))
);
}
#[tokio::test]
async fn test_task_render_detects_nested_tool_option_templates() {
use indexmap::IndexMap;
use super::{Task, TaskToolValue, TaskToolValueMap};
use crate::config::Config;
let config = Config::get().await.unwrap();
let nested_options = |value| {
let mut platform = toml::map::Map::new();
platform.insert("bin".to_string(), toml::Value::String(value));
let mut platforms = toml::map::Map::new();
platforms.insert("linux-x64".to_string(), toml::Value::Table(platform));
IndexMap::from([("platforms".to_string(), toml::Value::Table(platforms))])
};
let mut task = Task {
tools: IndexMap::from([(
"http:hello".to_string(),
TaskToolValue::Map(TaskToolValueMap {
version: "1.0.0".to_string(),
opts: nested_options("{{ env.HOME }}-bin".to_string()),
}),
)]),
..Default::default()
};
task.render(&config, Path::new(".")).await.unwrap();
let TaskToolValue::Map(tool) = task.tools.get("http:hello").unwrap() else {
panic!("expected mapped task tool value");
};
let expected = format!("{}-bin", crate::env::HOME.display());
assert_eq!(
tool.opts["platforms"]["linux-x64"]["bin"].as_str(),
Some(expected.as_str())
);
let mut invalid_task = Task {
tools: IndexMap::from([(
"http:hello".to_string(),
TaskToolValue::Map(TaskToolValueMap {
version: "1.0.0".to_string(),
opts: nested_options("{{".to_string()),
}),
)]),
..Default::default()
};
assert!(invalid_task.render(&config, Path::new(".")).await.is_err());
}
#[test]
fn test_get_matching_wildcard_does_not_match_parent() {
use std::collections::BTreeMap;
use super::GetMatchingExt;
let mut tasks: BTreeMap<String, String> = BTreeMap::new();
tasks.insert("test".to_string(), "test".to_string());
tasks.insert("test:foo".to_string(), "test:foo".to_string());
tasks.insert("test:bar".to_string(), "test:bar".to_string());
let matches = tasks.get_matching("test:*").unwrap();
assert_eq!(
matches,
vec![&"test:bar".to_string(), &"test:foo".to_string()]
);
let matches = tasks.get_matching("test").unwrap();
assert!(matches.contains(&&"test".to_string()));
}
#[test]
fn test_get_matching_respects_task_group_boundaries() {
use std::collections::BTreeMap;
use super::GetMatchingExt;
let tasks = BTreeMap::from([
("test".to_string(), "test".to_string()),
("test:local".to_string(), "test:local".to_string()),
(
"test:units:local".to_string(),
"test:units:local".to_string(),
),
(
"test:integration:local".to_string(),
"test:integration:local".to_string(),
),
(
"test:e2e:happy:local".to_string(),
"test:e2e:happy:local".to_string(),
),
]);
assert_eq!(
tasks.get_matching("test:*:local").unwrap(),
vec![
&"test:integration:local".to_string(),
&"test:units:local".to_string(),
]
);
assert_eq!(
tasks.get_matching("test:**:local").unwrap(),
vec![
&"test:e2e:happy:local".to_string(),
&"test:integration:local".to_string(),
&"test:local".to_string(),
&"test:units:local".to_string(),
]
);
assert!(
tasks
.get_matching("test:*")
.unwrap()
.contains(&&"test:local".to_string())
);
assert!(
!tasks
.get_matching("test:*")
.unwrap()
.contains(&&"test".to_string())
);
}
#[test]
fn test_get_matching_group_globs_support_other_glob_syntax() {
use std::collections::BTreeMap;
use super::GetMatchingExt;
let tasks = BTreeMap::from([
(
"generate:completions".to_string(),
"generate:completions".to_string(),
),
(
"generate:docs:api".to_string(),
"generate:docs:api".to_string(),
),
(
"generate:docs:api:deep".to_string(),
"generate:docs:api:deep".to_string(),
),
("check:a".to_string(), "check:a".to_string()),
("check:b".to_string(), "check:b".to_string()),
("check:ab".to_string(), "check:ab".to_string()),
]);
assert_eq!(
tasks.get_matching("generate:{completions,docs:*}").unwrap(),
vec![
&"generate:completions".to_string(),
&"generate:docs:api".to_string(),
]
);
assert_eq!(
tasks.get_matching("check:?").unwrap(),
vec![&"check:a".to_string(), &"check:b".to_string()]
);
assert_eq!(
tasks.get_matching("check:[ab]").unwrap(),
vec![&"check:a".to_string(), &"check:b".to_string()]
);
}
#[test]
fn test_get_matching_group_boundaries_in_monorepo_and_workspace_tasks() {
use std::collections::BTreeMap;
use super::GetMatchingExt;
let tasks = BTreeMap::from([
(
"//pkg:test:units:local".to_string(),
"//pkg:test:units:local".to_string(),
),
(
"//pkg:test:e2e:happy:local".to_string(),
"//pkg:test:e2e:happy:local".to_string(),
),
(
"node:@scope/app#test:units:local".to_string(),
"node:@scope/app#test:units:local".to_string(),
),
(
"node:@scope/app#test:e2e:happy:local".to_string(),
"node:@scope/app#test:e2e:happy:local".to_string(),
),
]);
assert_eq!(
tasks.get_matching("//pkg:test:*:local").unwrap(),
vec![&"//pkg:test:units:local".to_string()]
);
assert_eq!(
tasks.get_matching("//pkg:test:**:local").unwrap(),
vec![
&"//pkg:test:e2e:happy:local".to_string(),
&"//pkg:test:units:local".to_string(),
]
);
assert_eq!(
tasks.get_matching("node:@scope/app#test:*:local").unwrap(),
vec![&"node:@scope/app#test:units:local".to_string()]
);
assert_eq!(
tasks.get_matching("node:@scope/app#test:**:local").unwrap(),
vec![
&"node:@scope/app#test:e2e:happy:local".to_string(),
&"node:@scope/app#test:units:local".to_string(),
]
);
}
#[test]
fn test_get_matching_monorepo_project_without_slash_prefix() {
use std::collections::BTreeMap;
use super::GetMatchingExt;
let tasks = BTreeMap::from([
("//web:build".to_string(), "//web:build".to_string()),
("//web:dev".to_string(), "//web:dev".to_string()),
("//api:build".to_string(), "//api:build".to_string()),
]);
assert_eq!(
tasks.get_matching("web:build").unwrap(),
vec![&"//web:build".to_string()]
);
assert_eq!(
tasks.get_matching("web:*").unwrap(),
vec![&"//web:build".to_string(), &"//web:dev".to_string()]
);
assert_eq!(
tasks.get_matching("api:build").unwrap(),
vec![&"//api:build".to_string()]
);
assert!(tasks.get_matching("web:missing").unwrap().is_empty());
assert!(tasks.get_matching("missing:build").unwrap().is_empty());
let shadowed = BTreeMap::from([
("//web:build".to_string(), "//web:build".to_string()),
("web:build".to_string(), "web:build".to_string()),
]);
assert_eq!(
shadowed.get_matching("web:build").unwrap(),
vec![&"web:build".to_string()]
);
let flat = BTreeMap::from([("test:units".to_string(), "test:units".to_string())]);
assert!(flat.get_matching("web:build").unwrap().is_empty());
}
#[test]
fn test_get_matching_prefers_exact_over_extension_stripped() {
use std::collections::BTreeMap;
use super::GetMatchingExt;
let mut tasks: BTreeMap<String, String> = BTreeMap::new();
tasks.insert("hello".to_string(), "hello".to_string());
tasks.insert("hello.sh".to_string(), "hello.sh".to_string());
let matches = tasks.get_matching("hello").unwrap();
assert_eq!(matches, vec![&"hello".to_string()]);
let matches = tasks.get_matching("hello.sh").unwrap();
assert_eq!(matches, vec![&"hello.sh".to_string()]);
let mut only_file: BTreeMap<String, String> = BTreeMap::new();
only_file.insert("build.js".to_string(), "build.js".to_string());
let matches = only_file.get_matching("build").unwrap();
assert_eq!(matches, vec![&"build.js".to_string()]);
}
#[test]
fn test_get_matching_prefers_exact_monorepo() {
use std::collections::BTreeMap;
use super::GetMatchingExt;
let mut tasks: BTreeMap<String, String> = BTreeMap::new();
tasks.insert("//pkg:hello".to_string(), "//pkg:hello".to_string());
tasks.insert("//pkg:hello.sh".to_string(), "//pkg:hello.sh".to_string());
let matches = tasks.get_matching("//pkg:hello").unwrap();
assert_eq!(matches, vec![&"//pkg:hello".to_string()]);
let matches = tasks.get_matching("//pkg:hello.sh").unwrap();
assert_eq!(matches, vec![&"//pkg:hello.sh".to_string()]);
let mut only_file: BTreeMap<String, String> = BTreeMap::new();
only_file.insert(
"//pkg:migrate.sh".to_string(),
"//pkg:migrate.sh".to_string(),
);
let matches = only_file.get_matching("//pkg:migrate").unwrap();
assert_eq!(matches, vec![&"//pkg:migrate.sh".to_string()]);
}
#[test]
fn test_get_matching_trailing_ellipsis_includes_base_path() {
use std::collections::BTreeMap;
use super::GetMatchingExt;
let tasks = BTreeMap::from([
("//:test".to_string(), "//:test".to_string()),
("//apps/web:test".to_string(), "//apps/web:test".to_string()),
(
"//apps/web/e2e:test".to_string(),
"//apps/web/e2e:test".to_string(),
),
("//apps/api:test".to_string(), "//apps/api:test".to_string()),
]);
assert_eq!(
tasks.get_matching("//apps/web/...:test").unwrap(),
vec![
&"//apps/web/e2e:test".to_string(),
&"//apps/web:test".to_string(),
]
);
assert_eq!(
tasks.get_matching("//...:test").unwrap(),
vec![
&"//:test".to_string(),
&"//apps/api:test".to_string(),
&"//apps/web/e2e:test".to_string(),
&"//apps/web:test".to_string(),
]
);
assert_eq!(
tasks.get_matching("//apps/*/...:test").unwrap(),
vec![
&"//apps/api:test".to_string(),
&"//apps/web/e2e:test".to_string(),
&"//apps/web:test".to_string(),
]
);
}
#[test]
fn test_get_matching_resolves_aliases() {
use std::collections::BTreeMap;
use super::GetMatchingExt;
let mut tasks: BTreeMap<String, String> = BTreeMap::new();
tasks.insert("pr:remove".to_string(), "pr:remove".to_string());
tasks.insert("prr".to_string(), "pr:remove".to_string());
let matches = tasks.get_matching("prr").unwrap();
assert_eq!(matches, vec![&"pr:remove".to_string()]);
let matches = tasks.get_matching("pr:remove").unwrap();
assert_eq!(matches, vec![&"pr:remove".to_string()]);
}
#[test]
fn test_get_matching_workspace_task_ids() {
use std::collections::BTreeMap;
use super::GetMatchingExt;
let tasks = BTreeMap::from([
(
"node:@scope/app#build".to_string(),
"node:@scope/app#build".to_string(),
),
(
"node:@scope/app#test:unit".to_string(),
"node:@scope/app#test:unit".to_string(),
),
]);
assert!(
tasks
.get_matching("node:@scope/missing#build")
.unwrap()
.is_empty()
);
assert_eq!(
tasks.get_matching("node:@scope/app#test:*").unwrap(),
vec![&"node:@scope/app#test:unit".to_string()]
);
}
#[test]
fn test_get_matching_resolves_monorepo_aliases() {
use std::collections::BTreeMap;
use super::GetMatchingExt;
let mut tasks: BTreeMap<String, String> = BTreeMap::new();
tasks.insert("//:pr:remove".to_string(), "//:pr:remove".to_string());
tasks.insert("//:prr".to_string(), "//:pr:remove".to_string());
tasks.insert("prr".to_string(), "//:pr:remove".to_string());
let matches = tasks.get_matching("//:prr").unwrap();
assert_eq!(matches, vec![&"//:pr:remove".to_string()]);
let matches = tasks.get_matching("prr").unwrap();
assert_eq!(matches, vec![&"//:pr:remove".to_string()]);
let matches = tasks.get_matching("//:pr:remove").unwrap();
assert_eq!(matches, vec![&"//:pr:remove".to_string()]);
}
#[test]
fn test_estyled_prefix_no_red_or_yellow() {
let names = [
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india",
"juliet", "kilo", "lima", "mike", "november", "oscar", "papa", "build", "test", "lint",
"deploy", "clean", "start", "stop", "check",
];
let red_fg = "\x1b[31m";
let yellow_fg = "\x1b[33m";
let bright_red = "\x1b[38;5;9m";
let bright_yellow = "\x1b[38;5;11m";
for name in &names {
let task = Task {
display_name: name.to_string(),
..Default::default()
};
let styled = task.estyled_prefix();
assert!(
!styled.contains(red_fg),
"task {name:?} prefix contains red"
);
assert!(
!styled.contains(yellow_fg),
"task {name:?} prefix contains yellow"
);
assert!(
!styled.contains(bright_red),
"task {name:?} prefix contains bright red"
);
assert!(
!styled.contains(bright_yellow),
"task {name:?} prefix contains bright yellow"
);
}
}
}