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 channel = current_toolchain_channel();
204 let resolver = (channel == ToolchainChannel::Rnd)
210 .then(crate::release_cmd::CratesIoApiVersionResolver::default);
211 for package in GREENTIC_TOOLCHAIN_PACKAGES {
212 let crate_name = delegated_binary_name_for_channel(package.crate_name, channel);
213 let version: Option<String> = match resolver.as_ref() {
214 Some(resolver) => {
219 match crate::release_cmd::CrateVersionResolver::resolve_research_version(
220 resolver,
221 &crate_name,
222 )
223 .with_context(|| {
224 format!(
225 "failed to resolve research version for `{crate_name}` \
226 (gtc-research install needs an explicit pre-release version)"
227 )
228 })? {
229 crate::release_cmd::ResearchVersion::Pinned(version) => Some(version),
230 crate::release_cmd::ResearchVersion::Absent => {
231 eprintln!(
232 "note: `{crate_name}` has no research build on crates.io; \
233 skipping it on the research toolchain (use the stable \
234 channel for this tool)"
235 );
236 continue;
237 }
238 }
239 }
240 None => None,
241 };
242 for bin_name in package.bins {
243 install_with_binstall(
244 &crate_name,
245 &delegated_binary_name_for_channel(bin_name, channel),
246 latest,
247 version.as_deref(),
248 locale,
249 )?;
250 }
251 }
252 for package in GREENTIC_EXTERNAL_TOOL_PACKAGES {
267 for bin_name in package.bins {
268 if let Err(err) =
269 install_with_binstall(package.crate_name, bin_name, latest, None, locale)
270 {
271 eprintln!(
272 "note: optional external tool `{bin_name}` (crate `{}`) is unavailable; \
273 skipping it: {err}",
274 package.crate_name
275 );
276 }
277 }
278 }
279 Ok(())
280}
281
282fn install_with_binstall(
283 crate_name: &str,
284 bin_name: &str,
285 force_latest: bool,
286 version: Option<&str>,
287 locale: &str,
288) -> Result<()> {
289 eprintln!(
290 "{}",
291 crate::i18n::tf(
292 locale,
293 "runtime.tools.install.installing",
294 &[
295 ("bin_name", bin_name.to_string()),
296 ("crate_name", crate_name.to_string()),
297 ],
298 )
299 );
300
301 let mut cmd = Command::new("cargo");
302 cmd.args(binstall_args(crate_name, bin_name, force_latest, version));
303
304 let status = cmd
305 .stdin(Stdio::inherit())
306 .stdout(Stdio::inherit())
307 .stderr(Stdio::inherit())
308 .status()
309 .with_context(|| crate::i18n::t(locale, "runtime.tools.install.error.execute_binstall"))?;
310
311 if status.success() {
312 Ok(())
313 } else {
314 bail!(
315 "{}",
316 crate::i18n::tf(
317 locale,
318 "runtime.tools.install.error.binstall_failed",
319 &[
320 ("bin_name", bin_name.to_string()),
321 ("crate_name", crate_name.to_string()),
322 ("exit_code", format!("{:?}", status.code())),
323 ],
324 )
325 );
326 }
327}
328
329fn binstall_args(
330 crate_name: &str,
331 bin_name: &str,
332 force_latest: bool,
333 version: Option<&str>,
334) -> Vec<String> {
335 let mut args = vec![
336 "binstall".to_string(),
337 "-y".to_string(),
338 "--locked".to_string(),
339 "--maximum-resolution-timeout".to_string(),
340 "60".to_string(),
341 crate_name.to_string(),
342 "--bin".to_string(),
343 bin_name.to_string(),
344 ];
345 if let Some(version) = version {
348 args.push("--version".to_string());
349 args.push(version.to_string());
350 }
351 if force_latest {
352 args.push("--force".to_string());
353 }
354 args
355}
356
357fn ensure_cargo_binstall() -> Result<()> {
358 let locale = crate::i18n::select_locale(None);
359 let installed_version = installed_cargo_binstall_version()?;
360 if installed_version.is_none() {
361 eprintln!(
362 "{}",
363 crate::i18n::t(&locale, "runtime.tools.install.installing_binstall")
364 );
365 return install_cargo_binstall();
366 }
367
368 let installed_version = installed_version.expect("checked is_some above");
369 match latest_cargo_binstall_version() {
370 Ok(latest_version) => {
371 if installed_version >= latest_version {
372 return Ok(());
373 }
374
375 eprintln!(
376 "{}",
377 crate::i18n::tf(
378 &locale,
379 "runtime.tools.install.updating_binstall",
380 &[
381 ("installed_version", installed_version.to_string()),
382 ("latest_version", latest_version.to_string()),
383 ],
384 )
385 );
386 install_cargo_binstall()
387 }
388 Err(err) => {
389 eprintln!(
390 "{}",
391 crate::i18n::tf(
392 &locale,
393 "runtime.tools.install.warn.latest_check_failed",
394 &[
395 ("error", err.to_string()),
396 ("installed_version", installed_version.to_string()),
397 ],
398 )
399 );
400 Ok(())
401 }
402 }
403}
404
405fn install_cargo_binstall() -> Result<()> {
406 let status = Command::new("cargo")
407 .arg("install")
408 .arg("cargo-binstall")
409 .arg("--locked")
410 .stdin(Stdio::inherit())
411 .stdout(Stdio::inherit())
412 .stderr(Stdio::inherit())
413 .status()
414 .with_context(|| {
415 crate::i18n::t(
416 &crate::i18n::select_locale(None),
417 "runtime.tools.install.error.execute_install_binstall",
418 )
419 })?;
420
421 if status.success() {
422 Ok(())
423 } else {
424 let locale = crate::i18n::select_locale(None);
425 bail!(
426 "{}",
427 crate::i18n::tf(
428 &locale,
429 "runtime.tools.install.error.install_binstall_failed",
430 &[("exit_code", format!("{:?}", status.code()))],
431 )
432 );
433 }
434}
435
436fn installed_cargo_binstall_version() -> Result<Option<Version>> {
437 let output = Command::new("cargo")
438 .arg("binstall")
439 .arg("-V")
440 .stdin(Stdio::null())
441 .stderr(Stdio::null())
442 .output();
443 let output = match output {
444 Ok(output) => output,
445 Err(_) => return Ok(None),
446 };
447 if !output.status.success() {
448 return Ok(None);
449 }
450
451 let stdout =
452 String::from_utf8(output.stdout).context("`cargo binstall -V` returned non-UTF8 output")?;
453 parse_installed_cargo_binstall_version(&stdout)
454}
455
456fn latest_cargo_binstall_version() -> Result<Version> {
457 let output = Command::new("cargo")
458 .arg("search")
459 .arg("cargo-binstall")
460 .arg("--limit")
461 .arg("1")
462 .stdin(Stdio::null())
463 .stderr(Stdio::null())
464 .output()
465 .with_context(|| "failed to execute `cargo search cargo-binstall --limit 1`")?;
466 if !output.status.success() {
467 bail!(
468 "`cargo search cargo-binstall --limit 1` failed with exit code {:?}",
469 output.status.code()
470 );
471 }
472
473 let stdout = String::from_utf8(output.stdout)
474 .context("`cargo search cargo-binstall --limit 1` returned non-UTF8 output")?;
475 parse_latest_cargo_binstall_version(&stdout)
476}
477
478fn parse_installed_cargo_binstall_version(stdout: &str) -> Result<Option<Version>> {
479 let line = stdout.lines().next().unwrap_or_default();
480 let maybe_version = line
481 .split_whitespace()
482 .find_map(|token| Version::parse(token.trim_start_matches('v')).ok());
483 Ok(maybe_version)
484}
485
486fn parse_latest_cargo_binstall_version(stdout: &str) -> Result<Version> {
487 let first_line = stdout
488 .lines()
489 .find(|line| !line.trim().is_empty())
490 .ok_or_else(|| anyhow!("`cargo search cargo-binstall --limit 1` returned no results"))?;
491 let (_, rhs) = first_line
492 .split_once('=')
493 .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
494 let quoted = rhs
495 .split('#')
496 .next()
497 .map(str::trim)
498 .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
499 let version_text = quoted.trim_matches('"');
500 Version::parse(version_text)
501 .with_context(|| format!("failed to parse cargo-binstall version from `{first_line}`"))
502}
503
504#[cfg(test)]
505mod tests {
506 use super::{
507 ToolchainChannel, binstall_args, delegated_binary_name_for_channel, external_tool_env_key,
508 parse_installed_cargo_binstall_version, parse_latest_cargo_binstall_version,
509 resolve_external_tool,
510 };
511 use crate::toolchain_catalogue::GREENTIC_TOOLCHAIN_PACKAGES;
512
513 #[test]
514 fn delegated_install_catalogue_includes_runner() {
515 let found = GREENTIC_TOOLCHAIN_PACKAGES.iter().any(|package| {
516 package.crate_name == "greentic-runner" && package.bins.contains(&"greentic-runner")
517 });
518 assert!(found);
519 }
520
521 #[test]
522 fn binstall_args_include_force_only_when_latest_requested() {
523 assert_eq!(
524 binstall_args("greentic-runner", "greentic-runner", false, None),
525 vec![
526 "binstall",
527 "-y",
528 "--locked",
529 "--maximum-resolution-timeout",
530 "60",
531 "greentic-runner",
532 "--bin",
533 "greentic-runner"
534 ]
535 );
536 assert_eq!(
537 binstall_args("greentic-runner", "greentic-runner", true, None),
538 vec![
539 "binstall",
540 "-y",
541 "--locked",
542 "--maximum-resolution-timeout",
543 "60",
544 "greentic-runner",
545 "--bin",
546 "greentic-runner",
547 "--force"
548 ]
549 );
550 }
551
552 #[test]
553 fn binstall_args_pin_version_for_rnd_prerelease() {
554 assert_eq!(
557 binstall_args(
558 "greentic-start-rnd",
559 "greentic-start-rnd",
560 false,
561 Some("1.2.0-research.1"),
562 ),
563 vec![
564 "binstall",
565 "-y",
566 "--locked",
567 "--maximum-resolution-timeout",
568 "60",
569 "greentic-start-rnd",
570 "--bin",
571 "greentic-start-rnd",
572 "--version",
573 "1.2.0-research.1",
574 ]
575 );
576 }
577
578 #[test]
579 fn executable_name_selects_toolchain_channel() {
580 assert_eq!(
581 ToolchainChannel::from_executable_name("greentic-dev"),
582 ToolchainChannel::Stable
583 );
584 assert_eq!(
585 ToolchainChannel::from_executable_name("greentic-dev-dev"),
586 ToolchainChannel::Development
587 );
588 assert_eq!(
589 ToolchainChannel::from_executable_name("greentic-dev-dev.exe"),
590 ToolchainChannel::Development
591 );
592 assert_eq!(
593 ToolchainChannel::from_executable_name("greentic-dev-rnd"),
594 ToolchainChannel::Rnd
595 );
596 assert_eq!(
597 ToolchainChannel::from_executable_name("greentic-dev-rnd.exe"),
598 ToolchainChannel::Rnd
599 );
600 }
601
602 #[test]
603 fn development_channel_uses_dev_binary_names() {
604 assert_eq!(
605 delegated_binary_name_for_channel("greentic-pack", ToolchainChannel::Development),
606 "greentic-pack-dev"
607 );
608 assert_eq!(
609 delegated_binary_name_for_channel("greentic-runner-cli", ToolchainChannel::Development),
610 "greentic-runner-cli-dev"
611 );
612 assert_eq!(
613 delegated_binary_name_for_channel("greentic-pack-dev", ToolchainChannel::Development),
614 "greentic-pack-dev"
615 );
616 }
617
618 #[test]
619 fn rnd_channel_uses_rnd_binary_names() {
620 assert_eq!(
621 delegated_binary_name_for_channel("greentic-pack", ToolchainChannel::Rnd),
622 "greentic-pack-rnd"
623 );
624 assert_eq!(
625 delegated_binary_name_for_channel("greentic-runner-cli", ToolchainChannel::Rnd),
626 "greentic-runner-cli-rnd"
627 );
628 assert_eq!(
629 delegated_binary_name_for_channel("greentic-pack-rnd", ToolchainChannel::Rnd),
630 "greentic-pack-rnd"
631 );
632 }
633
634 #[test]
635 fn parse_installed_binstall_version_line() {
636 let parsed = parse_installed_cargo_binstall_version("cargo-binstall 1.15.7\n")
637 .expect("parse should succeed")
638 .expect("version should exist");
639 assert_eq!(parsed.to_string(), "1.15.7");
640 }
641
642 #[test]
643 fn parse_latest_binstall_version_line() {
644 let parsed = parse_latest_cargo_binstall_version(
645 "cargo-binstall = \"1.15.7\" # Binary installation for rust projects\n",
646 )
647 .expect("parse should succeed");
648 assert_eq!(parsed.to_string(), "1.15.7");
649 }
650
651 #[test]
652 fn external_tool_env_key_is_plain_uppercase_no_channel_suffix() {
653 assert_eq!(
656 external_tool_env_key("greentic-mcp-gen"),
657 "GREENTIC_DEV_BIN_GREENTIC_MCP_GEN"
658 );
659 }
660
661 #[test]
662 fn resolve_external_tool_errors_with_plain_name_when_absent() {
663 let err = resolve_external_tool("greentic-mcp-gen-absent-xyz")
666 .expect_err("expected resolution to fail");
667 assert!(err.to_string().contains("greentic-mcp-gen-absent-xyz"));
668 }
669}