mise 2026.9.4

Dev tools, env vars, and tasks in one CLI
use eyre::{Result, bail};

use crate::config::{Config, Settings};
use crate::system;
use crate::system::packages::nix::{Installable, nix_string};

/// Export active Nix bootstrap packages as a NixOS module
///
/// Writes to stdout without invoking Nix. Import the output into an existing
/// NixOS configuration; packages resolve against that configuration's `pkgs`.
/// Only shorthand attribute paths are exportable. Explicit flake references
/// and package-version pins cannot be represented by the importing package set.
#[derive(Debug, usage_rs::Args)]
#[usage(
    verbatim_doc_comment,
    example(r###"mise bootstrap packages export --format nix > packages.nix"###)
)]
pub(crate) struct SystemExport {
    /// Output format
    #[usage(long, choices("nix"))]
    format: String,
}

impl SystemExport {
    pub(crate) async fn run(self) -> Result<()> {
        debug_assert_eq!(self.format, "nix");
        let config = Config::get().await?;
        let enabled = Settings::get()
            .system_packages
            .managers
            .as_ref()
            .is_none_or(|managers| managers.iter().any(|m| m == "nix"));
        let packages = if enabled {
            system::nix_packages_for_export(&config)
        } else {
            vec![]
        };
        let module = render(
            packages
                .iter()
                .map(|(spec, package)| (spec.as_str(), package.version())),
        )?;
        miseprint!("{module}")?;
        Ok(())
    }
}

fn render<'a>(packages: impl IntoIterator<Item = (&'a str, &'a str)>) -> Result<String> {
    let mut attributes = std::collections::BTreeSet::new();
    for (spec, version) in packages {
        let name = spec
            .strip_prefix("nix:")
            .ok_or_else(|| eyre::eyre!("expected a nix: package: {spec}"))?;
        let request = Installable::parse(name)?;
        if request.explicit {
            bail!(
                "cannot export '{spec}': use a shorthand attribute such as nix:ripgrep; the importing NixOS configuration owns the package source"
            );
        }
        if version != "latest" {
            bail!(
                "cannot export version pin '{spec}' = '{version}': pin nixpkgs in the importing NixOS configuration instead"
            );
        }
        attributes.insert(
            request
                .attribute
                .split('.')
                .map(nix_string)
                .collect::<Vec<_>>()
                .join("."),
        );
    }
    let mut output = String::from(
        "# Generated by mise bootstrap packages export --format nix\n{ pkgs, ... }:\n{\n  environment.systemPackages = [\n",
    );
    for attribute in attributes {
        output.push_str(&format!("    pkgs.{attribute}\n"));
    }
    output.push_str("  ];\n}\n");
    Ok(output)
}

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

    #[test]
    fn export_is_sorted_and_quotes_attribute_segments() {
        let module = render([
            ("nix:python3Packages.pip", "latest"),
            ("nix:hello-world", "latest"),
            ("nix:hello-world", "latest"),
        ])
        .unwrap();
        assert!(
            module.contains("    pkgs.\"hello-world\"\n    pkgs.\"python3Packages\".\"pip\"\n")
        );
        assert_eq!(module.matches("hello-world").count(), 1);
        assert!(render([]).unwrap().contains("systemPackages = [\n  ];"));
    }

    #[test]
    fn export_rejects_sources_pins_and_expressions() {
        for (name, version) in [
            ("nix:github:owner/repo#hello", "latest"),
            ("nix:hello", "1.2"),
            ("nix:${builtins.abort}", "latest"),
            ("nix:hello\"; throw", "latest"),
        ] {
            assert!(render([(name, version)]).is_err());
        }
    }
}