Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions e2e/tasks/test_task_usage_cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env bash

# Test usage `cmd` (subcommand) support in task definitions

# Basic subcommand routing with env vars ($usage_cmd, $usage_*)
cat <<'EOF' >mise.toml
[tasks.deploy]
description = "Deploy task with subcommands"
usage = '''
cmd "staging" {
arg "<version>"
flag "--dry-run"
}
cmd "production" {
arg "<version>"
flag "--approve"
}
'''
run = '''
echo "cmd=$usage_cmd version=$usage_version"
'''
EOF

assert "mise run deploy staging v1.0" "cmd=staging version=v1.0"
assert "mise run deploy production v2.0" "cmd=production version=v2.0"

# Subcommand with Tera templates ({{ usage.cmd }}, {{ usage.* }})
cat <<'EOF' >mise.toml
[tasks.manage]
description = "Manage resources"
usage = '''
cmd "create" {
arg "<name>"
flag "--type <type>" default="default"
}
cmd "delete" {
arg "<name>"
flag "--force"
}
'''
run = '''
echo "action={{ usage.cmd }} name={{ usage.name }} type={{ usage.type }}"
{% if usage.force %}
echo "force=true"
{% endif %}
'''
EOF

assert "mise run manage create myresource" "action=create name=myresource type=default"
assert "mise run manage create myresource --type custom" "action=create name=myresource type=custom"
assert "mise run manage delete myresource --force" "\
action=delete name=myresource type=default
force=true"

# Subcommand with alias
cat <<'EOF' >mise.toml
[tasks.db]
description = "Database operations"
usage = '''
cmd "migrate" {
alias "m"
flag "--seed"
}
'''
run = '''
echo "cmd=$usage_cmd"
'''
EOF

assert "mise run db migrate" "cmd=migrate"
assert "mise run db m" "cmd=migrate"

# No subcommand selected β€” usage.cmd defaults to empty
cat <<'EOF' >mise.toml
[tasks.tool]
description = "Tool with optional subcommand"
usage = '''
arg "<input>"
cmd "check" {
arg "<target>"
}
'''
run = '''
echo "cmd={{ usage.cmd }} input={{ usage.input }}"
'''
EOF

assert "mise run tool hello" "cmd= input=hello"

# Mixed: root-level arg + subcommand with its own arg
cat <<'EOF' >mise.toml
[tasks.mixed]
description = "Root args and subcommands together"
usage = '''
arg "<env>"
cmd "deploy" {
arg "<version>"
}
cmd "rollback" {
arg "<version>"
}
'''
run = '''
echo "cmd=$usage_cmd env=$usage_env version=$usage_version"
'''
EOF

assert "mise run mixed staging" "cmd= env=staging version="
assert "mise run mixed deploy v1.0" "cmd=deploy env= version=v1.0"

# File-based task with #USAGE cmd
mkdir -p mise-tasks
cat <<'SCRIPT' >mise-tasks/runner
#!/usr/bin/env bash
#USAGE cmd "lint" {
#USAGE arg "<path>"
#USAGE }
#USAGE cmd "test" {
#USAGE arg "<path>"
#USAGE flag "--coverage"
#USAGE }

echo "cmd=$usage_cmd path=$usage_path"
SCRIPT
chmod +x mise-tasks/runner

assert "mise run runner lint src/" "cmd=lint path=src/"
assert "mise run runner test src/ --coverage" "cmd=test path=src/"
Comment thread
nkakouros marked this conversation as resolved.

rm -rf mise-tasks

# Root-level flags with subcommands require global=#true
cat <<'EOF' >mise.toml
[tasks.build]
description = "Build with parallelism"
usage = '''
flag "-p --parallel <parallel>" default="1" global=#true
cmd "debug" {
arg "<target>"
}
cmd "release" {
arg "<target>"
}
'''
run = 'echo "cmd=$usage_cmd target=$usage_target parallel=$usage_parallel"'
EOF

assert "mise run build debug myapp --parallel 4" "cmd=debug target=myapp parallel=4"
assert "mise run build debug myapp -p 4" "cmd=debug target=myapp parallel=4"
assert "mise run build debug myapp -p4" "cmd=debug target=myapp parallel=4"
assert "mise run build -p 4 release myapp" "cmd=release target=myapp parallel=4"
assert "mise run build debug myapp" "cmd=debug target=myapp parallel=1"
88 changes: 88 additions & 0 deletions e2e/tasks/test_task_usage_map_tera
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,91 @@ dirname=/path/to
basename=file.txt
extname=txt
file_stem=file"

# Count flag renders as integer (e.g. -vvv -> 3)
cat <<'EOF' >mise.toml
[tasks.usage-tera-count]
description = "Test count flag renders as integer"
usage = '''
flag "-v --verbose" count=#true
'''
run = '''
echo "verbosity={{ usage.verbose }}"
'''
EOF

assert "mise run usage-tera-count" "verbosity=0"
assert "mise run usage-tera-count -v" "verbosity=1"
assert "mise run usage-tera-count -vvv" "verbosity=3"

