Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
184 changes: 184 additions & 0 deletions e2e/tasks/test_task_templates
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
#!/usr/bin/env bash

# Test task templates feature (requires experimental = true)

# First, test that extends fails without experimental flag
cat <<'EOF' >mise.toml
[task_templates."python:build"]
run = "echo building python"
description = "Build a Python project"

[tasks.build]
extends = "python:build"
EOF

# Should fail without experimental mode (override the env var)
assert_fail "MISE_EXPERIMENTAL=0 mise run build" "experimental = true"

# Now enable experimental mode and test basic template functionality
cat <<'EOF' >mise.toml
[settings]
experimental = true

[task_templates."python:build"]
run = "echo building python"
description = "Build a Python project"

[tasks.build]
extends = "python:build"
EOF

# Basic template extension should work
assert "mise run build" "building python"

# Test local override of run command
cat <<'EOF' >mise.toml
[settings]
experimental = true

[task_templates."python:build"]
run = "echo template build"
description = "Template description"

[tasks.build]
extends = "python:build"
run = "echo local build"
EOF

assert "mise run build" "local build"

# Test tools deep merge
cat <<'EOF' >mise.toml
[settings]
experimental = true

[task_templates."python:build"]
run = "echo tools: python={{ env.MISE_TOOL_OPTS_PYTHON | default(value='none') }} node={{ env.MISE_TOOL_OPTS_NODE | default(value='none') }}"
tools = { python = "3.12", node = "18" }

[tasks.build]
extends = "python:build"
tools = { node = "20" } # Override node version, keep python from template
EOF

# The tools should be merged - task list should show both tools
assert_contains "mise tasks build" "python"
assert_contains "mise tasks build" "node"

# Test env deep merge
cat <<'EOF' >mise.toml
[settings]
experimental = true

[task_templates."python:build"]
run = "echo FOO=$FOO BAR=$BAR"
env = { FOO = "template_foo", BAR = "template_bar" }

[tasks.build]
extends = "python:build"
env = { FOO = "local_foo" } # Override FOO, keep BAR from template
EOF

assert "mise run build" "FOO=local_foo BAR=template_bar"

# Test description from template (when local is empty)
cat <<'EOF' >mise.toml
[settings]
experimental = true

[task_templates."python:build"]
run = "echo build"
description = "Template description"

[tasks.build]
extends = "python:build"
EOF

assert_contains "mise tasks build" "Template description"

# Test local description overrides template
cat <<'EOF' >mise.toml
[settings]
experimental = true

[task_templates."python:build"]
run = "echo build"
description = "Template description"

[tasks.build]
extends = "python:build"
description = "Local description"
EOF

assert_contains "mise tasks build" "Local description"
assert_not_contains "mise tasks build" "Template description"

# Test depends from template (using task name, not :prefix syntax)
cat <<'EOF' >mise.toml
[settings]
experimental = true

[task_templates."python:test"]
run = "echo testing"
depends = ["build"]

[tasks.build]
run = "echo building"

[tasks.test]
extends = "python:test"
EOF

assert "mise run test" "building
testing"

# Test depends local override (local takes precedence completely)
cat <<'EOF' >mise.toml
[settings]
experimental = true

[task_templates."python:test"]
run = "echo testing"
depends = ["prep"]

[tasks.prep]
run = "echo prep"

[tasks.lint]
run = "echo linting"

[tasks.test]
extends = "python:test"
depends = ["lint"] # Override, should NOT also run prep
EOF

assert "mise run test" "linting
testing"
assert_not_contains "mise run test" "prep"

# Test template not found error
cat <<'EOF' >mise.toml
[settings]
experimental = true

[tasks.build]
extends = "nonexistent:template"
EOF

assert_fail "mise run build" "not found"

# Test namespaced template names with colons
cat <<'EOF' >mise.toml
[settings]
experimental = true

[task_templates."rust:cargo:build"]
run = "echo cargo build"

[tasks.build]
extends = "rust:cargo:build"
EOF

assert "mise run build" "cargo build"

echo '' >mise.toml

Copilot AI Jan 28, 2026

Copy link

Choose a reason for hiding this comment

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

The test cleanup at the end uses echo '' >mise.toml which creates a file with a newline. Consider using rm -f mise.toml or true >mise.toml for cleaner test isolation, as the empty file with a newline could affect subsequent tests.

Suggested change
echo '' >mise.toml
rm -f mise.toml

Copilot uses AI. Check for mistakes.
19 changes: 15 additions & 4 deletions src/cli/generate/task_docs.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
use crate::config::Config;
use crate::config::{self, Config, Settings};
use crate::{dirs, file};
use indexmap::IndexMap;
use std::path::PathBuf;

use crate::config;

