mise 2026.4.11

The front-end to your dev env
use crate::cmd::cmd;
use crate::config::{Config, Settings};
use crate::dirs;
use crate::toolset::Toolset;
use eyre::Result;
use globset::{GlobBuilder, GlobSetBuilder};
use itertools::Itertools;
use std::iter::once;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::{collections::BTreeSet, sync::Arc};

#[derive(
    Debug, Clone, serde::Serialize, serde::Deserialize, Ord, PartialOrd, Eq, PartialEq, Hash,
)]
pub struct WatchFile {
    pub patterns: Vec<String>,
    #[serde(default)]
    pub run: Option<String>,
    pub task: Option<String>,
}

pub static MODIFIED_FILES: Mutex<Option<BTreeSet<PathBuf>>> = Mutex::new(None);

pub fn add_modified_file(file: PathBuf) {
    let mut mu = MODIFIED_FILES.lock().unwrap();
    let set = mu.get_or_insert_with(BTreeSet::new);
    set.insert(file);
}

pub async fn execute_runs(config: &Arc<Config>, ts: &Toolset) {
    let files = {
        let mut mu = MODIFIED_FILES.lock().unwrap();
        mu.take().unwrap_or_default()
    };
    if files.is_empty() {
        return;
    }
    for (root, wf) in config.watch_file_hooks().unwrap_or_default() {
        match has_matching_files(&root, &wf, &files) {
            Ok(files) if files.is_empty() => {
                continue;
            }
            Ok(files) => {
                if wf.task.is_some() && wf.run.is_some() {
                    warn!("watch_file hook has both run and task set, using task");
                }
                let result = if let Some(task_name) = &wf.task {
                    execute_task(config, ts, &root, task_name, files).await
                } else if let Some(run) = &wf.run {
                    execute(config, ts, &root, run, files).await
                } else {
                    warn!("watch_file hook has neither run nor task set, skipping");
                    continue;
                };
                if let Err(e) = result {
                    warn!("error executing watch_file hook: {e}");
                }
            }
            Err(e) => {
                warn!("error matching files: {e}");
            }
        }
    }
}

async fn execute(
    config: &Arc<Config>,
    ts: &Toolset,
    root: &Path,
    run: &str,
    files: Vec<&PathBuf>,
) -> Result<()> {
    Settings::get().ensure_experimental("watch_file_hooks")?;
    let modified_files_var = files
        .iter()
        .map(|f| f.to_string_lossy().replace(':', "\\:"))
        .join(":");
    let shell = Settings::get().default_inline_shell()?;

    let args = shell
        .iter()
        .skip(1)
        .map(|s| s.as_str())
        .chain(once(run))
        .collect_vec();
    let mut env = ts.full_env(config).await?;
    env.insert("MISE_WATCH_FILES_MODIFIED".to_string(), modified_files_var);
    if let Some(cwd) = &*dirs::CWD {
        env.insert(
            "MISE_ORIGINAL_CWD".to_string(),
            cwd.to_string_lossy().to_string(),
        );
    }
    env.insert(
        "MISE_PROJECT_ROOT".to_string(),
        root.to_string_lossy().to_string(),
    );
    // TODO: this should be different but I don't have easy access to it
    // env.insert("MISE_CONFIG_ROOT".to_string(), root.to_string_lossy().to_string());
    cmd(&shell[0], args)
        .stdout_to_stderr()
        // .dir(root)
        .full_env(env)
        .run()?;
    Ok(())
}

async fn execute_task(
    config: &Arc<Config>,
    ts: &Toolset,
    root: &Path,
    task_name: &str,
    files: Vec<&PathBuf>,
) -> Result<()> {
    Settings::get().ensure_experimental("watch_file_hooks")?;
    let modified_files_var = files
        .iter()
        .map(|f| f.to_string_lossy().replace(':', "\\:"))
        .join(":");
    let mise_bin = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("mise"));

    let mut env = ts.full_env(config).await?;
    env.insert("MISE_WATCH_FILES_MODIFIED".to_string(), modified_files_var);
    env.insert("MISE_NO_HOOKS".to_string(), "1".to_string());
    if let Some(cwd) = &*dirs::CWD {
        env.insert(
            "MISE_ORIGINAL_CWD".to_string(),
            cwd.to_string_lossy().to_string(),
        );
    }
    env.insert(
        "MISE_PROJECT_ROOT".to_string(),
        root.to_string_lossy().to_string(),
    );
    cmd(
        mise_bin,
        ["--cd", &root.to_string_lossy(), "run", task_name],
    )
    .stdout_to_stderr()
    .full_env(env)
    .run()?;
    Ok(())
}

fn has_matching_files<'a>(
    root: &Path,
    wf: &'a WatchFile,
    files: &'a BTreeSet<PathBuf>,
) -> Result<Vec<&'a PathBuf>> {
    let mut glob = GlobSetBuilder::new();
    for pattern in &wf.patterns {
        match GlobBuilder::new(pattern).literal_separator(true).build() {
            Ok(g) => {
                glob.add(g);
            }
            Err(e) => {
                warn!("invalid glob pattern: {e}");
            }
        }
    }
    let glob = glob.build()?;
    Ok(files
        .iter()
        .filter(|file| {
            if let Ok(rel) = file.strip_prefix(root) {
                !glob.matches(rel).is_empty()
            } else {
                false
            }
        })
        .collect())
}

pub fn glob(root: &Path, patterns: &[String]) -> Result<Vec<PathBuf>> {
    if patterns.is_empty() {
        return Ok(vec![]);
    }
    let opts = glob::MatchOptions {
        require_literal_separator: true,
        ..Default::default()
    };
    Ok(patterns
        .iter()
        .map(|pattern| root.join(pattern).to_string_lossy().to_string())
        .filter_map(|pattern| glob::glob_with(&pattern, opts).ok())
        .collect::<Vec<_>>()
        .into_iter()
        .flat_map(|paths| paths.filter_map(|p| p.ok()))
        .collect())
}