pub struct InstallMethod {
pub method: &'static str,
pub package: &'static str,
}
pub struct InstallCtx {
pub verbose: bool,
pub use_eatmydata: bool,
}
impl InstallMethod {
pub fn command(&self) -> String {
let steps = describe(self.method, &[self.package]);
steps.first().map_or_else(|| format!("({}) {}", self.method, self.package), |argv| join_argv(argv))
}
}
pub fn describe(method: &str, packages: &[&str]) -> Vec<Vec<String>> {
let sudo = sudo_argv();
let strs = |parts: &[&str]| -> Vec<String> {
parts.iter().map(|s| (*s).to_string()).collect()
};
let prefix = |head: &[&str], tail: &[&str]| -> Vec<String> {
sudo.iter().chain(head.iter()).chain(tail.iter())
.map(|s| (*s).to_string()).collect()
};
match method {
"apt" => {
let update = prefix(&["apt-get", "update"], &[]);
let mut install = prefix(&["apt-get", "install", "-y"], &[]);
install.extend(packages.iter().map(|s| (*s).to_string()));
vec![update, install]
}
"dnf" => {
let mut argv = prefix(&["dnf", "install", "-y"], &[]);
argv.extend(packages.iter().map(|s| (*s).to_string()));
vec![argv]
}
"pacman" => {
let mut argv = prefix(&["pacman", "-S", "--noconfirm"], &[]);
argv.extend(packages.iter().map(|s| (*s).to_string()));
vec![argv]
}
"brew" => {
let mut argv = strs(&["brew", "install"]);
argv.extend(packages.iter().map(|s| (*s).to_string()));
vec![argv]
}
"snap" => {
let mut argv = prefix(&["snap", "install"], &[]);
argv.extend(packages.iter().map(|s| (*s).to_string()));
vec![argv]
}
"pip" => vec![{ let mut a = strs(&["pip", "install"]); a.extend(packages.iter().map(|s| (*s).to_string())); a }],
"uv" => vec![{ let mut a = uv_pip_install_argv(); a.extend(packages.iter().map(|s| (*s).to_string())); a }],
"npm" => vec![{ let mut a = strs(&["npm", "install", "-g"]); a.extend(packages.iter().map(|s| (*s).to_string())); a }],
"cargo" => vec![{ let mut a = strs(&["cargo", "install"]); a.extend(packages.iter().map(|s| (*s).to_string())); a }],
"gem" => vec![{ let mut a = gem_install_argv(); a.extend(packages.iter().map(|s| (*s).to_string())); a }],
"binary" => packages.iter().flat_map(|p| describe_binary(p)).collect(),
"manual" => packages.iter()
.map(|p| vec!["# manual:".to_string(), (*p).to_string()])
.collect(),
_ => packages.iter()
.map(|p| vec![format!("# unknown method '{}':", method), (*p).to_string()])
.collect(),
}
}
pub fn run(method: &str, packages: &[&str], ctx: &InstallCtx) -> anyhow::Result<()> {
use anyhow::Context as _;
use std::process::Command;
if packages.is_empty() {
return Ok(());
}
let exec = |argv: &[String]| -> anyhow::Result<()> {
if ctx.verbose {
println!("Running: {}", join_argv(argv));
}
let status = Command::new(&argv[0]).args(&argv[1..]).status()
.with_context(|| format!("failed to spawn: {}", join_argv(argv)))?;
if !status.success() {
anyhow::bail!(
"{} exited with code {}",
join_argv(argv),
status.code().map_or_else(|| "unknown".to_string(), |c| c.to_string())
);
}
Ok(())
};
let sudo = sudo_argv();
let eatmydata: &[&str] = if ctx.use_eatmydata && which::which("eatmydata").is_ok() {
&["eatmydata"]
} else {
&[]
};
let pkgmgr_argv = |head: &[&str]| -> Vec<String> {
sudo.iter()
.chain(eatmydata.iter())
.chain(head.iter())
.map(|s| (*s).to_string())
.collect()
};
match method {
"apt" => {
let update = pkgmgr_argv(&["apt-get", "update"]);
exec(&update)?;
let mut argv = pkgmgr_argv(&["apt-get", "install", "-y"]);
argv.extend(packages.iter().map(|s| (*s).to_string()));
exec(&argv)
}
"dnf" => {
let mut argv = pkgmgr_argv(&["dnf", "install", "-y"]);
argv.extend(packages.iter().map(|s| (*s).to_string()));
exec(&argv)
}
"pacman" => {
let mut argv = pkgmgr_argv(&["pacman", "-S", "--noconfirm"]);
argv.extend(packages.iter().map(|s| (*s).to_string()));
exec(&argv)
}
"brew" => {
let mut argv = vec!["brew".to_string(), "install".to_string()];
argv.extend(packages.iter().map(|s| (*s).to_string()));
exec(&argv)
}
"snap" => {
let mut argv: Vec<String> = sudo.iter().chain(["snap", "install"].iter())
.map(|s| (*s).to_string()).collect();
argv.extend(packages.iter().map(|s| (*s).to_string()));
exec(&argv)
}
"pip" => {
let mut argv = vec!["pip".to_string(), "install".to_string()];
argv.extend(packages.iter().map(|s| (*s).to_string()));
exec(&argv)
}
"uv" => {
let mut argv = uv_pip_install_argv();
argv.extend(packages.iter().map(|s| (*s).to_string()));
exec(&argv)
}
"npm" => {
let mut argv = vec!["npm".to_string(), "install".to_string(), "-g".to_string()];
argv.extend(packages.iter().map(|s| (*s).to_string()));
exec(&argv)
}
"cargo" => {
let mut argv = vec!["cargo".to_string(), "install".to_string()];
argv.extend(packages.iter().map(|s| (*s).to_string()));
exec(&argv)
}
"gem" => {
let mut argv = gem_install_argv();
argv.extend(packages.iter().map(|s| (*s).to_string()));
exec(&argv)
}
"binary" => {
for pkg in packages {
run_binary(pkg, ctx)?;
}
Ok(())
}
"manual" => anyhow::bail!(
"method '{}' is manual-only — install these packages by hand: {}",
method, packages.join(", ")
),
other => anyhow::bail!("unknown install method '{other}'"),
}
}
fn sudo_argv() -> &'static [&'static str] {
if crate::platform::needs_sudo() { &["sudo"] } else { &[] }
}
fn gem_install_argv() -> Vec<String> {
let mut argv = vec!["gem".to_string(), "install".to_string()];
if gem_needs_user_install() {
argv.push("--user-install".to_string());
}
argv
}
fn uv_pip_install_argv() -> Vec<String> {
let probe = python_probe();
uv_pip_install_argv_for(probe.in_venv, probe.user_prefix.as_deref())
}
fn uv_pip_install_argv_for(in_venv: bool, user_prefix: Option<&str>) -> Vec<String> {
let mut argv: Vec<String> = ["uv", "pip", "install", "--python", "python3"]
.iter().map(|s| (*s).to_string()).collect();
if !in_venv {
argv.push("--system".to_string());
}
if let Some(user_base) = user_prefix {
argv.push("--prefix".to_string());
argv.push(user_base.to_string());
}
argv
}
#[derive(Clone, Default)]
struct PythonProbe {
in_venv: bool,
user_prefix: Option<String>,
}
fn python_probe() -> PythonProbe {
static PROBE: std::sync::OnceLock<PythonProbe> = std::sync::OnceLock::new();
PROBE.get_or_init(|| {
let Ok(out) = std::process::Command::new("python3")
.args(["-c", "import sys, sysconfig, site; \
print(int(sys.prefix != sys.base_prefix)); \
print(sysconfig.get_path('purelib')); \
print(site.getuserbase())"])
.output() else {
return PythonProbe::default();
};
if !out.status.success() {
return PythonProbe::default();
}
let stdout = String::from_utf8_lossy(&out.stdout);
let mut lines = stdout.lines();
let in_venv = lines.next().is_some_and(|l| l == "1");
let purelib = lines.next().map_or("", str::trim);
let user_base = lines.next().map_or("", str::trim);
let user_prefix = if in_venv || purelib.is_empty() || user_base.is_empty()
|| nearest_existing_ancestor_is_writable(std::path::Path::new(purelib))
{
None
} else {
Some(user_base.to_string())
};
PythonProbe { in_venv, user_prefix }
}).clone()
}
fn gem_needs_user_install() -> bool {
static NEEDS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*NEEDS.get_or_init(|| {
if crate::platform::is_root() {
return false;
}
let Ok(out) = std::process::Command::new("gem").args(["env", "gemdir"]).output() else {
return false;
};
if !out.status.success() {
return false;
}
let stdout = String::from_utf8_lossy(&out.stdout);
let gemdir = std::path::Path::new(stdout.trim());
if gemdir.as_os_str().is_empty() {
return false;
}
!nearest_existing_ancestor_is_writable(gemdir)
})
}
fn nearest_existing_ancestor_is_writable(path: &std::path::Path) -> bool {
let mut p = path;
loop {
if p.exists() {
return crate::platform::path_is_writable(p);
}
match p.parent() {
Some(parent) => p = parent,
None => return false,
}
}
}
pub fn augment_path_with_user_gem_bins() {
let Some(home) = std::env::var_os("HOME") else { return };
let gem_home = std::env::var_os("GEM_HOME");
let dirs = user_gem_bin_dirs(std::path::Path::new(&home), gem_home.as_deref());
if dirs.is_empty() {
return;
}
let path = std::env::var_os("PATH").unwrap_or_default();
if let Some(new_path) = path_with_appended_dirs(&path, &dirs) {
crate::platform::set_env("PATH", &new_path);
}
}
fn user_gem_bin_dirs(home: &std::path::Path, gem_home: Option<&std::ffi::OsStr>) -> Vec<std::path::PathBuf> {
let mut dirs = Vec::new();
if let Some(gem_home) = gem_home {
let bin = std::path::Path::new(gem_home).join("bin");
if bin.is_dir() {
dirs.push(bin);
}
}
for base in [home.join(".gem/ruby"), home.join(".local/share/gem/ruby")] {
let Ok(entries) = std::fs::read_dir(&base) else { continue };
let mut versions: Vec<std::path::PathBuf> = entries
.flatten()
.map(|e| e.path().join("bin"))
.filter(|p| p.is_dir())
.collect();
versions.sort();
dirs.append(&mut versions);
}
dirs
}
fn path_with_appended_dirs(path: &std::ffi::OsStr, dirs: &[std::path::PathBuf]) -> Option<std::ffi::OsString> {
let existing: Vec<std::path::PathBuf> = std::env::split_paths(path).collect();
let missing: Vec<std::path::PathBuf> = dirs.iter()
.filter(|d| !existing.contains(d))
.cloned()
.collect();
if missing.is_empty() {
return None;
}
std::env::join_paths(existing.into_iter().chain(missing)).ok()
}
fn join_argv(argv: &[String]) -> String {
argv.join(" ")
}
fn describe_binary(pkg: &str) -> Vec<Vec<String>> {
match binary_recipe(pkg) {
Some(BinaryRecipe { archive: ArchiveKind::Deb { source }, dest, .. }) => {
let sudo = sudo_argv();
let deb = format!("/tmp/{dest}.deb");
let (note, url) = match source {
DebSource::GithubRelease { repo, asset_pattern } => (
Some(format!("# resolve latest '{asset_pattern}' .deb asset from {repo}")),
"<resolved-asset-url>".to_string(),
),
DebSource::Url(url) => (None, url.to_string()),
};
let mut steps: Vec<Vec<String>> = Vec::new();
if let Some(note) = note {
steps.push(vec![note]);
}
steps.push(crate::download::curl_argv(&url, &deb));
steps.push(sudo.iter().chain(["apt-get", "install", "-y", &deb].iter())
.map(|s| (*s).to_string()).collect());
steps
}
Some(BinaryRecipe { url, archive, dest, .. }) => {
let tmp = format!("/tmp/{dest}");
let dl = format!("/tmp/{dest}.dl");
let final_path = format!("/usr/local/bin/{dest}");
let download = crate::download::curl_argv(url, &dl);
let extract = match archive {
ArchiveKind::TarGz { inner } => vec![
"tar".to_string(), "-xzf".to_string(),
dl,
"-C".to_string(), "/tmp".to_string(),
inner.to_string(),
],
ArchiveKind::Gunzip => vec![
"gunzip".to_string(), "-f".to_string(),
dl,
],
ArchiveKind::Raw => vec![
"mv".to_string(), dl, tmp.clone(),
],
ArchiveKind::Deb { .. } => unreachable!("matched by the Deb arm above"),
};
let chmod = vec!["chmod".to_string(), "+x".to_string(), tmp.clone()];
let sudo = sudo_argv();
let mv = sudo.iter().chain(["mv", &tmp, &final_path].iter())
.map(|s| (*s).to_string()).collect();
vec![download, extract, chmod, mv]
}
None => vec![vec![format!("# unknown binary recipe '{pkg}'")]],
}
}
fn github_token() -> Option<String> {
github_token_from(|name| std::env::var(name).ok())
}
fn github_token_from(lookup: impl Fn(&str) -> Option<String>) -> Option<String> {
["GITHUB_TOKEN", "GH_TOKEN"].iter()
.find_map(|name| lookup(name).filter(|token| !token.trim().is_empty()))
}
fn resolve_latest_deb_asset(repo: &str, asset_pattern: &str) -> anyhow::Result<String> {
use anyhow::Context as _;
let api = format!("https://api.github.com/repos/{repo}/releases/latest");
let token = github_token();
let body = crate::download::with_retry(|| {
let mut req = ureq::get(&api)
.header("User-Agent", "rsconstruct");
if let Some(token) = &token {
req = req.header("Authorization", &format!("Bearer {token}"));
}
req.call()
.with_context(|| format!("Failed to query GitHub releases API for {repo}"))?
.body_mut()
.read_to_string()
.with_context(|| format!("Failed to read GitHub releases response for {repo}"))
})?;
let release: serde_json::Value = serde_json::from_str(&body)
.with_context(|| format!("Failed to parse GitHub releases JSON for {repo}"))?;
release["assets"].as_array()
.with_context(|| format!("GitHub release for {repo} has no 'assets' array"))?
.iter()
.find_map(|a| {
let name = a["name"].as_str()?;
let is_deb = std::path::Path::new(name).extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("deb"));
(name.contains(asset_pattern) && is_deb)
.then(|| a["browser_download_url"].as_str())?
.map(std::string::ToString::to_string)
})
.with_context(|| format!(
"No .deb asset matching '{asset_pattern}' in the latest {repo} release"
))
}
fn run_binary(pkg: &str, ctx: &InstallCtx) -> anyhow::Result<()> {
use anyhow::Context as _;
use std::process::Command;
let recipe = binary_recipe(pkg)
.ok_or_else(|| anyhow::anyhow!("no binary install recipe for '{pkg}'"))?;
let download = format!("/tmp/{}.dl", recipe.dest);
let final_tmp = format!("/tmp/{}", recipe.dest);
let final_path = format!("/usr/local/bin/{}", recipe.dest);
let exec = |argv: &[&str]| -> anyhow::Result<()> {
if ctx.verbose {
println!("Running: {}", argv.join(" "));
}
let status = Command::new(argv[0]).args(&argv[1..]).status()
.with_context(|| format!("failed to spawn: {}", argv.join(" ")))?;
if !status.success() {
anyhow::bail!(
"{} exited with code {}",
argv.join(" "),
status.code().map_or_else(|| "unknown".to_string(), |c| c.to_string())
);
}
Ok(())
};
if let ArchiveKind::Deb { source } = recipe.archive {
let deb = format!("/tmp/{}.deb", recipe.dest);
let asset_url = match source {
DebSource::GithubRelease { repo, asset_pattern } => {
resolve_latest_deb_asset(repo, asset_pattern)?
}
DebSource::Url(url) => url.to_string(),
};
let dl = crate::download::curl_argv(&asset_url, &deb);
exec(&dl.iter().map(String::as_str).collect::<Vec<_>>())?;
let sudo = sudo_argv();
let mut install: Vec<&str> = sudo.to_vec();
install.extend(["apt-get", "install", "-y", &deb]);
let result = exec(&install);
std::fs::remove_file(&deb).ok();
return result;
}
let dl = crate::download::curl_argv(recipe.url, &download);
exec(&dl.iter().map(String::as_str).collect::<Vec<_>>())?;
match recipe.archive {
ArchiveKind::Deb { .. } => unreachable!("returned early by the Deb branch above"),
ArchiveKind::TarGz { inner } => {
exec(&["tar", "-xzf", &download, "-C", "/tmp", inner])?;
let inner_path = format!("/tmp/{inner}");
if inner_path != final_tmp {
std::fs::rename(&inner_path, &final_tmp)
.with_context(|| format!("rename {inner_path} -> {final_tmp}"))?;
}
std::fs::remove_file(&download).ok();
}
ArchiveKind::Gunzip => {
let gz = format!("{download}.gz");
std::fs::rename(&download, &gz).with_context(|| format!("rename {download} -> {gz}"))?;
exec(&["gunzip", "-f", &gz])?;
std::fs::rename(&download, &final_tmp)
.with_context(|| format!("rename {download} -> {final_tmp}"))?;
}
ArchiveKind::Raw => {
std::fs::rename(&download, &final_tmp)
.with_context(|| format!("rename {download} -> {final_tmp}"))?;
}
}
crate::platform::set_permissions_mode(std::path::Path::new(&final_tmp), 0o755)
.with_context(|| format!("chmod +x {final_tmp}"))?;
let sudo = sudo_argv();
let mut mv: Vec<&str> = sudo.to_vec();
mv.extend(["mv", &final_tmp, &final_path]);
exec(&mv)
}
struct BinaryRecipe {
url: &'static str,
archive: ArchiveKind,
dest: &'static str,
}
enum ArchiveKind {
TarGz { inner: &'static str },
Gunzip,
Raw,
Deb { source: DebSource },
}
enum DebSource {
GithubRelease { repo: &'static str, asset_pattern: &'static str },
Url(&'static str),
}
fn binary_recipe(pkg: &str) -> Option<BinaryRecipe> {
match pkg {
"rumdl" => Some(BinaryRecipe {
url: "https://github.com/rvben/rumdl/releases/download/v0.2.66/rumdl-v0.2.66-x86_64-unknown-linux-gnu.tar.gz",
archive: ArchiveKind::TarGz { inner: "rumdl" },
dest: "rumdl",
}),
"zola" => Some(BinaryRecipe {
url: "https://github.com/getzola/zola/releases/download/v0.23.3/zola-v0.23.3-x86_64-unknown-linux-gnu.tar.gz",
archive: ArchiveKind::TarGz { inner: "zola" },
dest: "zola",
}),
"taplo" => Some(BinaryRecipe {
url: "https://github.com/tamasfe/taplo/releases/latest/download/taplo-linux-x86_64.gz",
archive: ArchiveKind::Gunzip,
dest: "taplo",
}),
"actionlint" => Some(BinaryRecipe {
url: "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz",
archive: ArchiveKind::TarGz { inner: "actionlint" },
dest: "actionlint",
}),
"hadolint" => Some(BinaryRecipe {
url: "https://github.com/hadolint/hadolint/releases/latest/download/hadolint-Linux-x86_64",
archive: ArchiveKind::Raw,
dest: "hadolint",
}),
"checkpatch.pl" => Some(BinaryRecipe {
url: "https://raw.githubusercontent.com/torvalds/linux/master/scripts/checkpatch.pl",
archive: ArchiveKind::Raw,
dest: "checkpatch.pl",
}),
"drawio" => Some(BinaryRecipe {
url: "https://github.com/jgraph/drawio-desktop/releases/latest",
archive: ArchiveKind::Deb {
source: DebSource::GithubRelease {
repo: "jgraph/drawio-desktop",
asset_pattern: "amd64",
},
},
dest: "drawio",
}),
"google-chrome-stable" => Some(BinaryRecipe {
url: "https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb",
archive: ArchiveKind::Deb {
source: DebSource::Url(
"https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb",
),
},
dest: "google-chrome",
}),
_ => None,
}
}
pub struct ToolInfo {
pub name: &'static str,
pub runtime: &'static str,
pub install_methods: &'static [InstallMethod],
}
pub static TOOLS: &[ToolInfo] = &[
ToolInfo { name: "ruff", runtime: "python", install_methods: &[InstallMethod { method: "pip", package: "ruff" }] },
ToolInfo { name: "pylint", runtime: "python", install_methods: &[InstallMethod { method: "pip", package: "pylint" }] },
ToolInfo { name: "mypy", runtime: "python", install_methods: &[InstallMethod { method: "pip", package: "mypy" }] },
ToolInfo { name: "pyrefly", runtime: "python", install_methods: &[InstallMethod { method: "pip", package: "pyrefly" }] },
ToolInfo { name: "yamllint", runtime: "python", install_methods: &[InstallMethod { method: "pip", package: "yamllint" }] },
ToolInfo { name: "sphinx-build", runtime: "python", install_methods: &[InstallMethod { method: "pip", package: "sphinx" }] },
ToolInfo { name: "pip", runtime: "python", install_methods: &[InstallMethod { method: "apt", package: "python3-pip" }] },
ToolInfo { name: "uv", runtime: "python", install_methods: &[InstallMethod { method: "pip", package: "uv" }] },
ToolInfo { name: "jsonlint", runtime: "python", install_methods: &[InstallMethod { method: "pip", package: "demjson3" }] },
ToolInfo { name: "cpplint", runtime: "python", install_methods: &[InstallMethod { method: "pip", package: "cpplint" }] },
ToolInfo { name: "black", runtime: "python", install_methods: &[InstallMethod { method: "pip", package: "black" }] },
ToolInfo { name: "pytest", runtime: "python", install_methods: &[InstallMethod { method: "pip", package: "pytest" }] },
ToolInfo { name: "a2x", runtime: "python", install_methods: &[InstallMethod { method: "apt", package: "asciidoc" }] },
ToolInfo { name: "mako-render", runtime: "python", install_methods: &[InstallMethod { method: "pip", package: "mako" }] },
ToolInfo { name: "python3", runtime: "python", install_methods: &[InstallMethod { method: "apt", package: "python3" }] },
ToolInfo { name: "marp", runtime: "node", install_methods: &[InstallMethod { method: "npm", package: "@marp-team/marp-cli" }] },
ToolInfo { name: "mmdc", runtime: "node", install_methods: &[InstallMethod { method: "npm", package: "@mermaid-js/mermaid-cli" }] },
ToolInfo { name: "markdownlint", runtime: "node", install_methods: &[InstallMethod { method: "npm", package: "markdownlint-cli" }] },
ToolInfo { name: "prettier", runtime: "node", install_methods: &[InstallMethod { method: "npm", package: "prettier" }] },
ToolInfo { name: "eslint", runtime: "node", install_methods: &[InstallMethod { method: "npm", package: "eslint" }] },
ToolInfo { name: "htmlhint", runtime: "node", install_methods: &[InstallMethod { method: "npm", package: "htmlhint" }] },
ToolInfo { name: "jshint", runtime: "node", install_methods: &[InstallMethod { method: "npm", package: "jshint" }] },
ToolInfo { name: "npm", runtime: "node", install_methods: &[InstallMethod { method: "apt", package: "npm" }] },
ToolInfo { name: "node", runtime: "node", install_methods: &[InstallMethod { method: "apt", package: "nodejs" }] },
ToolInfo { name: "mdl", runtime: "ruby", install_methods: &[InstallMethod { method: "gem", package: "mdl" }] },
ToolInfo { name: "bundle", runtime: "ruby", install_methods: &[InstallMethod { method: "gem", package: "bundler" }] },
ToolInfo { name: "ruby", runtime: "ruby", install_methods: &[InstallMethod { method: "apt", package: "ruby" }] },
ToolInfo { name: "mdbook", runtime: "rust", install_methods: &[InstallMethod { method: "cargo", package: "mdbook" }] },
ToolInfo { name: "rumdl", runtime: "rust", install_methods: &[
InstallMethod { method: "binary", package: "rumdl" },
InstallMethod { method: "cargo", package: "rumdl" },
]},
ToolInfo { name: "zola", runtime: "rust", install_methods: &[
InstallMethod { method: "binary", package: "zola" },
]},
ToolInfo { name: "taplo", runtime: "rust", install_methods: &[
InstallMethod { method: "binary", package: "taplo" },
InstallMethod { method: "cargo", package: "taplo-cli" },
]},
ToolInfo { name: "cargo", runtime: "rust", install_methods: &[InstallMethod { method: "apt", package: "cargo" }] },
ToolInfo { name: "rustc", runtime: "rust", install_methods: &[InstallMethod { method: "apt", package: "rustc" }] },
ToolInfo { name: "perl", runtime: "perl", install_methods: &[InstallMethod { method: "apt", package: "perl" }] },
ToolInfo { name: "markdown", runtime: "perl", install_methods: &[InstallMethod { method: "apt", package: "markdown" }] },
ToolInfo { name: "checkpatch.pl", runtime: "perl", install_methods: &[InstallMethod { method: "binary", package: "checkpatch.pl" }] },
ToolInfo { name: "perltidy", runtime: "perl", install_methods: &[InstallMethod { method: "apt", package: "perltidy" }] },
ToolInfo { name: "xelatex", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "texlive-xetex" }] },
ToolInfo { name: "arm-none-eabi-gcc", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "gcc-arm-none-eabi" }] },
ToolInfo { name: "arm-none-eabi-ar", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "binutils-arm-none-eabi" }] },
ToolInfo { name: "arm-none-eabi-objcopy", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "binutils-arm-none-eabi" }] },
ToolInfo { name: "shellcheck", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "shellcheck" }] },
ToolInfo { name: "luacheck", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "lua-check" }] },
ToolInfo { name: "cppcheck", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "cppcheck" }] },
ToolInfo { name: "clang-tidy", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "clang-tidy" }] },
ToolInfo { name: "gcc", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "gcc" }] },
ToolInfo { name: "g++", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "g++" }] },
ToolInfo { name: "clang", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "clang" }] },
ToolInfo { name: "clang++", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "clang" }] },
ToolInfo { name: "ar", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "binutils" }] },
ToolInfo { name: "make", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "make" }] },
ToolInfo { name: "jq", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "jq" }] },
ToolInfo { name: "aspell", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "aspell" }] },
ToolInfo { name: "pandoc", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "pandoc" }] },
ToolInfo { name: "pdflatex", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "texlive-latex-base" }] },
ToolInfo { name: "qpdf", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "qpdf" }] },
ToolInfo { name: "dot", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "graphviz" }] },
ToolInfo { name: "drawio", runtime: "system", install_methods: &[InstallMethod { method: "binary", package: "drawio" }] },
ToolInfo { name: "libreoffice", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "libreoffice" }] },
ToolInfo { name: "flock", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "util-linux" }] },
ToolInfo { name: "uname", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "coreutils" }] },
ToolInfo { name: "sh", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "dash" }] },
ToolInfo { name: "git", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "git" }] },
ToolInfo { name: "pdfunite", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "poppler-utils" }] },
ToolInfo { name: "google-chrome", runtime: "system", install_methods: &[InstallMethod { method: "binary", package: "google-chrome-stable" }] },
ToolInfo { name: "objdump", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "binutils" }] },
ToolInfo { name: "tidy", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "tidy" }] },
ToolInfo { name: "xmllint", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "libxml2-utils" }] },
ToolInfo { name: "clojure", runtime: "jvm", install_methods: &[
InstallMethod { method: "apt", package: "clojure" },
InstallMethod { method: "brew", package: "clojure/tools/clojure" },
]},
ToolInfo { name: "svglint", runtime: "node", install_methods: &[InstallMethod { method: "npm", package: "svglint" }] },
ToolInfo { name: "svgo", runtime: "node", install_methods: &[InstallMethod { method: "npm", package: "svgo" }] },
ToolInfo { name: "cmakelint", runtime: "python", install_methods: &[InstallMethod { method: "pip", package: "cmakelint" }] },
ToolInfo { name: "protoc", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "protobuf-compiler" }] },
ToolInfo { name: "sass", runtime: "node", install_methods: &[InstallMethod { method: "npm", package: "sass" }] },
ToolInfo { name: "hadolint", runtime: "system", install_methods: &[
InstallMethod { method: "binary", package: "hadolint" },
InstallMethod { method: "brew", package: "hadolint" },
InstallMethod { method: "apt", package: "hadolint" },
]},
ToolInfo { name: "php", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "php-cli" }] },
ToolInfo { name: "checkstyle", runtime: "jvm", install_methods: &[InstallMethod { method: "apt", package: "checkstyle" }] },
ToolInfo { name: "yq", runtime: "system", install_methods: &[
InstallMethod { method: "pip", package: "yq" },
InstallMethod { method: "snap", package: "yq" },
InstallMethod { method: "apt", package: "yq" },
]},
ToolInfo { name: "stylelint", runtime: "node", install_methods: &[InstallMethod { method: "npm", package: "stylelint" }] },
ToolInfo { name: "jslint", runtime: "node", install_methods: &[InstallMethod { method: "npm", package: "jslint" }] },
ToolInfo { name: "standard", runtime: "node", install_methods: &[InstallMethod { method: "npm", package: "standard" }] },
ToolInfo { name: "htmllint", runtime: "node", install_methods: &[InstallMethod { method: "npm", package: "htmllint-cli" }] },
ToolInfo { name: "slidev", runtime: "node", install_methods: &[InstallMethod { method: "npm", package: "@slidev/cli" }] },
ToolInfo { name: "perlcritic", runtime: "perl", install_methods: &[InstallMethod { method: "apt", package: "libperl-critic-perl" }] },
ToolInfo { name: "jekyll", runtime: "ruby", install_methods: &[InstallMethod { method: "gem", package: "jekyll" }] },
ToolInfo { name: "true", runtime: "system", install_methods: &[InstallMethod { method: "apt", package: "coreutils" }] },
];
inventory::collect!(ToolInfo);
pub fn all_tools() -> impl Iterator<Item = &'static ToolInfo> {
TOOLS.iter().chain(inventory::iter::<ToolInfo>)
}
pub fn tool_info(tool: &str) -> Option<&'static ToolInfo> {
all_tools().find(|t| t.name == tool)
}
#[cfg(test)]
mod registry_tests {
use super::*;
#[test]
fn tool_names_are_unique_across_central_and_submitted() {
let mut seen = std::collections::HashSet::new();
let mut dups: Vec<&str> = Vec::new();
for t in all_tools() {
if !seen.insert(t.name) {
dups.push(t.name);
}
}
dups.sort_unstable();
assert!(dups.is_empty(), "duplicate tool registry entries: {dups:?}");
}
}
pub fn tool_install_command(tool: &str) -> Option<String> {
tool_info(tool).and_then(|t| t.install_methods.first().map(InstallMethod::command))
}
pub fn tool_runtime(tool: &str) -> Option<&'static str> {
tool_info(tool).map(|t| t.runtime)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn github_token_prefers_github_token_and_skips_empty() {
let lookup = |vars: &[(&str, &str)], name: &str| {
vars.iter().find(|(k, _)| *k == name).map(|(_, v)| (*v).to_string())
};
assert_eq!(
github_token_from(|n| lookup(&[("GITHUB_TOKEN", "a"), ("GH_TOKEN", "b")], n)),
Some("a".to_string())
);
assert_eq!(
github_token_from(|n| lookup(&[("GH_TOKEN", "b")], n)),
Some("b".to_string())
);
assert_eq!(
github_token_from(|n| lookup(&[("GITHUB_TOKEN", ""), ("GH_TOKEN", "b")], n)),
Some("b".to_string())
);
assert_eq!(
github_token_from(|n| lookup(&[("GITHUB_TOKEN", " ")], n)),
None
);
assert_eq!(github_token_from(|n| lookup(&[], n)), None);
}
#[test]
fn sudo_argv_matches_runtime() {
let prefix = sudo_argv();
if crate::platform::needs_sudo() {
assert_eq!(prefix, &["sudo"]);
} else {
assert!(prefix.is_empty());
}
}
#[test]
fn user_gem_bin_dirs_collects_existing_layouts() {
let home = tempfile::TempDir::new().unwrap();
assert!(user_gem_bin_dirs(home.path(), None).is_empty());
let upstream = home.path().join(".gem/ruby/3.2.0/bin");
let debian = home.path().join(".local/share/gem/ruby/3.3.0/bin");
let gem_home = home.path().join("mygems");
std::fs::create_dir_all(&upstream).unwrap();
std::fs::create_dir_all(&debian).unwrap();
std::fs::create_dir_all(gem_home.join("bin")).unwrap();
std::fs::create_dir_all(home.path().join(".gem/ruby/2.7.0")).unwrap();
let dirs = user_gem_bin_dirs(home.path(), Some(gem_home.as_os_str()));
assert_eq!(dirs, vec![gem_home.join("bin"), upstream, debian]);
}
#[test]
fn path_with_appended_dirs_appends_only_missing() {
use std::path::PathBuf;
let path = std::ffi::OsString::from("/usr/bin:/home/u/.gem/ruby/3.2.0/bin");
let dirs = vec![
PathBuf::from("/home/u/.gem/ruby/3.2.0/bin"),
PathBuf::from("/home/u/mygems/bin"),
];
let new_path = path_with_appended_dirs(&path, &dirs).unwrap();
assert_eq!(new_path, "/usr/bin:/home/u/.gem/ruby/3.2.0/bin:/home/u/mygems/bin");
assert!(path_with_appended_dirs(&new_path, &dirs).is_none());
assert!(path_with_appended_dirs(&path, &[]).is_none());
}
#[test]
fn ancestor_writability_walks_to_existing_dir() {
let dir = tempfile::TempDir::new().unwrap();
assert!(nearest_existing_ancestor_is_writable(&dir.path().join("a/b/c")));
if !crate::platform::is_root() {
crate::platform::set_permissions_mode(dir.path(), 0o555).unwrap();
assert!(!nearest_existing_ancestor_is_writable(&dir.path().join("a/b/c")));
crate::platform::set_permissions_mode(dir.path(), 0o755).unwrap();
}
}
#[test]
fn apt_describe_shape_is_correct() {
let steps = describe("apt", &["cowsay"]);
assert_eq!(steps.len(), 2);
let update = &steps[0];
let upd_idx = update.iter().position(|s| s == "apt-get").expect("apt-get in update argv");
assert_eq!(update[upd_idx + 1], "update");
assert_eq!(update.len(), upd_idx + 2);
let install = &steps[1];
let pkgmgr_idx = install.iter().position(|s| s == "apt-get").expect("apt-get in install argv");
assert_eq!(install[pkgmgr_idx + 1], "install");
assert_eq!(install[pkgmgr_idx + 2], "-y");
assert_eq!(install[pkgmgr_idx + 3], "cowsay");
if install[0] == "sudo" {
assert_eq!(pkgmgr_idx, 1);
} else {
assert_eq!(pkgmgr_idx, 0);
}
}
#[test]
fn apt_batch_describe_collapses() {
let steps = describe("apt", &["foo", "bar", "baz"]);
assert_eq!(steps.len(), 2);
let install = &steps[1];
let pkgmgr_idx = install.iter().position(|s| s == "apt-get").expect("apt-get in argv");
assert_eq!(&install[pkgmgr_idx + 3..], &["foo", "bar", "baz"]);
}
#[test]
fn pip_describe_never_has_sudo() {
let steps = describe("pip", &["ruff"]);
assert_eq!(steps.len(), 1);
let argv = &steps[0];
assert_eq!(argv[0], "pip");
assert!(!argv.contains(&"sudo".to_string()));
}
#[test]
fn uv_describe_targets_path_python3_without_sudo() {
let steps = describe("uv", &["flask==3.1.0", "pywin32==312 ; sys_platform == 'win32'"]);
assert_eq!(steps.len(), 1);
let argv = &steps[0];
assert_eq!(argv[..5], ["uv", "pip", "install", "--python", "python3"].map(String::from));
assert_eq!(argv.contains(&"--system".to_string()), !python_probe().in_venv);
assert_eq!(&argv[argv.len() - 2..], &["flask==3.1.0", "pywin32==312 ; sys_platform == 'win32'"]);
assert!(!argv.contains(&"sudo".to_string()));
}
#[test]
fn uv_argv_non_venv_uses_system_and_optional_prefix() {
assert_eq!(uv_pip_install_argv_for(false, None), ["uv", "pip", "install", "--python", "python3", "--system"].map(String::from));
assert_eq!(uv_pip_install_argv_for(false, Some("/home/u/.local")),
["uv", "pip", "install", "--python", "python3", "--system", "--prefix", "/home/u/.local"].map(String::from));
}
#[test]
fn uv_argv_venv_omits_system() {
let argv = uv_pip_install_argv_for(true, None);
assert_eq!(argv, ["uv", "pip", "install", "--python", "python3"].map(String::from));
assert!(!argv.contains(&"--system".to_string()));
}
#[test]
fn binary_describe_has_no_shell_metachars() {
for pkg in &["taplo", "rumdl", "zola"] {
let steps = describe("binary", &[pkg]);
assert!(steps.len() >= 3, "binary {pkg} should have >=3 steps");
for step in &steps {
for arg in step {
for forbidden in &['|', '>', '<', ';', '&'] {
assert!(
!arg.contains(*forbidden),
"binary {pkg} step contains shell metachar '{forbidden}': {arg:?}"
);
}
}
}
}
}
}