mise 2026.8.10

Dev tools, env vars, and tasks in one CLI
use clap::Subcommand;
use std::path::{Path, PathBuf};

mod bootstrap;
mod config;
mod devcontainer;
mod git_pre_commit;
mod github_action;
mod task_docs;
mod task_stubs;
mod tool_stub;

/// Generate files for various tools/services
#[derive(Debug, clap::Args)]
#[clap(visible_alias = "gen", alias = "g")]
pub struct Generate {
    #[clap(subcommand)]
    command: Commands,
}

#[derive(Debug, Subcommand)]
enum Commands {
    Bootstrap(bootstrap::Bootstrap),
    Config(config::Config),
    Devcontainer(devcontainer::Devcontainer),
    GitPreCommit(git_pre_commit::GitPreCommit),
    GithubAction(github_action::GithubAction),
    TaskDocs(task_docs::TaskDocs),
    TaskStubs(task_stubs::TaskStubs),
    ToolStub(tool_stub::ToolStub),
}

impl Commands {
    pub async fn run(self) -> eyre::Result<()> {
        match self {
            Self::Bootstrap(cmd) => cmd.run().await,
            Self::Config(cmd) => cmd.run().await,
            Self::Devcontainer(cmd) => cmd.run().await,
            Self::GitPreCommit(cmd) => cmd.run().await,
            Self::GithubAction(cmd) => cmd.run().await,
            Self::TaskDocs(cmd) => cmd.run().await,
            Self::TaskStubs(cmd) => cmd.run().await,
            Self::ToolStub(cmd) => cmd.run().await,
        }
    }
}

impl Generate {
    pub async fn run(self) -> eyre::Result<()> {
        self.command.run().await
    }
}

/// Where the Windows launcher for a generated stub goes, or `None` when the stub does not want one.
///
/// Stubs are shebang scripts, which Windows will not execute, so anything generated to be run as a
/// command needs a `.cmd` beside it. Skipped when the stub's own name already ends in an executable
/// extension, so `mytool.cmd` does not grow a `mytool.cmd.cmd`.
pub(super) fn windows_launcher_path(stub: &Path) -> Option<PathBuf> {
    let name = stub.file_name()?.to_str()?;
    let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
    if matches!(ext.as_deref(), Some("cmd" | "bat" | "exe")) {
        return None;
    }
    Some(stub.with_file_name(format!("{name}.cmd")))
}

/// Marks a `.cmd` as generated, so regeneration can tell its own launcher from a hand-written one.
///
/// The stub itself carries `# generated by mise task-stubs` for the same reason; a launcher needs
/// its own because its body varies per task and so cannot be recognised by comparison.
pub(super) const WINDOWS_LAUNCHER_MARKER: &str = "rem generated by mise";

/// The body of a Windows launcher that runs `command` with the caller's arguments.
///
/// `%*` forwards the argument text with quoting intact, and cmd returns the last command's exit
/// code when the script ends.
pub(super) fn windows_launcher_body(command: &str) -> String {
    format!("@echo off\r\n{WINDOWS_LAUNCHER_MARKER}\r\n{command} %*\r\n")
}

/// Recognise a `.cmd` this crate generated, so regeneration can replace or remove its own launcher
/// without touching one the user wrote.
///
/// Ownership rests on [`WINDOWS_LAUNCHER_MARKER`], never on comparing the body. The body embeds the
/// command, so it changes with `--mise-bin`, with the task name, and with the stub path — a
/// launcher written by an earlier run with different arguments is still ours, and comparison would
/// call it a stranger and leave it behind. The rest of the shape is checked so that
/// `@echo off` plus something ending in ` %*` is not enough on its own: the consequence of a false
/// positive here is deleting a file mise did not write.
pub(super) fn is_generated_launcher(contents: &str) -> bool {
    let mut lines = contents.lines();
    matches!(lines.next(), Some("@echo off"))
        && lines.next() == Some(WINDOWS_LAUNCHER_MARKER)
        && lines.next().is_some_and(|line| line.ends_with(" %*"))
        && lines.next().is_none()
}

