mise 2026.9.0

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

use eyre::{Result, eyre};

use crate::backend::BackendList;
use crate::cli::args::{BackendArg, BackendResolution};
use crate::config::config_file::ConfigFile;
use crate::file;
use crate::toolset::{ToolRequest, ToolRequestSet, ToolSource};

pub(crate) mod package_json;

#[derive(Debug, Clone)]
pub(crate) struct IdiomaticVersionFile {
    path: PathBuf,
    tools: ToolRequestSet,
}

impl IdiomaticVersionFile {
    #[allow(dead_code)]
    #[cfg(test)]
    pub(crate) fn init(path: PathBuf) -> Self {
        Self {
            path,
            tools: ToolRequestSet::new(),
        }
    }

    pub(crate) async fn parse(path: PathBuf, plugins: BackendList) -> Result<Self> {
        let source = ToolSource::IdiomaticVersionFile(path.clone());
        let mut tools = ToolRequestSet::new();

        for plugin in plugins {
            match plugin.parse_idiomatic_file_with_options(&path).await {
                Ok(versions) => {
                    for (version, mut options) in versions {
                        let backend = package_manager_checksum_backend(
                            plugin.ba(),
                            &version,
                            &mut options,
                            &path,
                        )?;
                        let mut tr = ToolRequest::new(backend, &version, source.clone())?;
                        tr.set_options(options);
                        tools.add_version(tr, &source);
                    }
                }
                Err(e) => {
                    trace!("skipping {} for {}: {}", path.display(), plugin.id(), e);
                    continue;
                }
            }
        }

        Ok(Self { tools, path })
    }

    fn read_only_error(&self, action: &str) -> eyre::Report {
        eyre!(
            "cannot {action} idiomatic version file {}; use mise.toml, .tool-versions, or --path to choose a writable config file",
            file::display_path(&self.path)
        )
    }
}

fn package_manager_checksum_backend(
    backend: &std::sync::Arc<BackendArg>,
    version: &str,
    options: &mut crate::toolset::ToolVersionOptions,
    path: &Path,
) -> Result<std::sync::Arc<BackendArg>> {
    if !package_json::is_package_json(path) {
        return Ok(backend.clone());
    }
    let Some(checksum) = options.opts.shift_remove("package_manager_checksum") else {
        return Ok(backend.clone());
    };
    options.opts.insert("checksum".to_string(), checksum);

    let full = match backend.short.as_str() {
        "bun" => {
            options
                .opts
                .insert("allow_builds".to_string(), toml::Value::Boolean(true));
            "npm:bun"
        }
        "npm" => "npm:npm",
        "pnpm" => "npm:pnpm",
        "yarn" => {
            let version = semver::Version::parse(version).map_err(|error| {
                eyre!("packageManager checksum requires an exact Yarn version: {error}")
            })?;
            if version.major < 2 {
                "npm:yarn"
            } else {
                options.opts.insert(
                    "url".to_string(),
                    toml::Value::String(format!(
                        "https://repo.yarnpkg.com/{version}/packages/yarnpkg-cli/bin/yarn.js"
                    )),
                );
                options
                    .opts
                    .insert("bin".to_string(), toml::Value::String("yarn".to_string()));
                options.opts.insert(
                    "windows_script_interpreter".to_string(),
                    toml::Value::String("node".to_string()),
                );
                "http:yarn"
            }
        }
        _ => return Ok(backend.clone()),
    };
    let tool_name = full
        .split_once(':')
        .map(|(_, tool_name)| tool_name)
        .unwrap_or(full);
    Ok(std::sync::Arc::new(BackendArg::new_raw(
        backend.short.clone(),
        Some(full.to_string()),
        tool_name.to_string(),
        None,
        BackendResolution::new(true),
    )))
}

impl ConfigFile for IdiomaticVersionFile {
    fn get_path(&self) -> &Path {
        self.path.as_path()
    }

    fn remove_tool(&self, _fa: &BackendArg) -> Result<()> {
        Err(self.read_only_error("remove tools from"))
    }

    fn replace_versions(
        &self,
        _plugin_name: &BackendArg,
        _versions: Vec<ToolRequest>,
    ) -> Result<()> {
        Err(self.read_only_error("update"))
    }

    #[cfg_attr(coverage_nightly, coverage(off))]
    fn save(&self) -> Result<()> {
        Ok(())
    }

    #[cfg_attr(coverage_nightly, coverage(off))]
    fn dump(&self) -> Result<String> {
        file::read_to_string(&self.path)
    }

    fn source(&self) -> ToolSource {
        ToolSource::IdiomaticVersionFile(self.path.clone())
    }

