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 let resolver = (channel == ToolchainChannel::Rnd)
163 .then(crate::release_cmd::CratesIoApiVersionResolver::default);
164 for package in GREENTIC_TOOLCHAIN_PACKAGES {
165 let crate_name = delegated_binary_name_for_channel(package.crate_name, channel);
166 let version: Option<String> = match resolver.as_ref() {
167 Some(resolver) => {
172 match crate::release_cmd::CrateVersionResolver::resolve_research_version(
173 resolver,
174 &crate_name,
175 )
176 .with_context(|| {
177 format!(
178 "failed to resolve research version for `{crate_name}` \
179 (gtc-research install needs an explicit pre-release version)"
180 )
181 })? {
182 crate::release_cmd::ResearchVersion::Pinned(version) => Some(version),
183 crate::release_cmd::ResearchVersion::Absent => {
184 eprintln!(
185 "note: `{crate_name}` has no research build on crates.io; \
186 skipping it on the research toolchain (use the stable \
187 channel for this tool)"
188 );
189 continue;
190 }
191 }
192 }
193 None => None,
194 };
195 for bin_name in package.bins {
196 install_with_binstall(
197 &crate_name,
198 &delegated_binary_name_for_channel(bin_name, channel),
199 latest,
200 version.as_deref(),
201 locale,
202 )?;
203 }
204 }
205 Ok(())
206}
207
208fn install_with_binstall(
209 crate_name: &str,
210 bin_name: &str,
211 force_latest: bool,
212 version: Option<&str>,
213 locale: &str,
214) -> Result<()> {
215 eprintln!(
216 "{}",
217 crate::i18n::tf(
218 locale,
219 "runtime.tools.install.installing",
220 &[
221 ("bin_name", bin_name.to_string()),
222 ("crate_name", crate_name.to_string()),
223 ],
224 )
225 );
226
227 let mut cmd = Command::new("cargo");
228 cmd.args(binstall_args(crate_name, bin_name, force_latest, version));
229
230 let status = cmd
231 .stdin(Stdio::inherit())
232 .stdout(Stdio::inherit())
233 .stderr(Stdio::inherit())
234 .status()
235 .with_context(|| crate::i18n::t(locale, "runtime.tools.install.error.execute_binstall"))?;
236
237 if status.success() {
238 Ok(())
239 } else {
240 bail!(
241 "{}",
242 crate::i18n::tf(
243 locale,
244 "runtime.tools.install.error.binstall_failed",
245 &[
246 ("bin_name", bin_name.to_string()),
247 ("crate_name", crate_name.to_string()),
248 ("exit_code", format!("{:?}", status.code())),
249 ],
250 )
251 );
252 }
253}
254
255fn binstall_args(
256 crate_name: &str,
257 bin_name: &str,
258 force_latest: bool,
259 version: Option<&str>,
260) -> Vec<String> {
261 let mut args = vec![
262 "binstall".to_string(),
263 "-y".to_string(),
264 "--locked".to_string(),
265 crate_name.to_string(),
266 "--bin".to_string(),
267 bin_name.to_string(),
268 ];
269 if let Some(version) = version {
272 args.push("--version".to_string());
273 args.push(version.to_string());
274 }
275 if force_latest {
276 args.push("--force".to_string());
277 }
278 args
279}
280
281fn ensure_cargo_binstall() -> Result<()> {
282 let locale = crate::i18n::select_locale(None);
283 let installed_version = installed_cargo_binstall_version()?;
284 if installed_version.is_none() {
285 eprintln!(
286 "{}",
287 crate::i18n::t(&locale, "runtime.tools.install.installing_binstall")
288 );
289 return install_cargo_binstall();
290 }
291
292 let installed_version = installed_version.expect("checked is_some above");
293 match latest_cargo_binstall_version() {
294 Ok(latest_version) => {
295 if installed_version >= latest_version {
296 return Ok(());
297 }
298
299 eprintln!(
300 "{}",
301 crate::i18n::tf(
302 &locale,
303 "runtime.tools.install.updating_binstall",
304 &[
305 ("installed_version", installed_version.to_string()),
306 ("latest_version", latest_version.to_string()),
307 ],
308 )
309 );
310 install_cargo_binstall()
311 }
312 Err(err) => {
313 eprintln!(
314 "{}",
315 crate::i18n::tf(
316 &locale,
317 "runtime.tools.install.warn.latest_check_failed",
318 &[
319 ("error", err.to_string()),
320 ("installed_version", installed_version.to_string()),
321 ],
322 )
323 );
324 Ok(())
325 }
326 }
327}
328
329fn install_cargo_binstall() -> Result<()> {
330 let status = Command::new("cargo")
331 .arg("install")
332 .arg("cargo-binstall")
333 .arg("--locked")
334 .stdin(Stdio::inherit())
335 .stdout(Stdio::inherit())
336 .stderr(Stdio::inherit())
337 .status()
338 .with_context(|| {
339 crate::i18n::t(
340 &crate::i18n::select_locale(None),
341 "runtime.tools.install.error.execute_install_binstall",
342 )
343 })?;
344
345 if status.success() {
346 Ok(())
347 } else {
348 let locale = crate::i18n::select_locale(None);
349 bail!(
350 "{}",
351 crate::i18n::tf(
352 &locale,
353 "runtime.tools.install.error.install_binstall_failed",
354 &[("exit_code", format!("{:?}", status.code()))],
355 )
356 );
357 }
358}
359
360fn installed_cargo_binstall_version() -> Result<Option<Version>> {
361 let output = Command::new("cargo")
362 .arg("binstall")
363 .arg("-V")
364 .stdin(Stdio::null())
365 .stderr(Stdio::null())
366 .output();
367 let output = match output {
368 Ok(output) => output,
369 Err(_) => return Ok(None),
370 };
371 if !output.status.success() {
372 return Ok(None);
373 }
374
375 let stdout =
376 String::from_utf8(output.stdout).context("`cargo binstall -V` returned non-UTF8 output")?;
377 parse_installed_cargo_binstall_version(&stdout)
378}
379
380fn latest_cargo_binstall_version() -> Result<Version> {
381 let output = Command::new("cargo")
382 .arg("search")
383 .arg("cargo-binstall")
384 .arg("--limit")
385 .arg("1")
386 .stdin(Stdio::null())
387 .stderr(Stdio::null())
388 .output()
389 .with_context(|| "failed to execute `cargo search cargo-binstall --limit 1`")?;
390 if !output.status.success() {
391 bail!(
392 "`cargo search cargo-binstall --limit 1` failed with exit code {:?}",
393 output.status.code()
394 );
395 }
396
397 let stdout = String::from_utf8(output.stdout)
398 .context("`cargo search cargo-binstall --limit 1` returned non-UTF8 output")?;
399 parse_latest_cargo_binstall_version(&stdout)
400}
401
402fn parse_installed_cargo_binstall_version(stdout: &str) -> Result<Option<Version>> {
403 let line = stdout.lines().next().unwrap_or_default();
404 let maybe_version = line
405 .split_whitespace()
406 .find_map(|token| Version::parse(token.trim_start_matches('v')).ok());
407 Ok(maybe_version)
408}
409
410fn parse_latest_cargo_binstall_version(stdout: &str) -> Result<Version> {
411 let first_line = stdout
412 .lines()
413 .find(|line| !line.trim().is_empty())
414 .ok_or_else(|| anyhow!("`cargo search cargo-binstall --limit 1` returned no results"))?;
415 let (_, rhs) = first_line
416 .split_once('=')
417 .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
418 let quoted = rhs
419 .split('#')
420 .next()
421 .map(str::trim)
422 .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
423 let version_text = quoted.trim_matches('"');
424 Version::parse(version_text)
425 .with_context(|| format!("failed to parse cargo-binstall version from `{first_line}`"))
426}
427
428#[cfg(test)]
429mod tests {
430 use super::{
431 ToolchainChannel, binstall_args, delegated_binary_name_for_channel,
432 parse_installed_cargo_binstall_version, parse_latest_cargo_binstall_version,
433 };
434 use crate::toolchain_catalogue::GREENTIC_TOOLCHAIN_PACKAGES;
435
436 #[test]
437 fn delegated_install_catalogue_includes_runner() {
438 let found = GREENTIC_TOOLCHAIN_PACKAGES.iter().any(|package| {
439 package.crate_name == "greentic-runner" && package.bins.contains(&"greentic-runner")
440 });
441 assert!(found);
442 }
443
444 #[test]
445 fn binstall_args_include_force_only_when_latest_requested() {
446 assert_eq!(
447 binstall_args("greentic-runner", "greentic-runner", false, None),
448 vec![
449 "binstall",
450 "-y",
451 "--locked",
452 "greentic-runner",
453 "--bin",
454 "greentic-runner"
455 ]
456 );
457 assert_eq!(
458 binstall_args("greentic-runner", "greentic-runner", true, None),
459 vec![
460 "binstall",
461 "-y",
462 "--locked",
463 "greentic-runner",
464 "--bin",
465 "greentic-runner",
466 "--force"
467 ]
468 );
469 }
470
471 #[test]
472 fn binstall_args_pin_version_for_rnd_prerelease() {
473 assert_eq!(
476 binstall_args(
477 "greentic-start-rnd",
478 "greentic-start-rnd",
479 false,
480 Some("1.2.0-research.1"),
481 ),
482 vec![
483 "binstall",
484 "-y",
485 "--locked",
486 "greentic-start-rnd",
487 "--bin",
488 "greentic-start-rnd",
489 "--version",
490 "1.2.0-research.1",
491 ]
492 );
493 }
494
495 #[test]
496 fn executable_name_selects_toolchain_channel() {
497 assert_eq!(
498 ToolchainChannel::from_executable_name("greentic-dev"),
499 ToolchainChannel::Stable
500 );
501 assert_eq!(
502 ToolchainChannel::from_executable_name("greentic-dev-dev"),
503 ToolchainChannel::Development
504 );
505 assert_eq!(
506 ToolchainChannel::from_executable_name("greentic-dev-dev.exe"),
507 ToolchainChannel::Development
508 );
509 assert_eq!(
510 ToolchainChannel::from_executable_name("greentic-dev-rnd"),
511 ToolchainChannel::Rnd
512 );
513 assert_eq!(
514 ToolchainChannel::from_executable_name("greentic-dev-rnd.exe"),
515 ToolchainChannel::Rnd
516 );
517 }
518
519 #[test]
520 fn development_channel_uses_dev_binary_names() {
521 assert_eq!(
522 delegated_binary_name_for_channel("greentic-pack", ToolchainChannel::Development),
523 "greentic-pack-dev"
524 );
525 assert_eq!(
526 delegated_binary_name_for_channel("greentic-runner-cli", ToolchainChannel::Development),
527 "greentic-runner-cli-dev"
528 );
529 assert_eq!(
530 delegated_binary_name_for_channel("greentic-pack-dev", ToolchainChannel::Development),
531 "greentic-pack-dev"
532 );
533 }
534
535 #[test]
536 fn rnd_channel_uses_rnd_binary_names() {
537 assert_eq!(
538 delegated_binary_name_for_channel("greentic-pack", ToolchainChannel::Rnd),
539 "greentic-pack-rnd"
540 );
541 assert_eq!(
542 delegated_binary_name_for_channel("greentic-runner-cli", ToolchainChannel::Rnd),
543 "greentic-runner-cli-rnd"
544 );
545 assert_eq!(
546 delegated_binary_name_for_channel("greentic-pack-rnd", ToolchainChannel::Rnd),
547 "greentic-pack-rnd"
548 );
549 }
550
551 #[test]
552 fn parse_installed_binstall_version_line() {
553 let parsed = parse_installed_cargo_binstall_version("cargo-binstall 1.15.7\n")
554 .expect("parse should succeed")
555 .expect("version should exist");
556 assert_eq!(parsed.to_string(), "1.15.7");
557 }
558
559 #[test]
560 fn parse_latest_binstall_version_line() {
561 let parsed = parse_latest_cargo_binstall_version(
562 "cargo-binstall = \"1.15.7\" # Binary installation for rust projects\n",
563 )
564 .expect("parse should succeed");
565 assert_eq!(parsed.to_string(), "1.15.7");
566 }
567}