use crate::Result;
use crate::config::Config;
use crate::file;
use crate::file::display_path;
use crate::task::Task;
use eyre::bail;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
#[derive(Debug, usage_rs::Args)]
#[usage(verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)]
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,
}
impl TaskStubs {
pub(super) async fn run(self) -> eyre::Result<()> {
let config = Config::get().await?;
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)?;
for migration in migrations {
match migration {
StubMigration::File(path) => {
remove_generated_launcher(&path)?;
file::remove_file(path)?
}
StubMigration::Directory(path) => file::remove_all(path)?,
}
}
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) = super::windows_launcher_path(&stub.path) {
file::write(&launcher_path, &stub.launcher)?;
miseprintln!("Wrote to {}", display_path(&launcher_path));
}
}
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())
}
}
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) -> Result<()> {
let Some(launcher) = super::windows_launcher_path(stub_path) else {
return Ok(());
};
let Ok(existing) = file::read_to_string(&launcher) else {
return Ok(());
};
if super::is_generated_launcher(&existing) {
file::remove_file(&launcher)?;
}
Ok(())
}
#[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<'_>]) -> 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)?;
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)?;
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<'_>) -> Result<()> {
let Some(launcher) = super::windows_launcher_path(&stub.path) else {
return Ok(());
};
match fs::symlink_metadata(&launcher) {
Ok(metadata) if metadata.file_type().is_file() => {
if !super::is_generated_launcher(&file::read_to_string(&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) -> 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)?;
Ok(())
}
fn validate_generated_stub_tree(path: &Path) -> 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)?;
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_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()
}
static AFTER_LONG_HELP: &str = color_print::cstr!(
r#"<bold><underline>Examples:</underline></bold>
$ <bold>mise tasks add test -- echo 'running tests'</bold>
$ <bold>mise generate task-stubs</bold>
$ <bold>./bin/test</bold>
running tests
"#
);
#[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"),
]
);
}
#[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"));
}
}