    fn to_tool_request_set(&self) -> Result<ToolRequestSet> {
        Ok(self.tools.clone())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::backend::{Backend, VersionInfo};
    use crate::cli::args::{BackendArg, BackendResolution};
    use crate::config::Config;
    use crate::install_context::InstallContext;
    use crate::toolset::ToolVersion;
    use async_trait::async_trait;
    use std::sync::Arc;

    #[derive(Debug)]
    struct MockBackend {
        ba: Arc<BackendArg>,
        fail: bool,
        version: Option<String>,
    }

    impl MockBackend {
        fn new(short: &str, fail: bool, version: Option<String>) -> Self {
            let ba = BackendArg::new_raw(
                short.to_string(),
                None,
                short.to_string(),
                None,
                BackendResolution::new(false),
            );
            Self {
                ba: Arc::new(ba),
                fail,
                version,
            }
        }
    }

    #[async_trait]
    impl Backend for MockBackend {
        fn ba(&self) -> &Arc<BackendArg> {
            &self.ba
        }

        async fn _list_remote_versions(&self, _config: &Arc<Config>) -> Result<Vec<VersionInfo>> {
            Ok(vec![])
        }

        async fn install_version_(
            &self,
            _ctx: &InstallContext,
            _tv: ToolVersion,
        ) -> Result<ToolVersion> {
            unimplemented!()
        }

        async fn parse_idiomatic_file(&self, _path: &Path) -> Result<Vec<String>> {
            if self.fail {
                eyre::bail!("mock error");
            }
            if let Some(v) = &self.version {
                Ok(vec![v.clone()])
            } else {
                Ok(vec![])
            }
        }
    }

    #[tokio::test]
    async fn test_idiomatic_parse_error_propagation() {
        let _config = Config::get().await.unwrap();
        let path = PathBuf::from(".tool-versions");
        let backend1 = Arc::new(MockBackend::new("node", true, None));
        let backend2 = Arc::new(MockBackend::new(
            "python",
            false,
            Some("3.10.0".to_string()),
        ));
        let plugins: BackendList = vec![backend1, backend2];

        let result = IdiomaticVersionFile::parse(path, plugins).await;

        assert!(result.is_ok(), "Should not propagate error from backend1");

        let file = result.unwrap();
        let trs = file.to_tool_request_set().unwrap();
        let tools: Vec<_> = trs.into_iter().collect();
        assert_eq!(tools.len(), 1);
        let (ba, versions, _) = &tools[0];
        assert_eq!(ba.short, "python");
        assert_eq!(versions[0].version(), "3.10.0");
    }

    #[test]
    fn test_idiomatic_mutations_return_errors() {
        let file = IdiomaticVersionFile::init(PathBuf::from("package.json"));
        let ba = MockBackend::new("node", false, None).ba;

        let err = file.replace_versions(&ba, vec![]).unwrap_err();
        assert!(
            err.to_string()
                .contains("cannot update idiomatic version file")
        );

        let err = file.remove_tool(&ba).unwrap_err();
        assert!(
            err.to_string()
                .contains("cannot remove tools from idiomatic version file")
        );
    }

    #[test]
    fn test_package_manager_checksums_use_matching_corepack_artifacts() {
        let path = Path::new("package.json");
        for (tool, version, expected) in [
            ("bun", "1.3.14", "npm:bun"),
            ("npm", "11.0.0", "npm:npm"),
            ("pnpm", "10.0.0", "npm:pnpm"),
            ("yarn", "1.22.22", "npm:yarn"),
            ("yarn", "4.0.0", "http:yarn"),
        ] {
            let mut options = crate::toolset::ToolVersionOptions::default();
            options.opts.insert(
                "package_manager_checksum".to_string(),
                toml::Value::String("sha224:abc".to_string()),
            );
            let backend = MockBackend::new(tool, false, None).ba;
            let backend =
                package_manager_checksum_backend(&backend, version, &mut options, path).unwrap();
            assert_eq!(backend.full(), expected);
            if tool == "bun" {
                assert_eq!(
                    options.opts.get("allow_builds"),
                    Some(&toml::Value::Boolean(true))
                );
            }
            if expected == "http:yarn" {
                assert_eq!(
                    options.opts.get("url").and_then(toml::Value::as_str),
                    Some("https://repo.yarnpkg.com/4.0.0/packages/yarnpkg-cli/bin/yarn.js")
                );
                assert_eq!(
                    options.opts.get("bin").and_then(toml::Value::as_str),
                    Some("yarn")
                );
                assert_eq!(
                    options
                        .opts
                        .get("windows_script_interpreter")
                        .and_then(toml::Value::as_str),
                    Some("node")
                );
            }
        }
    }
}