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