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_EXTERNAL_TOOL_PACKAGES, 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(crate) fn external_tool_env_key(name: &str) -> String {
117 format!("GREENTIC_DEV_BIN_{}", name.replace('-', "_").to_uppercase())
118}
119
120pub fn resolve_external_tool(name: &str) -> Result<PathBuf> {
127 let locale = crate::i18n::select_locale(None);
128 let env_key = external_tool_env_key(name);
129 if let Ok(path) = env::var(&env_key) {
130 let pb = PathBuf::from(path);
131 if pb.exists() {
132 return Ok(pb);
133 }
134 bail!(
135 "{}",
136 crate::i18n::tf(
137 &locale,
138 "runtime.passthrough.error.env_binary_missing",
139 &[
140 ("env_key", env_key.clone()),
141 ("path", pb.display().to_string()),
142 ],
143 )
144 );
145 }
146
147 if let Ok(path) = which::which(name) {
148 return Ok(path);
149 }
150
151 bail!(
152 "{}",
153 crate::i18n::tf(
154 &locale,
155 "runtime.passthrough.error.binary_not_found",
156 &[("name", name.to_string()), ("env_key", env_key)],
157 )
158 )
159}
160
161pub fn run_passthrough(bin: &Path, args: &[OsString], verbose: bool) -> Result<ExitStatus> {
162 let locale = crate::i18n::select_locale(None);
163 if verbose {
164 eprintln!(
165 "{}",
166 crate::i18n::tf(
167 &locale,
168 "runtime.passthrough.debug.exec",
169 &[
170 ("bin", bin.display().to_string()),
171 ("args", format!("{args:?}")),
172 ],
173 )
174 );
175 let _ = Command::new(bin)
178 .arg("--version")
179 .stdout(Stdio::inherit())
180 .stderr(Stdio::inherit())
181 .status();
182 }
183
184 Command::new(bin)
187 .args(args)
188 .stdin(Stdio::inherit())
189 .stdout(Stdio::inherit())
190 .stderr(Stdio::inherit())
191 .status()
192 .map_err(|e| {
193 anyhow!(crate::i18n::tf(
194 &locale,
195 "runtime.passthrough.error.execute",
196 &[("bin", bin.display().to_string()), ("error", e.to_string())],
197 ))
198 })
199}
200
201pub fn install_all_delegated_tools(latest: bool, locale: &str) -> Result<()> {
202 ensure_cargo_binstall()?;
203 let mut failed: Vec<String> = Vec::new();
204 let channel = current_toolchain_channel();
205 let resolver = (channel == ToolchainChannel::Rnd)
211 .then(crate::release_cmd::CratesIoApiVersionResolver::default);
212 for package in GREENTIC_TOOLCHAIN_PACKAGES {
213 let crate_name = delegated_binary_name_for_channel(package.crate_name, channel);
214 let version: Option<String> = match resolver.as_ref() {
215 Some(resolver) => {
220 match crate::release_cmd::CrateVersionResolver::resolve_research_version(
221 resolver,
222 &crate_name,
223 )
224 .with_context(|| {
225 format!(
226 "failed to resolve research version for `{crate_name}` \
227 (gtc-research install needs an explicit pre-release version)"
228 )
229 })? {
230 crate::release_cmd::ResearchVersion::Pinned(version) => Some(version),
231 crate::release_cmd::ResearchVersion::Absent => {
232 eprintln!(
233 "note: `{crate_name}` has no research build on crates.io; \
234 skipping it on the research toolchain (use the stable \
235 channel for this tool)"
236 );
237 continue;
238 }
239 }
240 }
241 None => None,
242 };
243 for bin_name in package.bins {
244 let bin = delegated_binary_name_for_channel(bin_name, channel);
245 if let Err(err) =
259 install_with_binstall(&crate_name, &bin, latest, version.as_deref(), locale)
260 {
261 eprintln!("error: `{bin}` (crate `{crate_name}`) could not be installed: {err}");
262 failed.push(bin);
263 }
264 }
265 }
266 for package in GREENTIC_EXTERNAL_TOOL_PACKAGES {
284 for bin_name in package.bins {
285 if let Err(err) =
286 install_with_binstall(package.crate_name, bin_name, latest, None, locale)
287 {
288 eprintln!(
289 "note: optional external tool `{bin_name}` (crate `{}`) is unavailable; \
290 skipping it: {err}",
291 package.crate_name
292 );
293 }
294 }
295 }
296
297 if !failed.is_empty() {
298 anyhow::bail!(
304 "could not install {} required toolchain binaries: {}",
305 failed.len(),
306 failed.join(", ")
307 );
308 }
309 Ok(())
310}
311
312fn install_with_binstall(
313 crate_name: &str,
314 bin_name: &str,
315 force_latest: bool,
316 version: Option<&str>,
317 locale: &str,
318) -> Result<()> {
319 eprintln!(
320 "{}",
321 crate::i18n::tf(
322 locale,
323 "runtime.tools.install.installing",
324 &[
325 ("bin_name", bin_name.to_string()),
326 ("crate_name", crate_name.to_string()),
327 ],
328 )
329 );
330
331 let mut cmd = Command::new("cargo");
332 cmd.args(binstall_args(crate_name, bin_name, force_latest, version));
333
334 let status = cmd
335 .stdin(Stdio::inherit())
336 .stdout(Stdio::inherit())
337 .stderr(Stdio::inherit())
338 .status()
339 .with_context(|| crate::i18n::t(locale, "runtime.tools.install.error.execute_binstall"))?;
340
341 if status.success() {
342 Ok(())
343 } else {
344 bail!(
345 "{}",
346 crate::i18n::tf(
347 locale,
348 "runtime.tools.install.error.binstall_failed",
349 &[
350 ("bin_name", bin_name.to_string()),
351 ("crate_name", crate_name.to_string()),
352 ("exit_code", format!("{:?}", status.code())),
353 ],
354 )
355 );
356 }
357}
358
359fn binstall_args(
360 crate_name: &str,
361 bin_name: &str,
362 force_latest: bool,
363 version: Option<&str>,
364) -> Vec<String> {
365 let mut args = vec![
366 "binstall".to_string(),
367 "-y".to_string(),
368 "--locked".to_string(),
369 "--maximum-resolution-timeout".to_string(),
370 "60".to_string(),
371 crate_name.to_string(),
372 "--bin".to_string(),
373 bin_name.to_string(),
374 ];
375 if let Some(version) = version {
378 args.push("--version".to_string());
379 args.push(version.to_string());
380 }
381 if force_latest {
382 args.push("--force".to_string());
383 }
384 args
385}
386
387fn ensure_cargo_binstall() -> Result<()> {
388 let locale = crate::i18n::select_locale(None);
389 let installed_version = installed_cargo_binstall_version()?;
390 if installed_version.is_none() {
391 eprintln!(
392 "{}",
393 crate::i18n::t(&locale, "runtime.tools.install.installing_binstall")
394 );
395 return install_cargo_binstall();
396 }
397
398 let installed_version = installed_version.expect("checked is_some above");
399 match latest_cargo_binstall_version() {
400 Ok(latest_version) => {
401 if installed_version >= latest_version {
402 return Ok(());
403 }
404
405 eprintln!(
406 "{}",
407 crate::i18n::tf(
408 &locale,
409 "runtime.tools.install.updating_binstall",
410 &[
411 ("installed_version", installed_version.to_string()),
412 ("latest_version", latest_version.to_string()),
413 ],
414 )
415 );
416 install_cargo_binstall()
417 }
418 Err(err) => {
419 eprintln!(
420 "{}",
421 crate::i18n::tf(
422 &locale,
423 "runtime.tools.install.warn.latest_check_failed",
424 &[
425 ("error", err.to_string()),
426 ("installed_version", installed_version.to_string()),
427 ],
428 )
429 );
430 Ok(())
431 }
432 }
433}
434
435fn install_cargo_binstall() -> Result<()> {
436 let status = Command::new("cargo")
437 .arg("install")
438 .arg("cargo-binstall")
439 .arg("--locked")
440 .stdin(Stdio::inherit())
441 .stdout(Stdio::inherit())
442 .stderr(Stdio::inherit())
443 .status()
444 .with_context(|| {
445 crate::i18n::t(
446 &crate::i18n::select_locale(None),
447 "runtime.tools.install.error.execute_install_binstall",
448 )
449 })?;
450
451 if status.success() {
452 Ok(())
453 } else {
454 let locale = crate::i18n::select_locale(None);
455 bail!(
456 "{}",
457 crate::i18n::tf(
458 &locale,
459 "runtime.tools.install.error.install_binstall_failed",
460 &[("exit_code", format!("{:?}", status.code()))],
461 )
462 );
463 }
464}
465
466fn installed_cargo_binstall_version() -> Result<Option<Version>> {
467 let output = Command::new("cargo")
468 .arg("binstall")
469 .arg("-V")
470 .stdin(Stdio::null())
471 .stderr(Stdio::null())
472 .output();
473 let output = match output {
474 Ok(output) => output,
475 Err(_) => return Ok(None),
476 };
477 if !output.status.success() {
478 return Ok(None);
479 }
480
481 let stdout =
482 String::from_utf8(output.stdout).context("`cargo binstall -V` returned non-UTF8 output")?;
483 parse_installed_cargo_binstall_version(&stdout)
484}
485
486fn latest_cargo_binstall_version() -> Result<Version> {
487 let output = Command::new("cargo")
488 .arg("search")
489 .arg("cargo-binstall")
490 .arg("--limit")
491 .arg("1")
492 .stdin(Stdio::null())
493 .stderr(Stdio::null())
494 .output()
495 .with_context(|| "failed to execute `cargo search cargo-binstall --limit 1`")?;
496 if !output.status.success() {
497 bail!(
498 "`cargo search cargo-binstall --limit 1` failed with exit code {:?}",
499 output.status.code()
500 );
501 }
502
503 let stdout = String::from_utf8(output.stdout)
504 .context("`cargo search cargo-binstall --limit 1` returned non-UTF8 output")?;
505 parse_latest_cargo_binstall_version(&stdout)
506}
507
508fn parse_installed_cargo_binstall_version(stdout: &str) -> Result<Option<Version>> {
509 let line = stdout.lines().next().unwrap_or_default();
510 let maybe_version = line
511 .split_whitespace()
512 .find_map(|token| Version::parse(token.trim_start_matches('v')).ok());
513 Ok(maybe_version)
514}
515
516fn parse_latest_cargo_binstall_version(stdout: &str) -> Result<Version> {
517 let first_line = stdout
518 .lines()
519 .find(|line| !line.trim().is_empty())
520 .ok_or_else(|| anyhow!("`cargo search cargo-binstall --limit 1` returned no results"))?;
521 let (_, rhs) = first_line
522 .split_once('=')
523 .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
524 let quoted = rhs
525 .split('#')
526 .next()
527 .map(str::trim)
528 .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
529 let version_text = quoted.trim_matches('"');
530 Version::parse(version_text)
531 .with_context(|| format!("failed to parse cargo-binstall version from `{first_line}`"))
532}
533
534#[cfg(test)]
535mod tests {
536 use super::{
537 ToolchainChannel, binstall_args, delegated_binary_name_for_channel, external_tool_env_key,
538 parse_installed_cargo_binstall_version, parse_latest_cargo_binstall_version,
539 resolve_external_tool,
540 };
541 use crate::toolchain_catalogue::GREENTIC_TOOLCHAIN_PACKAGES;
542
543 #[test]
544 fn delegated_install_catalogue_includes_runner() {
545 let found = GREENTIC_TOOLCHAIN_PACKAGES.iter().any(|package| {
546 package.crate_name == "greentic-runner" && package.bins.contains(&"greentic-runner")
547 });
548 assert!(found);
549 }
550
551 #[test]
552 fn binstall_args_include_force_only_when_latest_requested() {
553 assert_eq!(
554 binstall_args("greentic-runner", "greentic-runner", false, None),
555 vec![
556 "binstall",
557 "-y",
558 "--locked",
559 "--maximum-resolution-timeout",
560 "60",
561 "greentic-runner",
562 "--bin",
563 "greentic-runner"
564 ]
565 );
566 assert_eq!(
567 binstall_args("greentic-runner", "greentic-runner", true, None),
568 vec![
569 "binstall",
570 "-y",
571 "--locked",
572 "--maximum-resolution-timeout",
573 "60",
574 "greentic-runner",
575 "--bin",
576 "greentic-runner",
577 "--force"
578 ]
579 );
580 }
581
582 #[test]
583 fn binstall_args_pin_version_for_rnd_prerelease() {
584 assert_eq!(
587 binstall_args(
588 "greentic-start-rnd",
589 "greentic-start-rnd",
590 false,
591 Some("1.2.0-research.1"),
592 ),
593 vec![
594 "binstall",
595 "-y",
596 "--locked",
597 "--maximum-resolution-timeout",
598 "60",
599 "greentic-start-rnd",
600 "--bin",
601 "greentic-start-rnd",
602 "--version",
603 "1.2.0-research.1",
604 ]
605 );
606 }
607
608 #[test]
609 fn executable_name_selects_toolchain_channel() {
610 assert_eq!(
611 ToolchainChannel::from_executable_name("greentic-dev"),
612 ToolchainChannel::Stable
613 );
614 assert_eq!(
615 ToolchainChannel::from_executable_name("greentic-dev-dev"),
616 ToolchainChannel::Development
617 );
618 assert_eq!(
619 ToolchainChannel::from_executable_name("greentic-dev-dev.exe"),
620 ToolchainChannel::Development
621 );
622 assert_eq!(
623 ToolchainChannel::from_executable_name("greentic-dev-rnd"),
624 ToolchainChannel::Rnd
625 );
626 assert_eq!(
627 ToolchainChannel::from_executable_name("greentic-dev-rnd.exe"),
628 ToolchainChannel::Rnd
629 );
630 }
631
632 #[test]
633 fn development_channel_uses_dev_binary_names() {
634 assert_eq!(
635 delegated_binary_name_for_channel("greentic-pack", ToolchainChannel::Development),
636 "greentic-pack-dev"
637 );
638 assert_eq!(
639 delegated_binary_name_for_channel("greentic-runner-cli", ToolchainChannel::Development),
640 "greentic-runner-cli-dev"
641 );
642 assert_eq!(
643 delegated_binary_name_for_channel("greentic-pack-dev", ToolchainChannel::Development),
644 "greentic-pack-dev"
645 );
646 }
647
648 #[test]
649 fn rnd_channel_uses_rnd_binary_names() {
650 assert_eq!(
651 delegated_binary_name_for_channel("greentic-pack", ToolchainChannel::Rnd),
652 "greentic-pack-rnd"
653 );
654 assert_eq!(
655 delegated_binary_name_for_channel("greentic-runner-cli", ToolchainChannel::Rnd),
656 "greentic-runner-cli-rnd"
657 );
658 assert_eq!(
659 delegated_binary_name_for_channel("greentic-pack-rnd", ToolchainChannel::Rnd),
660 "greentic-pack-rnd"
661 );
662 }
663
664 #[test]
665 fn parse_installed_binstall_version_line() {
666 let parsed = parse_installed_cargo_binstall_version("cargo-binstall 1.15.7\n")
667 .expect("parse should succeed")
668 .expect("version should exist");
669 assert_eq!(parsed.to_string(), "1.15.7");
670 }
671
672 #[test]
673 fn parse_latest_binstall_version_line() {
674 let parsed = parse_latest_cargo_binstall_version(
675 "cargo-binstall = \"1.15.7\" # Binary installation for rust projects\n",
676 )
677 .expect("parse should succeed");
678 assert_eq!(parsed.to_string(), "1.15.7");
679 }
680
681 #[test]
682 fn external_tool_env_key_is_plain_uppercase_no_channel_suffix() {
683 assert_eq!(
686 external_tool_env_key("greentic-mcp-gen"),
687 "GREENTIC_DEV_BIN_GREENTIC_MCP_GEN"
688 );
689 }
690
691 #[test]
692 fn resolve_external_tool_errors_with_plain_name_when_absent() {
693 let err = resolve_external_tool("greentic-mcp-gen-absent-xyz")
696 .expect_err("expected resolution to fail");
697 assert!(err.to_string().contains("greentic-mcp-gen-absent-xyz"));
698 }
699}