use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::extras::Language;
pub const DEFAULT_RUST_DEV_TOOLS: &[&str] = &[
"cargo-edit",
"cargo-sort",
"cargo-machete",
"cargo-deny",
"cargo-llvm-cov",
];
const DEFAULT_PYTHON_PM: &str = "uv";
const DEFAULT_NODE_PM: &str = "pnpm";
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ToolsConfig {
#[serde(default)]
pub python_package_manager: Option<String>,
#[serde(default)]
pub node_package_manager: Option<String>,
#[serde(default)]
pub rust_dev_tools: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct LangContext<'a> {
pub tools: &'a ToolsConfig,
pub run_wrapper: Option<&'a str>,
pub extra_lint_paths: &'a [String],
pub project_file: Option<&'a str>,
}
impl<'a> LangContext<'a> {
pub fn default(tools: &'a ToolsConfig) -> Self {
Self {
tools,
run_wrapper: None,
extra_lint_paths: &[],
project_file: None,
}
}
}
pub fn wrap_command(cmd: String, wrapper: Option<&str>) -> String {
match wrapper {
Some(w) => format!("{w} {cmd}"),
None => cmd,
}
}
pub fn append_paths(cmd: String, paths: &[String]) -> String {
if paths.is_empty() {
cmd
} else {
format!("{} {}", cmd, paths.join(" "))
}
}
pub fn require_tool(tool: &str) -> String {
format!("command -v {tool} >/dev/null 2>&1")
}
pub fn require_tools(tools: &[&str]) -> String {
tools.iter().map(|t| require_tool(t)).collect::<Vec<_>>().join(" && ")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolchainCommand {
Build,
Test,
Setup,
Update,
Clean,
}
#[must_use]
pub fn required_tools_for_language(command: ToolchainCommand, lang: Language, tools: &ToolsConfig) -> Vec<String> {
match lang {
Language::Rust if command == ToolchainCommand::Update => {
vec!["cargo".to_string(), "cargo-upgrade".to_string()]
}
Language::Rust => vec!["cargo".to_string()],
Language::Python => vec![tools.python_pm().to_string()],
Language::Node => vec![tools.node_pm().to_string()],
Language::Wasm => vec!["wasm-pack".to_string()],
Language::Ruby => vec!["ruby".to_string(), "bundle".to_string()],
Language::Php => vec!["composer".to_string()],
Language::Elixir => vec!["mix".to_string()],
Language::Go => vec!["go".to_string()],
Language::Java => vec!["mvn".to_string()],
Language::Csharp => vec!["dotnet".to_string()],
Language::R => vec!["Rscript".to_string()],
Language::Kotlin | Language::KotlinAndroid => vec!["gradle".to_string()],
Language::Swift => vec!["swift".to_string()],
Language::Dart => vec!["dart".to_string()],
Language::Gleam => vec!["gleam".to_string()],
Language::Zig => vec!["zig".to_string()],
Language::Ffi | Language::Jni => vec!["cargo".to_string()],
Language::C => vec![],
}
}
pub fn require_ruby_bundler() -> String {
format!(
"{} && {} >/dev/null 2>&1",
require_tool("ruby"),
ruby_bundle("--version")
)
}
pub(crate) fn ruby_bundle(arguments: &str) -> String {
format!("BUNDLE_PATH=vendor/bundle ruby -S bundle {arguments}")
}
pub(crate) fn ruby_bundle_exec(command: &str) -> String {
ruby_bundle(&format!("exec ruby -S {command}"))
}
impl ToolsConfig {
pub fn python_pm(&self) -> &str {
self.python_package_manager.as_deref().unwrap_or(DEFAULT_PYTHON_PM)
}
pub fn node_pm(&self) -> &str {
self.node_package_manager.as_deref().unwrap_or(DEFAULT_NODE_PM)
}
pub fn rust_tools(&self) -> Vec<&str> {
match self.rust_dev_tools.as_deref() {
Some(list) => list.iter().map(String::as_str).collect(),
None => DEFAULT_RUST_DEV_TOOLS.to_vec(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_documented_values() {
let cfg = ToolsConfig::default();
assert_eq!(cfg.python_pm(), "uv");
assert_eq!(cfg.node_pm(), "pnpm");
assert_eq!(
cfg.rust_tools(),
vec![
"cargo-edit",
"cargo-sort",
"cargo-machete",
"cargo-deny",
"cargo-llvm-cov"
]
);
}
#[test]
fn getters_return_user_value_when_set() {
let cfg = ToolsConfig {
python_package_manager: Some("pip".to_string()),
node_package_manager: Some("yarn".to_string()),
rust_dev_tools: Some(vec!["cargo-foo".to_string(), "cargo-bar".to_string()]),
};
assert_eq!(cfg.python_pm(), "pip");
assert_eq!(cfg.node_pm(), "yarn");
assert_eq!(cfg.rust_tools(), vec!["cargo-foo", "cargo-bar"]);
}
#[test]
fn empty_rust_dev_tools_is_respected() {
let cfg = ToolsConfig {
rust_dev_tools: Some(vec![]),
..Default::default()
};
assert!(cfg.rust_tools().is_empty());
}
#[test]
fn deserializes_from_toml() {
let toml_str = r#"
python_package_manager = "poetry"
node_package_manager = "npm"
rust_dev_tools = ["cargo-edit"]
"#;
let cfg: ToolsConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.python_pm(), "poetry");
assert_eq!(cfg.node_pm(), "npm");
assert_eq!(cfg.rust_tools(), vec!["cargo-edit"]);
}
#[test]
fn require_tool_emits_command_v() {
assert_eq!(require_tool("ruff"), "command -v ruff >/dev/null 2>&1");
}
#[test]
fn ruby_bundler_precondition_checks_the_active_interpreter() {
assert_eq!(
require_ruby_bundler(),
"command -v ruby >/dev/null 2>&1 && BUNDLE_PATH=vendor/bundle ruby -S bundle --version >/dev/null 2>&1"
);
}
#[test]
fn ruby_bundle_exec_forces_bundler_and_gem_tool_through_active_interpreter() {
assert_eq!(
ruby_bundle_exec("rubocop -A ."),
"BUNDLE_PATH=vendor/bundle ruby -S bundle exec ruby -S rubocop -A ."
);
}
#[cfg(unix)]
fn write_executable(path: &std::path::Path, content: &str) {
use std::os::unix::fs::PermissionsExt as _;
std::fs::write(path, content).expect("write executable");
let mut permissions = std::fs::metadata(path).expect("metadata").permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).expect("chmod executable");
}
#[cfg(unix)]
#[test]
fn ruby_bundle_exec_survives_foreign_tool_shebangs() {
let temp = tempfile::tempdir().expect("tempdir");
let ruby = temp.path().join("ruby");
let bundle = temp.path().join("bundle");
let rubocop = temp.path().join("rubocop");
let marker = temp.path().join("marker");
write_executable(
&ruby,
"#!/bin/sh\n[ \"$1\" = -S ] || exit 91\nshift\nscript=$(command -v \"$1\") || exit 92\nshift\nexec /bin/sh \"$script\" \"$@\"\n",
);
write_executable(
&bundle,
"#!/missing/foreign/ruby\n[ \"$BUNDLE_PATH\" = vendor/bundle ] || exit 94\n[ \"$1\" = exec ] || exit 93\nshift\nexec \"$@\"\n",
);
write_executable(
&rubocop,
"#!/missing/foreign/ruby\nprintf '%s\\n' active > \"$ABI_PROBE\"\n",
);
let path = format!(
"{}:{}",
temp.path().display(),
std::env::var("PATH").unwrap_or_default()
);
let run = |command: &str| {
std::process::Command::new("/bin/sh")
.args(["-c", command])
.env("PATH", &path)
.env("ABI_PROBE", &marker)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("run ABI probe")
};
assert!(!run("BUNDLE_PATH=vendor/bundle bundle exec ruby -S rubocop").success());
assert!(!run("ruby -S bundle exec ruby -S rubocop").success());
assert!(!run("BUNDLE_PATH=vendor/bundle ruby -S bundle exec rubocop").success());
assert!(!marker.exists());
assert!(run(&ruby_bundle_exec("rubocop")).success());
assert_eq!(std::fs::read_to_string(marker).expect("read marker"), "active\n");
}
#[test]
fn require_tools_joins_with_and() {
assert_eq!(
require_tools(&["go", "gofmt"]),
"command -v go >/dev/null 2>&1 && command -v gofmt >/dev/null 2>&1"
);
}
#[test]
fn empty_toml_uses_defaults() {
let cfg: ToolsConfig = toml::from_str("").unwrap();
assert_eq!(cfg.python_pm(), "uv");
assert_eq!(cfg.node_pm(), "pnpm");
}
#[test]
fn rust_required_tools_include_cargo_edit_only_for_update() {
let tools = ToolsConfig::default();
assert_eq!(
required_tools_for_language(ToolchainCommand::Update, Language::Rust, &tools),
vec!["cargo".to_string(), "cargo-upgrade".to_string()]
);
for command in [
ToolchainCommand::Build,
ToolchainCommand::Test,
ToolchainCommand::Setup,
ToolchainCommand::Clean,
] {
assert_eq!(
required_tools_for_language(command, Language::Rust, &tools),
vec!["cargo".to_string()],
"{command:?} must not require cargo-upgrade"
);
}
}
#[test]
fn python_and_node_required_tools_follow_the_configured_package_manager() {
let tools = ToolsConfig {
python_package_manager: Some("poetry".to_string()),
node_package_manager: Some("yarn".to_string()),
..Default::default()
};
assert_eq!(
required_tools_for_language(ToolchainCommand::Build, Language::Python, &tools),
vec!["poetry".to_string()]
);
assert_eq!(
required_tools_for_language(ToolchainCommand::Build, Language::Node, &tools),
vec!["yarn".to_string()]
);
}
#[test]
fn c_has_no_required_toolchain() {
assert!(required_tools_for_language(ToolchainCommand::Build, Language::C, &ToolsConfig::default()).is_empty());
}
#[test]
fn ffi_and_jni_require_only_cargo() {
let tools = ToolsConfig::default();
assert_eq!(
required_tools_for_language(ToolchainCommand::Build, Language::Ffi, &tools),
vec!["cargo".to_string()]
);
assert_eq!(
required_tools_for_language(ToolchainCommand::Build, Language::Jni, &tools),
vec!["cargo".to_string()]
);
}
}