1use anyhow::{Context, Result, anyhow, bail};
2use semver::Version;
3use std::env;
4use std::ffi::OsString;
5use std::path::{Path, PathBuf};
6use std::process::{Command, ExitStatus, Stdio};
7
8use crate::toolchain_catalogue::GREENTIC_TOOLCHAIN_PACKAGES;
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum ToolchainChannel {
12 Stable,
13 Development,
14}
15
16impl ToolchainChannel {
17 pub fn from_executable_name(name: &str) -> Self {
18 let stem = name.strip_suffix(".exe").unwrap_or(name);
19 if stem == "greentic-dev-dev" {
20 Self::Development
21 } else {
22 Self::Stable
23 }
24 }
25}
26
27pub fn current_toolchain_channel() -> ToolchainChannel {
28 let executable_name = env::args_os()
29 .next()
30 .and_then(|arg| PathBuf::from(arg).file_name().map(|name| name.to_owned()))
31 .or_else(|| {
32 env::current_exe()
33 .ok()
34 .and_then(|path| path.file_name().map(|name| name.to_owned()))
35 });
36 executable_name
37 .as_deref()
38 .and_then(|name| name.to_str())
39 .map(ToolchainChannel::from_executable_name)
40 .unwrap_or(ToolchainChannel::Stable)
41}
42
43pub fn delegated_binary_name(name: &str) -> String {
44 delegated_binary_name_for_channel(name, current_toolchain_channel())
45}
46
47pub fn delegated_binary_name_for_channel(name: &str, channel: ToolchainChannel) -> String {
48 match channel {
49 ToolchainChannel::Stable => name.to_string(),
50 ToolchainChannel::Development => development_binary_name(name),
51 }
52}
53
54fn development_binary_name(name: &str) -> String {
55 if name == "greentic-dev" {
56 return "greentic-dev-dev".to_string();
57 }
58 if name.ends_with("-dev") {
59 name.to_string()
60 } else {
61 format!("{name}-dev")
62 }
63}
64
65pub fn resolve_binary(name: &str) -> Result<PathBuf> {
67 resolve_binary_for_channel(name, current_toolchain_channel())
68}
69
70pub fn resolve_binary_for_channel(name: &str, channel: ToolchainChannel) -> Result<PathBuf> {
71 let locale = crate::i18n::select_locale(None);
72 let resolved_name = delegated_binary_name_for_channel(name, channel);
73 let env_key = format!(
74 "GREENTIC_DEV_BIN_{}",
75 resolved_name.replace('-', "_").to_uppercase()
76 );
77 if let Ok(path) = env::var(&env_key) {
78 let pb = PathBuf::from(path);
79 if pb.exists() {
80 return Ok(pb);
81 }
82 bail!(
83 "{}",
84 crate::i18n::tf(
85 &locale,
86 "runtime.passthrough.error.env_binary_missing",
87 &[
88 ("env_key", env_key.clone()),
89 ("path", pb.display().to_string()),
90 ],
91 )
92 );
93 }
94
95 if let Ok(path) = which::which(&resolved_name) {
96 return Ok(path);
97 }
98
99 bail!(
100 "{}",
101 crate::i18n::tf(
102 &locale,
103 "runtime.passthrough.error.binary_not_found",
104 &[("name", resolved_name), ("env_key", env_key)],
105 )
106 )
107}
108
109pub fn run_passthrough(bin: &Path, args: &[OsString], verbose: bool) -> Result<ExitStatus> {
110 let locale = crate::i18n::select_locale(None);
111 if verbose {
112 eprintln!(
113 "{}",
114 crate::i18n::tf(
115 &locale,
116 "runtime.passthrough.debug.exec",
117 &[
118 ("bin", bin.display().to_string()),
119 ("args", format!("{args:?}")),
120 ],
121 )
122 );
123 let _ = Command::new(bin)
124 .arg("--version")
125 .stdout(Stdio::inherit())
126 .stderr(Stdio::inherit())
127 .status();
128 }
129
130 Command::new(bin)
131 .args(args)
132 .stdin(Stdio::inherit())
133 .stdout(Stdio::inherit())
134 .stderr(Stdio::inherit())
135 .status()
136 .map_err(|e| {
137 anyhow!(crate::i18n::tf(
138 &locale,
139 "runtime.passthrough.error.execute",
140 &[("bin", bin.display().to_string()), ("error", e.to_string())],
141 ))
142 })
143}
144
145pub fn install_all_delegated_tools(latest: bool, locale: &str) -> Result<()> {
146 ensure_cargo_binstall()?;
147 let channel = current_toolchain_channel();
148 for package in GREENTIC_TOOLCHAIN_PACKAGES {
149 let crate_name = delegated_binary_name_for_channel(package.crate_name, channel);
150 for bin_name in package.bins {
151 install_with_binstall(
152 &crate_name,
153 &delegated_binary_name_for_channel(bin_name, channel),
154 latest,
155 locale,
156 )?;
157 }
158 }
159 Ok(())
160}
161
162fn install_with_binstall(
163 crate_name: &str,
164 bin_name: &str,
165 force_latest: bool,
166 locale: &str,
167) -> Result<()> {
168 eprintln!(
169 "{}",
170 crate::i18n::tf(
171 locale,
172 "runtime.tools.install.installing",
173 &[
174 ("bin_name", bin_name.to_string()),
175 ("crate_name", crate_name.to_string()),
176 ],
177 )
178 );
179
180 let mut cmd = Command::new("cargo");
181 cmd.args(binstall_args(crate_name, bin_name, force_latest));
182
183 let status = cmd
184 .stdin(Stdio::inherit())
185 .stdout(Stdio::inherit())
186 .stderr(Stdio::inherit())
187 .status()
188 .with_context(|| crate::i18n::t(locale, "runtime.tools.install.error.execute_binstall"))?;
189
190 if status.success() {
191 Ok(())
192 } else {
193 bail!(
194 "{}",
195 crate::i18n::tf(
196 locale,
197 "runtime.tools.install.error.binstall_failed",
198 &[
199 ("bin_name", bin_name.to_string()),
200 ("crate_name", crate_name.to_string()),
201 ("exit_code", format!("{:?}", status.code())),
202 ],
203 )
204 );
205 }
206}
207
208fn binstall_args(crate_name: &str, bin_name: &str, force_latest: bool) -> Vec<String> {
209 let mut args = vec![
210 "binstall".to_string(),
211 "-y".to_string(),
212 "--locked".to_string(),
213 crate_name.to_string(),
214 "--bin".to_string(),
215 bin_name.to_string(),
216 ];
217 if force_latest {
218 args.push("--force".to_string());
219 }
220 args
221}
222
223fn ensure_cargo_binstall() -> Result<()> {
224 let locale = crate::i18n::select_locale(None);
225 let installed_version = installed_cargo_binstall_version()?;
226 if installed_version.is_none() {
227 eprintln!(
228 "{}",
229 crate::i18n::t(&locale, "runtime.tools.install.installing_binstall")
230 );
231 return install_cargo_binstall();
232 }
233
234 let installed_version = installed_version.expect("checked is_some above");
235 match latest_cargo_binstall_version() {
236 Ok(latest_version) => {
237 if installed_version >= latest_version {
238 return Ok(());
239 }
240
241 eprintln!(
242 "{}",
243 crate::i18n::tf(
244 &locale,
245 "runtime.tools.install.updating_binstall",
246 &[
247 ("installed_version", installed_version.to_string()),
248 ("latest_version", latest_version.to_string()),
249 ],
250 )
251 );
252 install_cargo_binstall()
253 }
254 Err(err) => {
255 eprintln!(
256 "{}",
257 crate::i18n::tf(
258 &locale,
259 "runtime.tools.install.warn.latest_check_failed",
260 &[
261 ("error", err.to_string()),
262 ("installed_version", installed_version.to_string()),
263 ],
264 )
265 );
266 Ok(())
267 }
268 }
269}
270
271fn install_cargo_binstall() -> Result<()> {
272 let status = Command::new("cargo")
273 .arg("install")
274 .arg("cargo-binstall")
275 .arg("--locked")
276 .stdin(Stdio::inherit())
277 .stdout(Stdio::inherit())
278 .stderr(Stdio::inherit())
279 .status()
280 .with_context(|| {
281 crate::i18n::t(
282 &crate::i18n::select_locale(None),
283 "runtime.tools.install.error.execute_install_binstall",
284 )
285 })?;
286
287 if status.success() {
288 Ok(())
289 } else {
290 let locale = crate::i18n::select_locale(None);
291 bail!(
292 "{}",
293 crate::i18n::tf(
294 &locale,
295 "runtime.tools.install.error.install_binstall_failed",
296 &[("exit_code", format!("{:?}", status.code()))],
297 )
298 );
299 }
300}
301
302fn installed_cargo_binstall_version() -> Result<Option<Version>> {
303 let output = Command::new("cargo")
304 .arg("binstall")
305 .arg("-V")
306 .stdin(Stdio::null())
307 .stderr(Stdio::null())
308 .output();
309 let output = match output {
310 Ok(output) => output,
311 Err(_) => return Ok(None),
312 };
313 if !output.status.success() {
314 return Ok(None);
315 }
316
317 let stdout =
318 String::from_utf8(output.stdout).context("`cargo binstall -V` returned non-UTF8 output")?;
319 parse_installed_cargo_binstall_version(&stdout)
320}
321
322fn latest_cargo_binstall_version() -> Result<Version> {
323 let output = Command::new("cargo")
324 .arg("search")
325 .arg("cargo-binstall")
326 .arg("--limit")
327 .arg("1")
328 .stdin(Stdio::null())
329 .stderr(Stdio::null())
330 .output()
331 .with_context(|| "failed to execute `cargo search cargo-binstall --limit 1`")?;
332 if !output.status.success() {
333 bail!(
334 "`cargo search cargo-binstall --limit 1` failed with exit code {:?}",
335 output.status.code()
336 );
337 }
338
339 let stdout = String::from_utf8(output.stdout)
340 .context("`cargo search cargo-binstall --limit 1` returned non-UTF8 output")?;
341 parse_latest_cargo_binstall_version(&stdout)
342}
343
344fn parse_installed_cargo_binstall_version(stdout: &str) -> Result<Option<Version>> {
345 let line = stdout.lines().next().unwrap_or_default();
346 let maybe_version = line
347 .split_whitespace()
348 .find_map(|token| Version::parse(token.trim_start_matches('v')).ok());
349 Ok(maybe_version)
350}
351
352fn parse_latest_cargo_binstall_version(stdout: &str) -> Result<Version> {
353 let first_line = stdout
354 .lines()
355 .find(|line| !line.trim().is_empty())
356 .ok_or_else(|| anyhow!("`cargo search cargo-binstall --limit 1` returned no results"))?;
357 let (_, rhs) = first_line
358 .split_once('=')
359 .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
360 let quoted = rhs
361 .split('#')
362 .next()
363 .map(str::trim)
364 .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
365 let version_text = quoted.trim_matches('"');
366 Version::parse(version_text)
367 .with_context(|| format!("failed to parse cargo-binstall version from `{first_line}`"))
368}
369
370#[cfg(test)]
371mod tests {
372 use super::{
373 ToolchainChannel, binstall_args, delegated_binary_name_for_channel,
374 parse_installed_cargo_binstall_version, parse_latest_cargo_binstall_version,
375 };
376 use crate::toolchain_catalogue::GREENTIC_TOOLCHAIN_PACKAGES;
377
378 #[test]
379 fn delegated_install_catalogue_includes_runner() {
380 let found = GREENTIC_TOOLCHAIN_PACKAGES.iter().any(|package| {
381 package.crate_name == "greentic-runner" && package.bins.contains(&"greentic-runner")
382 });
383 assert!(found);
384 }
385
386 #[test]
387 fn binstall_args_include_force_only_when_latest_requested() {
388 assert_eq!(
389 binstall_args("greentic-runner", "greentic-runner", false),
390 vec![
391 "binstall",
392 "-y",
393 "--locked",
394 "greentic-runner",
395 "--bin",
396 "greentic-runner"
397 ]
398 );
399 assert_eq!(
400 binstall_args("greentic-runner", "greentic-runner", true),
401 vec![
402 "binstall",
403 "-y",
404 "--locked",
405 "greentic-runner",
406 "--bin",
407 "greentic-runner",
408 "--force"
409 ]
410 );
411 }
412
413 #[test]
414 fn executable_name_selects_toolchain_channel() {
415 assert_eq!(
416 ToolchainChannel::from_executable_name("greentic-dev"),
417 ToolchainChannel::Stable
418 );
419 assert_eq!(
420 ToolchainChannel::from_executable_name("greentic-dev-dev"),
421 ToolchainChannel::Development
422 );
423 assert_eq!(
424 ToolchainChannel::from_executable_name("greentic-dev-dev.exe"),
425 ToolchainChannel::Development
426 );
427 }
428
429 #[test]
430 fn development_channel_uses_dev_binary_names() {
431 assert_eq!(
432 delegated_binary_name_for_channel("greentic-pack", ToolchainChannel::Development),
433 "greentic-pack-dev"
434 );
435 assert_eq!(
436 delegated_binary_name_for_channel("greentic-runner-cli", ToolchainChannel::Development),
437 "greentic-runner-cli-dev"
438 );
439 assert_eq!(
440 delegated_binary_name_for_channel("greentic-pack-dev", ToolchainChannel::Development),
441 "greentic-pack-dev"
442 );
443 }
444
445 #[test]
446 fn parse_installed_binstall_version_line() {
447 let parsed = parse_installed_cargo_binstall_version("cargo-binstall 1.15.7\n")
448 .expect("parse should succeed")
449 .expect("version should exist");
450 assert_eq!(parsed.to_string(), "1.15.7");
451 }
452
453 #[test]
454 fn parse_latest_binstall_version_line() {
455 let parsed = parse_latest_cargo_binstall_version(
456 "cargo-binstall = \"1.15.7\" # Binary installation for rust projects\n",
457 )
458 .expect("parse should succeed");
459 assert_eq!(parsed.to_string(), "1.15.7");
460 }
461}