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:?}"
);
}
}
}
}
}
}