/// Generate documentation for tasks in a project
#[derive(Debug, clap::Args)]
#[clap(verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)]
Expand Down Expand Up @@ -44,7 +43,19 @@ impl TaskDocs {
pub async fn run(self) -> eyre::Result<()> {
let config = Config::get().await?;
let dir = dirs::CWD.as_ref().unwrap();
let tasks = config::load_tasks_in_dir(&config, dir, &config.config_files).await?;
// Collect task templates from config hierarchy
let templates = if Settings::get().experimental {
config
.config_files
.values()
.rev()
.flat_map(|cf| cf.task_templates())
.collect()
} else {
IndexMap::new()
};
let tasks =
config::load_tasks_in_dir(&config, dir, &config.config_files, &templates).await?;
let mut out = vec![];
for task in tasks.iter().filter(|t| !t.hide) {
out.push(task.render_markdown(&config).await?);
Expand Down
42 changes: 41 additions & 1 deletion src/config/config_file/mise_toml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ use crate::hooks::{Hook, Hooks};
use crate::prepare::PrepareConfig;
use crate::redactions::Redactions;
use crate::registry::REGISTRY;
use crate::task::Task;
use crate::task::{Task, TaskTemplate};
use crate::tera::{BASE_CONTEXT, get_tera};
use crate::toolset::{ToolRequest, ToolRequestSet, ToolSource, ToolVersionOptions};
use crate::watch_files::WatchFile;
Expand Down Expand Up @@ -74,6 +74,8 @@ pub struct MiseToml {
#[serde(default)]
tasks: Tasks,
#[serde(default)]
task_templates: TaskTemplates,
#[serde(default)]
watch_files: Vec<WatchFile>,
#[serde(default)]
prepare: Option<PrepareConfig>,
Expand Down Expand Up @@ -101,6 +103,9 @@ pub struct MiseTomlTool {
#[derive(Debug, Default, Clone)]
pub struct Tasks(pub BTreeMap<String, Task>);

#[derive(Debug, Default, Clone)]
pub struct TaskTemplates(pub IndexMap<String, TaskTemplate>);

#[derive(Debug, Default, Clone)]
pub struct EnvList(pub(crate) Vec<EnvDirective>);

Expand Down Expand Up @@ -508,6 +513,10 @@ impl ConfigFile for MiseToml {
self.tasks.0.values().collect()
}

fn task_templates(&self) -> IndexMap<String, TaskTemplate> {
self.task_templates.0.clone()
}

fn remove_tool(&self, fa: &BackendArg) -> eyre::Result<()> {
let mut tools = self.tools.lock().unwrap();
tools.shift_remove(fa);
Expand Down Expand Up @@ -865,6 +874,7 @@ impl Clone for MiseToml {
redactions: self.redactions.clone(),
plugins: self.plugins.clone(),
tasks: self.tasks.clone(),
task_templates: self.task_templates.clone(),

Copilot AI Jan 28, 2026

Copy link

Choose a reason for hiding this comment

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

The task_templates field in the Clone implementation lacks test coverage. Consider adding a test that verifies cloning a MiseToml instance with task templates preserves the templates correctly.

Copilot uses AI. Check for mistakes.
task_config: self.task_config.clone(),
settings: self.settings.clone(),
watch_files: self.watch_files.clone(),
Expand Down Expand Up @@ -1717,6 +1727,36 @@ impl<'de> de::Deserialize<'de> for Tasks {
}
}

impl<'de> de::Deserialize<'de> for TaskTemplates {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
struct TaskTemplatesVisitor;

impl<'de> Visitor<'de> for TaskTemplatesVisitor {
type Value = TaskTemplates;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter.write_str("map of task template names to template definitions")
}

fn visit_map<M>(self, mut map: M) -> std::result::Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
let mut templates = IndexMap::new();
while let Some(name) = map.next_key::<String>()? {
let template: TaskTemplate = map.next_value()?;
templates.insert(name, template);
}
Ok(TaskTemplates(templates))
}
}

deserializer.deserialize_any(TaskTemplatesVisitor)
}
}

impl<'de> de::Deserialize<'de> for BackendArg {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
Expand Down
6 changes: 5 additions & 1 deletion src/config/config_file/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use crate::hash::hash_to_str;
use crate::hooks::Hook;
use crate::prepare::PrepareConfig;
use crate::redactions::Redactions;
use crate::task::Task;
use crate::task::{Task, TaskTemplate};
use crate::toolset::{ToolRequest, ToolRequestSet, ToolSource, ToolVersionList, Toolset};
use crate::ui::{prompt, style};
use crate::watch_files::WatchFile;
Expand Down Expand Up @@ -110,6 +110,10 @@ pub trait ConfigFile: Debug + Send + Sync {
&DEFAULT_TASK_CONFIG
}

fn task_templates(&self) -> IndexMap<String, TaskTemplate> {
IndexMap::new()
}

fn experimental_monorepo_root(&self) -> Option<bool> {
None
}
Expand Down
Loading
Loading