Skip to main content

greentic_dev/
passthrough.rs

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
70/// Resolve a binary by name using env override, then PATH.
71pub 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
114/// Environment-override key for an external tool, e.g. `greentic-mcp-gen`
115/// → `GREENTIC_DEV_BIN_GREENTIC_MCP_GEN`.
116pub(crate) fn external_tool_env_key(name: &str) -> String {
117    format!("GREENTIC_DEV_BIN_{}", name.replace('-', "_").to_uppercase())
118}
119
120/// Resolve an external (non-Greentic-channel) tool binary by its plain name.
121///
122/// Unlike [`resolve_binary`], this never appends the toolchain channel suffix
123/// (`-dev`/`-rnd`): external tools such as `greentic-mcp-gen` ship a single,
124/// unsuffixed binary. Resolution order: `GREENTIC_DEV_BIN_<NAME>` env override,
125/// then `PATH`.
126pub 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        // Accepted risk: delegated Greentic tool path is resolved from fixed tool names or explicit local override; no shell is invoked.
176        // foxguard: ignore[rs/no-command-injection]
177        let _ = Command::new(bin)
178            .arg("--version")
179            .stdout(Stdio::inherit())
180            .stderr(Stdio::inherit())
181            .status();
182    }
183
184    // Accepted risk: passthrough intentionally executes a resolved Greentic tool binary with argv, never through a shell.
185    // foxguard: ignore[rs/no-command-injection]
186    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    // The research (`rnd`) lane publishes `<crate>-rnd` at `X.Y.Z-research`
206    // PRERELEASE versions. `cargo binstall`/cargo will not select a pre-release
207    // without an explicit `--version`, so for the Rnd channel we resolve the
208    // latest research version per crate and pin it. Stable/Development publish
209    // regular releases that binstall picks up without a version.
210    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            // Research channel: resolve the `-rnd` prerelease to pin. Tools with
216            // no research build (404) come back `Absent` — skip them with a note
217            // instead of aborting the whole install, since only start/runner/
218            // setup ship `-research` builds. Use the stable channel for the rest.
219            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            // Collected, not propagated. With `?` the loop stopped at the
246            // first crate binstall could not resolve, having ALREADY replaced
247            // every binary before it in the list — so a single unpublishable
248            // crate left the toolchain half-moved and the command reporting
249            // only the crate that failed. That is the same failure the
250            // external-tools loop below was fixed for; the difference is only
251            // that these are required, so the run still ends in an error.
252            //
253            // It matters most on the development channel: crates.io has
254            // carried no Greentic dev build since the publishing account was
255            // locked on 2026-09-08, so binstall there resolves whatever older
256            // version the index still knows. Finishing the list is what keeps
257            // one frozen crate from deciding how far the rest got.
258            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    // External tools ship a single unsuffixed binary — install by plain name.
267    //
268    // A failure here MUST NOT abort the run, and must not even be counted.
269    // `greentic-mcp-generator` is not published to crates.io at all — it ships
270    // as a private GitHub release and reaches a customer through
271    // `install --tenant` — so `cargo binstall` can never resolve it and exits
272    // 76 every time. With `?`, that took the whole command down, and at the
273    // time `install --tenant` called this before fetching a single tenant
274    // artifact, so every tenant install failed having installed nothing, with
275    // an error naming a crate the operator could do nothing about. (That
276    // second half no longer applies: `install --tenant` installs tenant
277    // artifacts only — see the comment at its call site in `install.rs`.)
278    //
279    // The core toolchain above is still REQUIRED and still ends the run in an
280    // error; the difference is that it now finishes the list first, so one
281    // unresolvable crate does not decide how far the rest got. These are
282    // optional, so they are skipped with a note and never reach `failed`.
283    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        // Named individually rather than as a count: on the development
299        // channel the usual cause is that crates.io has carried no Greentic
300        // dev build since the publishing account was locked on 2026-09-08, and
301        // which binaries that leaves behind is the whole of what an operator
302        // needs to know to side-load them from the GitHub release archives.
303        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    // Pre-release (`X.Y.Z-research`) `-rnd` crates need an explicit pinned
376    // version; binstall will not select a pre-release otherwise.
377    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        // The `-rnd` lane publishes `X.Y.Z-research` PRERELEASES; binstall needs
585        // an explicit `--version` to select them.
586        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        // The key derives from the plain binary name; it must never carry a
684        // `-dev`/`-rnd` channel suffix.
685        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        // A name that is not on PATH and has no env override resolves to an error
694        // that mentions the plain (unsuffixed) name.
695        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}