use std::process::Command;
use crate::detect::Forge;
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
Git,
Sh,
ForgeCli(Forge),
Nix,
Cargo,
GitCliff,
Uv,
}
pub const GIT_FLOOR: (u32, u32, u32) = (2, 36, 0);
impl Tool {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Git => "git",
Self::Sh => "sh",
Self::ForgeCli(forge) => forge.cli(),
Self::Nix => "nix",
Self::Cargo => "cargo",
Self::GitCliff => "git-cliff",
Self::Uv => "uv",
}
}
#[must_use]
pub const fn override_var(self) -> Option<&'static str> {
match self {
Self::Git => Some("RK_GIT_BIN"),
Self::Sh => Some("RK_SH_BIN"),
Self::ForgeCli(forge) => Some(forge.cli_override()),
Self::Nix => Some("RK_NIX_BIN"),
Self::Cargo => None,
Self::GitCliff => Some("RK_GIT_CLIFF_BIN"),
Self::Uv => Some("RK_UV_BIN"),
}
}
#[must_use]
pub fn program(self) -> std::ffi::OsString {
self.override_var()
.and_then(std::env::var_os)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| self.name().into())
}
#[must_use]
pub const fn runs_in_target(self) -> bool {
matches!(
self,
Self::Sh | Self::Nix | Self::Cargo | Self::GitCliff | Self::Uv
)
}
const fn presence_args(self) -> &'static [&'static str] {
match self {
Self::Sh => &["-c", ":"],
_ => &["--version"],
}
}
#[must_use]
pub const fn floor(self) -> Option<(u32, u32, u32)> {
match self {
Self::Git => Some(GIT_FLOOR),
Self::ForgeCli(forge) => Some(forge.cli_floor()),
_ => None,
}
}
fn install(self) -> String {
self.override_var().map_or_else(
|| format!("install {} on PATH", self.name()),
|var| format!("install {} on PATH, or point {var} at it", self.name()),
)
}
fn upgrade(self) -> String {
match self {
Self::ForgeCli(forge) => forge.cli_upgrade().to_owned(),
_ => format!("upgrade {}", self.name()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unmet {
pub tool: Tool,
pub problem: String,
pub action: String,
}
#[must_use]
pub fn check(tool: Tool, target: Option<&std::path::Path>) -> Option<Unmet> {
check_program(tool, &tool.program(), target)
}
#[must_use]
pub fn check_program(
tool: Tool,
program: &std::ffi::OsStr,
target: Option<&std::path::Path>,
) -> Option<Unmet> {
judge(tool, program, target, None)
}
#[must_use]
pub fn check_under_path(
tool: Tool,
target: Option<&std::path::Path>,
path: &std::ffi::OsStr,
) -> Option<Unmet> {
judge(tool, &tool.program(), target, Some(path))
}
fn judge(
tool: Tool,
program: &std::ffi::OsStr,
target: Option<&std::path::Path>,
path: Option<&std::ffi::OsStr>,
) -> Option<Unmet> {
let name = tool.name();
let mut command = Command::new(program);
if let Some(dir) = target.filter(|_| tool.runs_in_target()) {
command.current_dir(dir);
}
if let Some(path) = path {
command.env("PATH", path);
}
let answer = command
.args(tool.presence_args())
.stdin(std::process::Stdio::null())
.output();
let output = match answer {
Ok(output) if output.status.success() => output,
Ok(output) => {
return Some(Unmet {
tool,
problem: format!(
"{name} does not answer {}: {}",
tool.presence_args().join(" "),
crate::maintenance::last_line(&output.stderr)
),
action: tool.install(),
});
}
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
let problem = match tool.override_var().and_then(|var| {
std::env::var(var)
.ok()
.filter(|value| !value.is_empty())
.map(|value| (var, value))
}) {
Some((var, value)) => {
format!("{name} is missing: {var} names {value}, which does not exist")
}
None => format!("{name} is missing: it is not on PATH"),
};
return Some(Unmet {
tool,
problem,
action: tool.install(),
});
}
Err(source) => {
return Some(Unmet {
tool,
problem: format!("{name} does not start: {source}"),
action: tool.install(),
});
}
};
let floor = tool.floor()?;
let show = |(major, minor, patch): (u32, u32, u32)| format!("{major}.{minor}.{patch}");
match crate::probes::parse_cli_version(&String::from_utf8_lossy(&output.stdout)) {
Some(found) if found >= floor => None,
Some(found) => Some(Unmet {
tool,
problem: format!(
"{name} {} is below the {} rk calls",
show(found),
show(floor)
),
action: tool.upgrade(),
}),
None => Some(Unmet {
tool,
problem: format!("{name} answers no version to hold against {}", show(floor)),
action: tool.upgrade(),
}),
}
}
#[must_use]
pub fn unmet(tools: &[Tool], target: Option<&std::path::Path>) -> Vec<Unmet> {
let mut seen: Vec<Tool> = Vec::new();
tools
.iter()
.copied()
.filter(|tool| {
let fresh = !seen.contains(tool);
seen.push(*tool);
fresh
})
.filter_map(|tool| check(tool, target))
.collect()
}
#[must_use]
pub fn refusal(verb: &str, tools: &[Tool], unmet: &[Unmet]) -> RkError {
let problems: Vec<&str> = unmet.iter().map(|unmet| unmet.problem.as_str()).collect();
let actions: Vec<&str> = unmet.iter().map(|unmet| unmet.action.as_str()).collect();
let mut names: Vec<&str> = Vec::new();
for tool in tools {
if !names.contains(&tool.name()) {
names.push(tool.name());
}
}
RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
format!(
"{verb} runs tools this host cannot serve: {}",
problems.join("; ")
),
)
.expected(format!(
"every tool {verb} runs on PATH, at or above its floor: {}",
names.join(", ")
))
.action(actions.join("; "))
.target_state("unchanged"),
)
}
pub fn require_in(
verb: &str,
tools: &[Tool],
target: Option<&std::path::Path>,
) -> Result<(), RkError> {
let found = unmet(tools, target);
if found.is_empty() {
return Ok(());
}
Err(refusal(verb, tools, &found))
}
pub fn require(verb: &str, tools: &[Tool]) -> Result<(), RkError> {
require_in(verb, tools, None)
}
#[cfg(test)]
mod tests {
use super::{GIT_FLOOR, Tool};
use crate::detect::Forge;
#[test]
fn the_floors_are_the_releases_the_calls_need() {
assert_eq!(GIT_FLOOR, (2, 36, 0));
assert_eq!(Tool::Git.floor(), Some((2, 36, 0)));
assert_eq!(Tool::ForgeCli(Forge::Github).floor(), Some((2, 57, 0)));
assert_eq!(Tool::Nix.floor(), None);
}
#[test]
fn each_tool_names_its_override() {
assert_eq!(Tool::Git.override_var(), Some("RK_GIT_BIN"));
assert_eq!(
Tool::ForgeCli(Forge::Gitlab).override_var(),
Some("RK_GLAB_BIN")
);
assert_eq!(Tool::Cargo.override_var(), None);
}
}