use crate::Result;
use crate::config::Config;
use crate::file;
use crate::file::display_path;
use crate::shims::find_mise_shim_bin;
use crate::task::Task;
use eyre::{WrapErr, bail};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::env;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
#[derive(Debug, usage_rs::Args)]
#[usage(
verbatim_doc_comment,
example(
r###"mise tasks add test -- echo 'running tests'
mise generate task-stubs
./bin/test
running tests"###
)
)]
pub(super) struct TaskStubs {
#[usage(long, short, verbatim_doc_comment, default="bin", value_hint=ValueHint::DirPath)]
dir: PathBuf,
#[usage(long, short, verbatim_doc_comment, default = "mise")]
mise_bin: PathBuf,
#[usage(long, verbatim_doc_comment, value_enum, default = "cmd")]
windows_launcher: WindowsLauncher,
}
#[derive(Debug, Default, Clone, Copy, usage_rs::ValueEnum)]
enum WindowsLauncher {
#[default]
#[usage()]
Cmd,
#[usage()]
Exe,
}
impl TaskStubs {
pub(super) async fn run(self) -> eyre::Result<()> {
let config = Config::get().await?;
let launchers = Launchers::resolve(self.windows_launcher)?;
let tasks = config.tasks().await?;
let task_paths = tasks.values().map(Task::name_to_path).collect::<Vec<_>>();
let base_paths = stub_base_paths(&tasks, &task_paths);
let paths = resolve_stub_paths(&self.dir, &base_paths)?;
let stubs = tasks
.values()
.zip(task_paths)
.zip(paths)
.map(|((task, legacy_path), path)| {
Ok(TaskStub {
task,
legacy_path: self.dir.join(legacy_path),
path,
output: self.generate(task)?,
legacy_output: self.generate_legacy(task)?,
launcher: self.generate_launcher(task),
})
})
.collect::<Result<Vec<_>>>()?;
let migrations = validate_stub_paths(&self.dir, &stubs, &launchers)?;
for migration in migrations {
match migration {
StubMigration::File(path) => {
remove_generated_launcher(&path, &launchers)?;
file::remove_file(path)?
}
StubMigration::Directory(path) => file::remove_all(path)?,
}
}
if stubs.iter().any(|s| launchers.path(&s.path).is_some()) {
warn_if_windows_cannot_run(&self.mise_bin);
}
for stub in &stubs {
if let Some(parent) = stub.path.parent() {
file::create_dir_all(parent)?;
}
file::write(&stub.path, &stub.output)?;
file::make_executable(&stub.path)?;
miseprintln!("Wrote to {}", display_path(&stub.path));
if let Some(launcher_path) = launchers.path(&stub.path) {
let other = launchers.other_path(&stub.path);
remove_owned_launcher(&other, &launchers)?;
launchers.write(&launcher_path, &stub.launcher)?;
miseprintln!("Wrote to {}", display_path(&launcher_path));
warn_if_shadowed(&launcher_path, &other);
}
}
Ok(())
}
fn generate_launcher(&self, task: &Task) -> String {
let mise_bin = super::cmd_quote(&self.mise_bin.to_string_lossy());
let display_name = super::cmd_quote(&task.display_name);
super::windows_launcher_body(&format!("{mise_bin} run {display_name}"))
}
fn generate(&self, task: &Task) -> Result<String> {
let mise_bin = self.mise_bin.to_string_lossy();
let mise_bin = shell_words::quote(&mise_bin);
let display_name = &task.display_name;
let script = format!(
r#"
#!/bin/sh
# generated by mise task-stubs
exec {mise_bin} run {display_name} "$@"
"#
);
Ok(script.trim().to_string())
}
fn generate_legacy(&self, task: &Task) -> Result<String> {
let mise_bin = self.mise_bin.to_string_lossy();
let mise_bin = shell_words::quote(&mise_bin);
let display_name = &task.display_name;
let script = format!(
r#"
#!/bin/sh
exec {mise_bin} run {display_name} "$@"
"#
);
Ok(script.trim().to_string())
}
}
fn warn_if_windows_cannot_run(mise_bin: &Path) {
if windows_can_run(mise_bin) {
return;
}
warn!(
"{} is a path with no Windows launcher beside it, so the generated launchers cannot run it. \
Write one with `mise generate install-script --write {} --windows`, or drop --mise-bin to \
resolve mise off PATH.",
display_path(mise_bin),
mise_bin.display()
);
}
fn windows_can_run(mise_bin: &Path) -> bool {
if !mise_bin.to_string_lossy().contains(['/', '\\']) {
return true;
}
if windows_runnable_extension(mise_bin) {
return true;
}
let (Some(parent), Some(name)) = (mise_bin.parent(), mise_bin.file_name()) else {
return true;
};
let Ok(entries) = fs::read_dir(parent) else {
return false;
};
entries.filter_map(|e| e.ok()).any(|entry| {
let file_name = entry.file_name();
let sibling = Path::new(&file_name);
sibling
.file_stem()
.is_some_and(|stem| stem.eq_ignore_ascii_case(name))
&& windows_runnable_extension(sibling)
})
}
fn windows_runnable_extension(path: &Path) -> bool {
path.extension().is_some_and(|ext| {
["cmd", "bat", "exe"]
.iter()
.any(|known| ext.eq_ignore_ascii_case(known))
})
}
struct Launchers {
native: bool,
shim_bin: Option<PathBuf>,
}
impl Launchers {
fn resolve(kind: WindowsLauncher) -> Result<Self> {
let shim_bin = env::current_exe()
.ok()
.as_deref()
.and_then(find_mise_shim_bin);
let native = matches!(kind, WindowsLauncher::Exe);
if native && shim_bin.is_none() {
bail!(
"cannot write native task stub launchers: mise-shim.exe was not found next to this mise or on PATH. \
It ships with the Windows build of mise, so --windows-launcher=exe only works when generating on Windows."
);
}
Ok(Self { native, shim_bin })
}
fn path(&self, stub: &Path) -> Option<PathBuf> {
if self.native {
super::windows_exe_launcher_path(stub)
} else {
super::windows_launcher_path(stub)
}
}
fn other_path(&self, stub: &Path) -> Option<PathBuf> {
if self.native {
super::windows_launcher_path(stub)
} else {
super::windows_exe_launcher_path(stub)
}
}
fn write(&self, launcher: &Path, cmd_body: &str) -> Result<()> {
let Some(shim_bin) = self.shim_bin.as_ref().filter(|_| self.native) else {
return file::write(launcher, cmd_body);
};
fs::copy(shim_bin, launcher).wrap_err_with(|| {
format!(
"failed to copy {} to {}",
display_path(shim_bin),
display_path(launcher)
)
})?;
Ok(())
}
fn owns(&self, launcher: &Path) -> bool {
if !is_exe_path(launcher) {
return file::read_to_string(launcher)
.inspect_err(|err| debug!("keeping {}: {err}", display_path(launcher)))
.is_ok_and(|contents| super::is_generated_launcher(&contents));
}
let Some(shim_bin) = &self.shim_bin else {
return false;
};
file_contents_eq(shim_bin, launcher).unwrap_or_else(|err| {
debug!("keeping {}: {err}", display_path(launcher));
false
})
}
}
fn is_exe_path(path: &Path) -> bool {
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("exe"))
}
fn file_contents_eq(a: &Path, b: &Path) -> Result<bool> {
if fs::metadata(a)?.len() != fs::metadata(b)?.len() {
return Ok(false);
}
Ok(fs::read(a)? == fs::read(b)?)
}
fn warn_if_shadowed(written: &Path, other: &Option<PathBuf>) {
let Some(other) = other else { return };
if is_exe_path(written) || !is_exe_path(other) || !other.is_file() {
return;
}
warn!(
"{} is still there and mise cannot show it wrote it, so Windows will run it instead of {}. Remove it by hand, or regenerate on Windows.",
display_path(other),
display_path(written)
);
}
fn remove_owned_launcher(launcher: &Option<PathBuf>, launchers: &Launchers) -> Result<()> {
let Some(launcher) = launcher else {
return Ok(());
};
if !fs::symlink_metadata(launcher).is_ok_and(|m| m.file_type().is_file()) {
return Ok(());
}
if launchers.owns(launcher) {
file::remove_file(launcher)?;
}
Ok(())
}
struct TaskStub<'a> {
task: &'a Task,
legacy_path: PathBuf,
path: PathBuf,
output: String,
legacy_output: String,
launcher: String,
}
fn remove_generated_launcher(stub_path: &Path, launchers: &Launchers) -> Result<()> {
remove_owned_launcher(&super::windows_launcher_path(stub_path), launchers)?;
remove_owned_launcher(&super::windows_exe_launcher_path(stub_path), launchers)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum StubMigration {
File(PathBuf),
Directory(PathBuf),
}
fn stub_base_paths(tasks: &BTreeMap<String, Task>, task_paths: &[PathBuf]) -> Vec<PathBuf> {
let display_paths = tasks
.values()
.map(Task::display_name_to_path)
.collect::<Vec<_>>();
let mut counts: HashMap<&PathBuf, usize> = HashMap::new();
for path in &display_paths {
*counts.entry(path).or_default() += 1;
}
display_paths
.iter()
.zip(task_paths)
.map(|(display_path, task_path)| {
if counts.get(display_path).copied().unwrap_or_default() > 1 {
task_path.clone()
} else {
display_path.clone()
}
})
.collect()
}
fn resolve_stub_paths(dir: &Path, task_paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
let base_paths = task_paths
.iter()
.map(|path| dir.join(path))
.collect::<Vec<_>>();
let paths = base_paths
.iter()
.enumerate()
.map(|(index, path)| {
if base_paths.iter().enumerate().any(|(other_index, other)| {
index != other_index && other != path && other.starts_with(path)
}) {
path.join("_default")
} else {
path.clone()
}
})
.collect::<Vec<_>>();
let mut seen = HashSet::new();
for path in &paths {
if !seen.insert(path) {
bail!(
"multiple tasks map to task stub path {}",
display_path(path)
);
}
}
Ok(paths)
}
fn validate_stub_paths(
dir: &Path,
stubs: &[TaskStub<'_>],
launchers: &Launchers,
) -> Result<Vec<StubMigration>> {
let mut migrations = HashSet::new();
for stub in stubs.iter().filter(|stub| stub.legacy_path != stub.path) {
match fs::symlink_metadata(&stub.legacy_path) {
Ok(metadata) if metadata.file_type().is_file() => {
let existing = file::read_to_string(&stub.legacy_path)?;
if existing != stub.output && existing != stub.legacy_output {
bail!(
"cannot create nested task stubs because {} is not the generated stub for task {}",
display_path(&stub.legacy_path),
stub.task.display_name
);
}
migrations.insert(StubMigration::File(stub.legacy_path.clone()));
}
Ok(metadata) if metadata.file_type().is_dir() => {}
Ok(_) => bail!(
"cannot create nested task stubs because {} is not a directory",
display_path(&stub.legacy_path)
),
Err(err) if matches!(err.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {}
Err(err) => return Err(err.into()),
}
}
for stub in stubs {
match fs::symlink_metadata(&stub.path) {
Ok(metadata) if metadata.file_type().is_dir() => {
validate_generated_stub_directory(&stub.path, &stub.output, stub.task, launchers)?;
migrations.insert(StubMigration::Directory(stub.path.clone()));
}
Ok(metadata) if metadata.file_type().is_symlink() => bail!(
"cannot write task stub because {} is a symbolic link",
display_path(&stub.path)
),
Ok(metadata) if metadata.file_type().is_file() => {
let existing = file::read_to_string(&stub.path)?;
let legacy_leaf = stub.legacy_path == stub.path && existing == stub.legacy_output;
if existing != stub.output && !legacy_leaf {
bail!(
"cannot write task stub because {} is not a generated task stub",
display_path(&stub.path)
);
}
}
Ok(_) => bail!(
"cannot write task stub because {} is not a regular file",
display_path(&stub.path)
),
Err(err) if matches!(err.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {}
Err(err) => return Err(err.into()),
}
validate_launcher_path(stub, launchers)?;
for parent in stub.path.ancestors().skip(1) {
match fs::symlink_metadata(parent) {
Ok(metadata)
if metadata.file_type().is_dir()
|| migrations.contains(&StubMigration::File(parent.to_path_buf())) => {}
Ok(_) => bail!(
"cannot create task stub directory because {} is not a directory",
display_path(parent)
),
Err(err)
if matches!(err.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {}
Err(err) => return Err(err.into()),
}
if parent == dir {
break;
}
}
}
Ok(migrations.into_iter().collect())
}
fn validate_launcher_path(stub: &TaskStub<'_>, launchers: &Launchers) -> Result<()> {
let Some(launcher) = launchers.path(&stub.path) else {
return Ok(());
};
match fs::symlink_metadata(&launcher) {
Ok(metadata) if metadata.file_type().is_file() => {
if !launchers.owns(&launcher) {
bail!(
"cannot write Windows launcher because {} is not a generated launcher",
display_path(&launcher)
);
}
}
Ok(_) => bail!(
"cannot write Windows launcher because {} is not a regular file",
display_path(&launcher)
),
Err(err) if matches!(err.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {}
Err(err) => return Err(err.into()),
}
Ok(())
}
fn validate_generated_stub_directory(
path: &Path,
expected: &str,
task: &Task,
launchers: &Launchers,
) -> Result<()> {
let default = path.join("_default");
match fs::symlink_metadata(&default) {
Ok(metadata)
if metadata.file_type().is_file() && file::read_to_string(&default)? == expected => {}
_ => bail!(
"cannot replace task stub directory because {} does not contain the generated stub for task {}",
display_path(path),
task.display_name
),
}
validate_generated_stub_tree(path, launchers)?;
Ok(())
}
fn validate_generated_stub_tree(path: &Path, launchers: &Launchers) -> Result<usize> {
let mut files = 0;
for entry in fs::read_dir(path)? {
let entry = entry?;
let entry_path = entry.path();
let metadata = fs::symlink_metadata(&entry_path)?;
if metadata.file_type().is_dir() {
let child_files = validate_generated_stub_tree(&entry_path, launchers)?;
if child_files == 0 {
bail!(
"cannot replace task stub directory because {} is empty",
display_path(&entry_path)
);
}
files += child_files;
} else if metadata.file_type().is_file() && is_exe_path(&entry_path) {
if !launchers.owns(&entry_path) {
bail!(
"cannot replace task stub directory because {} is not a generated task stub",
display_path(&entry_path)
);
}
} else if metadata.file_type().is_file()
&& is_generated_task_stub(&file::read_to_string(&entry_path)?)
{
files += 1;
} else if metadata.file_type().is_file()
&& super::is_generated_launcher(&file::read_to_string(&entry_path)?)
{
} else {
bail!(
"cannot replace task stub directory because {} is not a generated task stub",
display_path(&entry_path)
);
}
}
Ok(files)
}
fn is_generated_task_stub(contents: &str) -> bool {
let mut lines = contents.lines();
matches!(lines.next(), Some("#!/bin/sh"))
&& matches!(lines.next(), Some("# generated by mise task-stubs"))
&& lines
.next()
.and_then(|line| line.strip_prefix("exec "))
.and_then(|line| line.strip_suffix(" \"$@\""))
.is_some_and(|line| line.contains(" run "))
&& lines.next().is_none()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_parent_and_nested_task_paths() {
let paths = resolve_stub_paths(
Path::new("bin"),
&[
PathBuf::from("foo"),
PathBuf::from("foo/bar"),
PathBuf::from("foo/bar/baz"),
PathBuf::from("foobar"),
],
)
.unwrap();
assert_eq!(
paths,
[
PathBuf::from("bin/foo/_default"),
PathBuf::from("bin/foo/bar/_default"),
PathBuf::from("bin/foo/bar/baz"),
PathBuf::from("bin/foobar"),
]
);
}
fn launchers(native: bool, shim_bin: Option<&Path>) -> Launchers {
Launchers {
native,
shim_bin: shim_bin.map(Path::to_path_buf),
}
}
#[test]
fn each_mode_writes_its_own_form_and_clears_the_other() {
let stub = Path::new("bin/hello");
let cmd = launchers(false, None);
assert_eq!(cmd.path(stub), Some(PathBuf::from("bin/hello.cmd")));
assert_eq!(cmd.other_path(stub), Some(PathBuf::from("bin/hello.exe")));
let exe = launchers(true, Some(Path::new("mise-shim.exe")));
assert_eq!(exe.path(stub), Some(PathBuf::from("bin/hello.exe")));
assert_eq!(exe.other_path(stub), Some(PathBuf::from("bin/hello.cmd")));
}
#[test]
fn a_native_launcher_is_owned_only_while_it_is_still_the_copy() {
let dir = tempfile::tempdir().unwrap();
let shim_bin = dir.path().join("mise-shim.exe");
fs::write(&shim_bin, b"\x4d\x5aPRETEND-BINARY").unwrap();
let launchers = launchers(true, Some(&shim_bin));
let ours = dir.path().join("hello.exe");
fs::copy(&shim_bin, &ours).unwrap();
assert!(launchers.owns(&ours));
let theirs = dir.path().join("theirs.exe");
fs::write(&theirs, b"\x4d\x5aPRETEND-BINARZ").unwrap();
assert!(!launchers.owns(&theirs));
let shorter = dir.path().join("shorter.exe");
fs::write(&shorter, b"\x4d\x5aPRETEND-BINAR").unwrap();
assert!(!launchers.owns(&shorter));
}
#[test]
fn without_a_shim_binary_no_exe_is_ours() {
let dir = tempfile::tempdir().unwrap();
let stray = dir.path().join("hello.exe");
fs::write(&stray, b"anything").unwrap();
assert!(!launchers(false, None).owns(&stray));
}
#[test]
fn a_cmd_launcher_is_still_judged_by_its_marker() {
let dir = tempfile::tempdir().unwrap();
let launchers = launchers(false, None);
let ours = dir.path().join("hello.cmd");
fs::write(&ours, super::super::windows_launcher_body("mise run hello")).unwrap();
assert!(launchers.owns(&ours));
let theirs = dir.path().join("theirs.cmd");
fs::write(&theirs, "@echo off\r\nmise run hello %*\r\n").unwrap();
assert!(!launchers.owns(&theirs));
}
#[test]
fn a_native_launcher_that_cannot_be_read_is_not_ours() {
let dir = tempfile::tempdir().unwrap();
let shim_bin = dir.path().join("mise-shim.exe");
fs::write(&shim_bin, b"\x4d\x5aPRETEND-BINARY").unwrap();
let with_shim = launchers(true, Some(&shim_bin));
let gone = dir.path().join("gone.exe");
assert!(!with_shim.owns(&gone));
remove_owned_launcher(&Some(gone), &with_shim).unwrap();
let missing_shim = launchers(true, Some(&dir.path().join("no-shim.exe")));
let ours = dir.path().join("ours.exe");
fs::copy(&shim_bin, &ours).unwrap();
assert!(!missing_shim.owns(&ours));
remove_owned_launcher(&Some(ours.clone()), &missing_shim).unwrap();
assert!(ours.exists());
}
#[test]
fn a_launcher_that_cannot_be_read_is_not_ours() {
let dir = tempfile::tempdir().unwrap();
let launchers = launchers(false, None);
let theirs = dir.path().join("theirs.cmd");
fs::write(&theirs, b"@echo off\r\necho \x93hi\x94\r\n").unwrap();
assert!(!launchers.owns(&theirs));
remove_owned_launcher(&Some(theirs.clone()), &launchers).unwrap();
assert!(theirs.exists());
}
#[test]
fn removing_a_launcher_leaves_what_is_not_ours() {
let dir = tempfile::tempdir().unwrap();
let launchers = launchers(false, None);
let ours = dir.path().join("hello.cmd");
fs::write(&ours, super::super::windows_launcher_body("mise run hello")).unwrap();
remove_owned_launcher(&Some(ours.clone()), &launchers).unwrap();
assert!(!ours.exists());
let theirs = dir.path().join("theirs.cmd");
fs::write(&theirs, "@echo off\r\necho mine\r\n").unwrap();
remove_owned_launcher(&Some(theirs.clone()), &launchers).unwrap();
assert!(theirs.exists());
let missing = dir.path().join("missing.cmd");
remove_owned_launcher(&Some(missing), &launchers).unwrap();
remove_owned_launcher(&None, &launchers).unwrap();
}
#[test]
fn a_bare_mise_bin_needs_nothing_beside_it() {
for bin in ["mise", "mise.exe"] {
assert!(windows_can_run(Path::new(bin)), "{bin}");
}
}
#[test]
fn a_windows_spelled_path_is_a_path_on_every_host() {
let dir = tempfile::tempdir().unwrap();
assert!(!windows_can_run(&dir.path().join(".\\bin\\mise")));
}
#[test]
fn a_path_mise_bin_needs_something_windows_can_run() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("bin");
fs::create_dir_all(&bin).unwrap();
let script = bin.join("mise");
fs::write(&script, "#!/usr/bin/env bash\n").unwrap();
assert!(!windows_can_run(&script));
fs::write(bin.join("mise.cmd"), "@echo off\r\n").unwrap();
assert!(windows_can_run(&script));
for name in ["other.exe", "other.CMD", "other.bat"] {
assert!(windows_can_run(&bin.join(name)), "{name}");
}
}
#[test]
fn a_sibling_is_matched_the_way_windows_matches_it() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("mise");
fs::write(&script, "#!/usr/bin/env bash\n").unwrap();
fs::write(dir.path().join("mise.CMD"), "@echo off\r\n").unwrap();
assert!(windows_can_run(&script));
}
#[test]
fn a_sibling_that_is_not_there_does_not_count() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("bin");
fs::create_dir_all(&bin).unwrap();
fs::write(bin.join("mise"), "#!/usr/bin/env bash\n").unwrap();
fs::write(bin.join("mise.txt"), "notes\n").unwrap();
assert!(!windows_can_run(&bin.join("mise")));
assert!(!windows_can_run(&dir.path().join("nested").join("mise")));
}
#[test]
fn rejects_duplicate_resolved_paths() {
let err = resolve_stub_paths(
Path::new("bin"),
&[PathBuf::from("foo"), PathBuf::from("foo/_default")],
)
.unwrap_err();
let message = err.to_string();
assert!(message.contains("multiple tasks map to task stub path"));
assert!(message.contains("_default"));
}
}