/// Quote one word of a generated `.cmd` so cmd.exe passes it through unchanged.
///
/// Quoting alone is not enough. Measured on Windows against a real `cmd.exe`:
///
/// - unquoted, a path containing `&` runs the text before it as a command
///   (`'...\cmdtest\a' is not recognized`); quoting fixes it, because inside `"..."` cmd stops
///   treating `& | < > ^ ( )` as syntax
/// - `%NAME%` is expanded *inside* quotes too, so a literal `%` has to be written `%%` — the
///   batch-file spelling. `"C:\%FOO%\mise.exe"` came out as `C:\INJECTED\mise.exe`
///
/// Quoting unconditionally rather than only when it is needed: a quoted bare name still resolves
/// through `PATH` (verified), so there is no case where the quotes cost anything, and one rule is
/// easier to keep correct than a predicate over cmd's metacharacter set.
pub(super) fn cmd_quote(s: &str) -> String {
    format!("\"{}\"", s.replace('%', "%%"))
}

#[cfg(test)]
mod windows_launcher_tests {
    use super::*;

    #[test]
    fn launcher_sits_beside_the_stub() {
        assert_eq!(
            windows_launcher_path(Path::new("bin/hello")),
            Some(PathBuf::from("bin/hello.cmd"))
        );
        // a dot that is not an executable extension is just part of the name
        assert_eq!(
            windows_launcher_path(Path::new("my.tool")),
            Some(PathBuf::from("my.tool.cmd"))
        );
    }

    #[test]
    fn a_name_that_is_already_executable_gets_none() {
        for name in ["mytool.cmd", "mytool.BAT", "mytool.exe"] {
            assert_eq!(windows_launcher_path(Path::new(name)), None, "{name}");
        }
    }

    #[test]
    fn body_forwards_arguments() {
        assert_eq!(
            windows_launcher_body("mise run hello"),
            "@echo off\r\nrem generated by mise\r\nmise run hello %*\r\n"
        );
    }

    #[test]
    fn every_body_carries_the_ownership_marker() {
        // Regeneration refuses to replace a `.cmd` it cannot recognise, so the marker has to be
        // present in whatever the generators produce, not only in the one example above.
        for command in ["mise run hello", r#"mise tool-stub "%~dpn0""#] {
            let body = windows_launcher_body(command);
            assert!(
                body.lines().nth(1) == Some(WINDOWS_LAUNCHER_MARKER),
                "{body:?}"
            );
        }
    }

    #[test]
    fn a_launcher_is_recognised_whatever_command_it_carries() {
        // The point of recognising by marker rather than by comparison: these bodies differ from
        // each other and from whatever the current run would produce -- a changed `--mise-bin`, a
        // renamed task, an older mise -- and every one of them is still ours to replace or remove.
        for command in [
            "mise run hello",
            r#""C:\Program Files\mise.exe" run hello"#,
            r#"mise tool-stub "%~dpn0""#,
            "some-other-binary run build",
        ] {
            assert!(
                is_generated_launcher(&windows_launcher_body(command)),
                "{command}"
            );
        }
    }

    #[test]
    fn a_launcher_without_the_marker_is_not_ours() {
        // The controls. A false positive here deletes or overwrites a file mise did not write, so
        // the shape alone must not be enough -- the first case is exactly what someone would write
        // by hand for the same purpose.
        for contents in [
            "@echo off\r\nmise run hello %*\r\n",
            "@echo off\r\nrem hand written\r\nmise run hello %*\r\n",
            "rem generated by mise\r\nmise run hello %*\r\n",
            // Trailing content: a launcher plus something the user appended is not ours to delete.
            "@echo off\r\nrem generated by mise\r\nmise run hello %*\r\necho done\r\n",
            // Not a launcher at all.
            "@echo off\r\nrem generated by mise\r\nmise run hello\r\n",
            "",
        ] {
            assert!(!is_generated_launcher(contents), "{contents:?}");
        }
    }

    #[test]
    fn quoting_survives_cmd_metacharacters() {
        assert_eq!(cmd_quote("mise"), "\"mise\"");
        assert_eq!(
            cmd_quote(r"C:\Program Files\mise.exe"),
            "\"C:\\Program Files\\mise.exe\""
        );
        // `&` would otherwise end the command; the quotes are what contain it
        assert_eq!(cmd_quote(r"C:\a&b\mise.exe"), "\"C:\\a&b\\mise.exe\"");
        // `%` is expanded even inside quotes, so it has to be doubled
        assert_eq!(cmd_quote(r"C:\p%c\mise.exe"), "\"C:\\p%%c\\mise.exe\"");
        assert_eq!(cmd_quote("%PATH%"), "\"%%PATH%%\"");
    }
}