Skip to main content

release_kit/
requirements.rs

1//! The tools `rk` itself runs, and the check every verb makes before it
2//! acts.
3//!
4//! A tool here is a dependency of the `rk` application: the host supplies
5//! it, and a verb that runs it checks it at the start of the run, before
6//! its first read of a forge and its first write. A missing tool, or one
7//! below the floor this binary calls, stops the verb with one error that
8//! names every such tool. Which tools a verb checks comes from the verb,
9//! its flags, and the target's record, never from which tools the host
10//! happens to carry.
11//!
12//! A project's own hook manager is not here. `rk integrate` asks git to
13//! run the clone's `pre-merge-commit` hook, and git runs whatever the
14//! project installed.
15//!
16//! SATISFIES tool-requirements:a-verb-checks-its-tools-first
17
18use std::process::Command;
19
20use crate::detect::Forge;
21use crate::diagnostic::{Diagnostic, Reason};
22use crate::error::RkError;
23
24/// One tool `rk` runs.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Tool {
27    /// git, which every verb that reads or writes a repository runs.
28    Git,
29    /// The POSIX shell every setup step and recorded build command runs
30    /// through.
31    Sh,
32    /// A forge CLI: gh or glab.
33    ForgeCli(Forge),
34    /// Nix, which `rk self-depend sync` updates and builds a devshell with.
35    Nix,
36    /// cargo, which `rk integrate` asks for a crate's package listing.
37    Cargo,
38    /// git-cliff, which the release engine computes a version and a
39    /// changelog with.
40    GitCliff,
41    /// uv, which the python driver adapter runs.
42    Uv,
43}
44
45/// git's floor: `git hook run`, which the integration gate runs, first
46/// shipped in git 2.36.0.
47pub const GIT_FLOOR: (u32, u32, u32) = (2, 36, 0);
48
49impl Tool {
50    /// The tool's binary name.
51    #[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    /// The environment variable that substitutes the binary, where one
65    /// exists.
66    #[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    /// The program this process spawns for the tool: the override where
80    /// it is set and not empty, else the binary name, which `PATH`
81    /// resolves. Every spawn of the tool takes its program from here, so
82    /// the check judges the program that runs.
83    #[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    /// Whether rk runs the tool with the target as the working directory,
92    /// so a relative override or a relative `PATH` entry resolves there.
93    /// git and the forge CLI run from the caller's directory.
94    #[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    /// The call that answers whether the tool runs, and prints its
103    /// version where the tool has a floor.
104    const fn presence_args(self) -> &'static [&'static str] {
105        match self {
106            Self::Sh => &["-c", ":"],
107            _ => &["--version"],
108        }
109    }
110
111    /// The lowest version this binary calls, where the tool has one.
112    #[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    /// What the operator does where the tool does not run.
122    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    /// What the operator does where the tool runs below its floor.
130    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/// Why one tool cannot serve the verb.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct Unmet {
141    /// The tool.
142    pub tool: Tool,
143    /// What is wrong with it, for the message.
144    pub problem: String,
145    /// What the operator does about it.
146    pub action: String,
147}
148
149/// Check one tool: it runs, and it answers a version at or above its
150/// floor where it has one. `None` where it serves.
151#[must_use]
152pub fn check(tool: Tool, target: Option<&std::path::Path>) -> Option<Unmet> {
153    check_program(tool, &tool.program(), target)
154}
155
156/// [`check`] for one program a caller already resolved, such as the
157/// absolute path of the forge CLI it is about to run, so the check judges
158/// that exact file.
159#[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/// [`check`] under the search path a child receives, where rk hands the
169/// child another `PATH` than its own, so the check searches as the spawn
170/// searches.
171#[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/// Every tool of `tools` that cannot serve, each once, judged where rk
259/// runs it: a tool that runs in the target is checked from `target`.
260#[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/// The refusal that names every tool of `unmet`, for `verb`, which runs
276/// `tools`.
277#[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
304/// Check every tool a verb runs, before it acts.
305///
306/// One error names each tool that cannot serve the verb. A tool that rk
307/// runs in the target is checked from `target`, where a relative override
308/// or `PATH` entry resolves the way the spawn resolves it.
309///
310/// # Errors
311///
312/// [`Reason::PrerequisiteUnmet`] naming every missing or too-old tool,
313/// with the target unchanged.
314pub 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
326/// [`require_in`] for tools that all run from the caller's directory.
327///
328/// # Errors
329///
330/// As [`require_in`].
331pub 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    /// The floors this binary calls: git for `git hook run`, gh for
341    /// `gh auth status --active`.
342    #[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    /// Each tool names the override the rest of the binary honors.
351    #[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}