1use std::process::Command;
19
20use crate::detect::Forge;
21use crate::diagnostic::{Diagnostic, Reason};
22use crate::error::RkError;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Tool {
27 Git,
29 Sh,
32 ForgeCli(Forge),
34 Nix,
36 Cargo,
38 GitCliff,
41 Uv,
43}
44
45pub const GIT_FLOOR: (u32, u32, u32) = (2, 36, 0);
48
49impl Tool {
50 #[must_use]
52 pub const fn name(self) -> &'static str {
53 match self {
54 Self::Git => "git",
55 Self::Sh => "sh",
56 Self::ForgeCli(forge) => forge.cli(),
57 Self::Nix => "nix",
58 Self::Cargo => "cargo",
59 Self::GitCliff => "git-cliff",
60 Self::Uv => "uv",
61 }
62 }
63
64 #[must_use]
67 pub const fn override_var(self) -> Option<&'static str> {
68 match self {
69 Self::Git => Some("RK_GIT_BIN"),
70 Self::Sh => Some("RK_SH_BIN"),
71 Self::ForgeCli(forge) => Some(forge.cli_override()),
72 Self::Nix => Some("RK_NIX_BIN"),
73 Self::Cargo => None,
74 Self::GitCliff => Some("RK_GIT_CLIFF_BIN"),
75 Self::Uv => Some("RK_UV_BIN"),
76 }
77 }
78
79 #[must_use]
84 pub fn program(self) -> std::ffi::OsString {
85 self.override_var()
86 .and_then(std::env::var_os)
87 .filter(|value| !value.is_empty())
88 .unwrap_or_else(|| self.name().into())
89 }
90
91 #[must_use]
95 pub const fn runs_in_target(self) -> bool {
96 matches!(
97 self,
98 Self::Sh | Self::Nix | Self::Cargo | Self::GitCliff | Self::Uv
99 )
100 }
101
102 const fn presence_args(self) -> &'static [&'static str] {
105 match self {
106 Self::Sh => &["-c", ":"],
107 _ => &["--version"],
108 }
109 }
110
111 #[must_use]
113 pub const fn floor(self) -> Option<(u32, u32, u32)> {
114 match self {
115 Self::Git => Some(GIT_FLOOR),
116 Self::ForgeCli(forge) => Some(forge.cli_floor()),
117 _ => None,
118 }
119 }
120
121 fn install(self) -> String {
123 self.override_var().map_or_else(
124 || format!("install {} on PATH", self.name()),
125 |var| format!("install {} on PATH, or point {var} at it", self.name()),
126 )
127 }
128
129 fn upgrade(self) -> String {
131 match self {
132 Self::ForgeCli(forge) => forge.cli_upgrade().to_owned(),
133 _ => format!("upgrade {}", self.name()),
134 }
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct Unmet {
141 pub tool: Tool,
143 pub problem: String,
145 pub action: String,
147}
148
149#[must_use]
152pub fn check(tool: Tool, target: Option<&std::path::Path>) -> Option<Unmet> {
153 check_program(tool, &tool.program(), target)
154}
155
156#[must_use]
160pub fn check_program(
161 tool: Tool,
162 program: &std::ffi::OsStr,
163 target: Option<&std::path::Path>,
164) -> Option<Unmet> {
165 judge(tool, program, target, None)
166}
167
168#[must_use]
172pub fn check_under_path(
173 tool: Tool,
174 target: Option<&std::path::Path>,
175 path: &std::ffi::OsStr,
176) -> Option<Unmet> {
177 judge(tool, &tool.program(), target, Some(path))
178}
179
180fn judge(
181 tool: Tool,
182 program: &std::ffi::OsStr,
183 target: Option<&std::path::Path>,
184 path: Option<&std::ffi::OsStr>,
185) -> Option<Unmet> {
186 let name = tool.name();
187 let mut command = Command::new(program);
188 if let Some(dir) = target.filter(|_| tool.runs_in_target()) {
189 command.current_dir(dir);
190 }
191 if let Some(path) = path {
192 command.env("PATH", path);
193 }
194 let answer = command
195 .args(tool.presence_args())
196 .stdin(std::process::Stdio::null())
197 .output();
198 let output = match answer {
199 Ok(output) if output.status.success() => output,
200 Ok(output) => {
201 return Some(Unmet {
202 tool,
203 problem: format!(
204 "{name} does not answer {}: {}",
205 tool.presence_args().join(" "),
206 crate::maintenance::last_line(&output.stderr)
207 ),
208 action: tool.install(),
209 });
210 }
211 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
212 let problem = match tool.override_var().and_then(|var| {
213 std::env::var(var)
214 .ok()
215 .filter(|value| !value.is_empty())
216 .map(|value| (var, value))
217 }) {
218 Some((var, value)) => {
219 format!("{name} is missing: {var} names {value}, which does not exist")
220 }
221 None => format!("{name} is missing: it is not on PATH"),
222 };
223 return Some(Unmet {
224 tool,
225 problem,
226 action: tool.install(),
227 });
228 }
229 Err(source) => {
230 return Some(Unmet {
231 tool,
232 problem: format!("{name} does not start: {source}"),
233 action: tool.install(),
234 });
235 }
236 };
237 let floor = tool.floor()?;
238 let show = |(major, minor, patch): (u32, u32, u32)| format!("{major}.{minor}.{patch}");
239 match crate::probes::parse_cli_version(&String::from_utf8_lossy(&output.stdout)) {
240 Some(found) if found >= floor => None,
241 Some(found) => Some(Unmet {
242 tool,
243 problem: format!(
244 "{name} {} is below the {} rk calls",
245 show(found),
246 show(floor)
247 ),
248 action: tool.upgrade(),
249 }),
250 None => Some(Unmet {
251 tool,
252 problem: format!("{name} answers no version to hold against {}", show(floor)),
253 action: tool.upgrade(),
254 }),
255 }
256}
257
258#[must_use]
261pub fn unmet(tools: &[Tool], target: Option<&std::path::Path>) -> Vec<Unmet> {
262 let mut seen: Vec<Tool> = Vec::new();
263 tools
264 .iter()
265 .copied()
266 .filter(|tool| {
267 let fresh = !seen.contains(tool);
268 seen.push(*tool);
269 fresh
270 })
271 .filter_map(|tool| check(tool, target))
272 .collect()
273}
274
275#[must_use]
278pub fn refusal(verb: &str, tools: &[Tool], unmet: &[Unmet]) -> RkError {
279 let problems: Vec<&str> = unmet.iter().map(|unmet| unmet.problem.as_str()).collect();
280 let actions: Vec<&str> = unmet.iter().map(|unmet| unmet.action.as_str()).collect();
281 let mut names: Vec<&str> = Vec::new();
282 for tool in tools {
283 if !names.contains(&tool.name()) {
284 names.push(tool.name());
285 }
286 }
287 RkError::refusal(
288 Diagnostic::new(
289 Reason::PrerequisiteUnmet,
290 format!(
291 "{verb} runs tools this host cannot serve: {}",
292 problems.join("; ")
293 ),
294 )
295 .expected(format!(
296 "every tool {verb} runs on PATH, at or above its floor: {}",
297 names.join(", ")
298 ))
299 .action(actions.join("; "))
300 .target_state("unchanged"),
301 )
302}
303
304pub fn require_in(
315 verb: &str,
316 tools: &[Tool],
317 target: Option<&std::path::Path>,
318) -> Result<(), RkError> {
319 let found = unmet(tools, target);
320 if found.is_empty() {
321 return Ok(());
322 }
323 Err(refusal(verb, tools, &found))
324}
325
326pub fn require(verb: &str, tools: &[Tool]) -> Result<(), RkError> {
332 require_in(verb, tools, None)
333}
334
335#[cfg(test)]
336mod tests {
337 use super::{GIT_FLOOR, Tool};
338 use crate::detect::Forge;
339
340 #[test]
343 fn the_floors_are_the_releases_the_calls_need() {
344 assert_eq!(GIT_FLOOR, (2, 36, 0));
345 assert_eq!(Tool::Git.floor(), Some((2, 36, 0)));
346 assert_eq!(Tool::ForgeCli(Forge::Github).floor(), Some((2, 57, 0)));
347 assert_eq!(Tool::Nix.floor(), None);
348 }
349
350 #[test]
352 fn each_tool_names_its_override() {
353 assert_eq!(Tool::Git.override_var(), Some("RK_GIT_BIN"));
354 assert_eq!(
355 Tool::ForgeCli(Forge::Gitlab).override_var(),
356 Some("RK_GLAB_BIN")
357 );
358 assert_eq!(Tool::Cargo.override_var(), None);
359 }
360}