# Negate flag (--no-color sets to false, default is true)
cat <<'EOF' >mise.toml
[tasks.usage-tera-negate]
description = "Test negate flag"
usage = '''
flag "--color" negate="--no-color" default=#true
'''
run = '''
echo "color={{ usage.color }}"
'''
Comment thread
greptile-apps[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 -- Alice double-dash separator is unnecessary here

The -- end-of-options separator is only needed when a positional value could be misinterpreted as a flag (e.g. a value starting with -). Alice is a plain string that would match [name] directly without the separator.

Using -- Alice here may mislead task authors into thinking the separator is always required for optional args, when the corresponding unit test in test_usage_arg_defaults_for_unprovided already passes &["x"] without one.

Suggested change
'''
assert "mise run usage-tera-opt-arg Alice" "hello Alice"

EOF

assert "mise run usage-tera-negate" "color=true"
assert "mise run usage-tera-negate --color" "color=true"
assert "mise run usage-tera-negate --no-color" "color=false"

# Boolean flag defaults to false when not provided (unprovided flag in template)
cat <<'EOF' >mise.toml
[tasks.usage-tera-bool-default]
description = "Test boolean flag defaults to false"
usage = '''
flag "-f --force"
'''
run = '''
{% if usage.force %}
echo "forced"
{% else %}
echo "not forced"
{% endif %}
'''
EOF

assert "mise run usage-tera-bool-default" "not forced"
assert "mise run usage-tera-bool-default --force" "forced"

# Optional arg defaults to empty string when not provided
cat <<'EOF' >mise.toml
[tasks.usage-tera-opt-arg]
description = "Test optional arg defaults to empty"
usage = '''
arg "[name]"
'''
run = '''
{% if usage.name %}
echo "hello {{ usage.name }}"
{% else %}
echo "hello world"
{% endif %}
'''
EOF

assert "mise run usage-tera-opt-arg" "hello world"
assert "mise run usage-tera-opt-arg -- Alice" "hello Alice"

# Arg choices restrict allowed values
cat <<'EOF' >mise.toml
[tasks.usage-tera-arg-choices]
description = "Test arg choices"
usage = '''
arg "<shell>" {
choices "bash" "zsh" "fish"
}
'''
run = '''
echo "shell={{ usage.shell }}"
'''
EOF

assert "mise run usage-tera-arg-choices bash" "shell=bash"
assert "mise run usage-tera-arg-choices zsh" "shell=zsh"
assert_fail "mise run usage-tera-arg-choices powershell"
17 changes: 15 additions & 2 deletions src/task/task_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::task::task_context_builder::TaskContextBuilder;
use crate::task::task_list::split_task_spec;
use crate::task::task_output::{TaskOutput, trunc};
use crate::task::task_output_handler::OutputHandler;
use crate::task::task_script_parser::subcommand_name_from_parse;
use crate::task::task_source_checker::{save_checksum, sources_are_fresh, task_cwd};
use crate::task::{Deps, FailedTasks, GetMatchingExt, Task};
use crate::toolset::env_cache::CachedEnv;
Expand Down Expand Up @@ -941,7 +942,10 @@ impl TaskExecutor {
let (spec, _) = task
.parse_usage_spec_with_vars(config, self.cd.clone(), env, extra_vars)
.await?;
if !spec.cmd.args.is_empty() || !spec.cmd.flags.is_empty() {
if !spec.cmd.args.is_empty()
|| !spec.cmd.flags.is_empty()
|| !spec.cmd.subcommands.is_empty()
{
let args: Vec<String> = get_args();
trace!("Parsing usage spec for {:?}", args);
// Pass env vars to Parser so it can resolve env= defaults in usage specs
Expand All @@ -955,8 +959,17 @@ impl TaskExecutor {
trace!("Adding key {} value {} in env", k, v);
env.insert(k, v);
}
// always export $usage_cmd when spec has subcommands so
// shell scripts with `set -u` don't fail when none is chosen
if !spec.cmd.subcommands.is_empty() {
env.entry("usage_cmd".to_string()).or_default();
}
if let Some(subcmd) = subcommand_name_from_parse(&po.cmds) {
trace!("Adding key usage_cmd value {} in env", subcmd);
env.insert("usage_cmd".to_string(), subcmd);
}
Comment on lines +962 to +970

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 $usage_cmd guard not extended to subcommand-scoped args/flags

The fix at lines 964–966 ensures $usage_cmd is always exported as an empty string when the spec has subcommands (protecting shell scripts that use set -u). However, the same protection is not applied to the args and flags that belong to those subcommands.

Consider the mixed task in the e2e test (lines 90–115 of test_task_usage_cmd):

  • Running mise run mixed staging selects the root arg <env> but no subcommand, so $usage_version (a subcommand arg) is never exported by po.as_env().
  • Running mise run mixed deploy v1.0 selects the deploy subcommand, so $usage_env (the root-level arg) is never exported.

Any task script that uses set -u (or set -euo pipefail) will crash with "unbound variable" in these cases. The e2e assertions like "cmd= env=staging version=" pass today only because bash silently expands unset variables to empty without set -u.

A defensive fix (similar to the usage_cmd guard) would iterate over the spec's args/flags across all subcommands and ensure they are pre-inserted with empty defaults before po.as_env() runs β€” or at a minimum document that set -u is not supported when mixing root-level args with subcommands.

} else {
trace!("Usage spec has no args or flags");
trace!("Usage spec has no args, flags, or subcommands");
}
Comment thread
nkakouros marked this conversation as resolved.

Ok(())
Expand Down
Loading
Loading