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 channel = current_toolchain_channel();
204    // The research (`rnd`) lane publishes `<crate>-rnd` at `X.Y.Z-research`
205    // PRERELEASE versions. `cargo binstall`/cargo will not select a pre-release
206    // without an explicit `--version`, so for the Rnd channel we resolve the
207    // latest research version per crate and pin it. Stable/Development publish
208    // regular releases that binstall picks up without a version.
209    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            // Research channel: resolve the `-rnd` prerelease to pin. Tools with
215            // no research build (404) come back `Absent` — skip them with a note
216            // instead of aborting the whole install, since only start/runner/
217            // setup ship `-research` builds. Use the stable channel for the rest.
218            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    // External tools ship a single unsuffixed binary — install by plain name.
253    //
254    // A failure here MUST NOT abort the run. `greentic-mcp-generator` is not
255    // published to crates.io at all — it ships as a private GitHub release and
256    // reaches a customer through `install --tenant` — so `cargo binstall` can
257    // never resolve it and exits 76 every time. With `?`, that took the whole
258    // command down, including `install --tenant`, which calls this before
259    // fetching a single tenant artifact: every tenant install failed having
260    // installed nothing, and the error named a crate the operator could do
261    // nothing about.
262    //
263    // The core toolchain above stays fatal on purpose — those binaries are
264    // required. These are optional, and an operator who needs one gets it from
265    // the tenant install path that is designed to carry it.
266    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        crate_name.to_string(),
340        "--bin".to_string(),
341        bin_name.to_string(),
342    ];
343    // Pre-release (`X.Y.Z-research`) `-rnd` crates need an explicit pinned
344    // version; binstall will not select a pre-release otherwise.
345    if let Some(version) = version {
346        args.push("--version".to_string());
347        args.push(version.to_string());
348    }
349    if force_latest {
350        args.push("--force".to_string());
351    }
352    args
353}
354
355fn ensure_cargo_binstall() -> Result<()> {
356    let locale = crate::i18n::select_locale(None);
357    let installed_version = installed_cargo_binstall_version()?;
358    if installed_version.is_none() {
359        eprintln!(
360            "{}",
361            crate::i18n::t(&locale, "runtime.tools.install.installing_binstall")
362        );
363        return install_cargo_binstall();
364    }
365
366    let installed_version = installed_version.expect("checked is_some above");
367    match latest_cargo_binstall_version() {
368        Ok(latest_version) => {
369            if installed_version >= latest_version {
370                return Ok(());
371            }
372
373            eprintln!(
374                "{}",
375                crate::i18n::tf(
376                    &locale,
377                    "runtime.tools.install.updating_binstall",
378                    &[
379                        ("installed_version", installed_version.to_string()),
380                        ("latest_version", latest_version.to_string()),
381                    ],
382                )
383            );
384            install_cargo_binstall()
385        }
386        Err(err) => {
387            eprintln!(
388                "{}",
389                crate::i18n::tf(
390                    &locale,
391                    "runtime.tools.install.warn.latest_check_failed",
392                    &[
393                        ("error", err.to_string()),
394                        ("installed_version", installed_version.to_string()),
395                    ],
396                )
397            );
398            Ok(())
399        }
400    }
401}
402
403fn install_cargo_binstall() -> Result<()> {
404    let status = Command::new("cargo")
405        .arg("install")
406        .arg("cargo-binstall")
407        .arg("--locked")
408        .stdin(Stdio::inherit())
409        .stdout(Stdio::inherit())
410        .stderr(Stdio::inherit())
411        .status()
412        .with_context(|| {
413            crate::i18n::t(
414                &crate::i18n::select_locale(None),
415                "runtime.tools.install.error.execute_install_binstall",
416            )
417        })?;
418
419    if status.success() {
420        Ok(())
421    } else {
422        let locale = crate::i18n::select_locale(None);
423        bail!(
424            "{}",
425            crate::i18n::tf(
426                &locale,
427                "runtime.tools.install.error.install_binstall_failed",
428                &[("exit_code", format!("{:?}", status.code()))],
429            )
430        );
431    }
432}
433
434fn installed_cargo_binstall_version() -> Result<Option<Version>> {
435    let output = Command::new("cargo")
436        .arg("binstall")
437        .arg("-V")
438        .stdin(Stdio::null())
439        .stderr(Stdio::null())
440        .output();
441    let output = match output {
442        Ok(output) => output,
443        Err(_) => return Ok(None),
444    };
445    if !output.status.success() {
446        return Ok(None);
447    }
448
449    let stdout =
450        String::from_utf8(output.stdout).context("`cargo binstall -V` returned non-UTF8 output")?;
451    parse_installed_cargo_binstall_version(&stdout)
452}
453
454fn latest_cargo_binstall_version() -> Result<Version> {
455    let output = Command::new("cargo")
456        .arg("search")
457        .arg("cargo-binstall")
458        .arg("--limit")
459        .arg("1")
460        .stdin(Stdio::null())
461        .stderr(Stdio::null())
462        .output()
463        .with_context(|| "failed to execute `cargo search cargo-binstall --limit 1`")?;
464    if !output.status.success() {
465        bail!(
466            "`cargo search cargo-binstall --limit 1` failed with exit code {:?}",
467            output.status.code()
468        );
469    }
470
471    let stdout = String::from_utf8(output.stdout)
472        .context("`cargo search cargo-binstall --limit 1` returned non-UTF8 output")?;
473    parse_latest_cargo_binstall_version(&stdout)
474}
475
476fn parse_installed_cargo_binstall_version(stdout: &str) -> Result<Option<Version>> {
477    let line = stdout.lines().next().unwrap_or_default();
478    let maybe_version = line
479        .split_whitespace()
480        .find_map(|token| Version::parse(token.trim_start_matches('v')).ok());
481    Ok(maybe_version)
482}
483
484fn parse_latest_cargo_binstall_version(stdout: &str) -> Result<Version> {
485    let first_line = stdout
486        .lines()
487        .find(|line| !line.trim().is_empty())
488        .ok_or_else(|| anyhow!("`cargo search cargo-binstall --limit 1` returned no results"))?;
489    let (_, rhs) = first_line
490        .split_once('=')
491        .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
492    let quoted = rhs
493        .split('#')
494        .next()
495        .map(str::trim)
496        .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
497    let version_text = quoted.trim_matches('"');
498    Version::parse(version_text)
499        .with_context(|| format!("failed to parse cargo-binstall version from `{first_line}`"))
500}
501
502#[cfg(test)]
503mod tests {
504    use super::{
505        ToolchainChannel, binstall_args, delegated_binary_name_for_channel, external_tool_env_key,
506        parse_installed_cargo_binstall_version, parse_latest_cargo_binstall_version,
507        resolve_external_tool,
508    };
509    use crate::toolchain_catalogue::GREENTIC_TOOLCHAIN_PACKAGES;
510
511    #[test]
512    fn delegated_install_catalogue_includes_runner() {
513        let found = GREENTIC_TOOLCHAIN_PACKAGES.iter().any(|package| {
514            package.crate_name == "greentic-runner" && package.bins.contains(&"greentic-runner")
515        });
516        assert!(found);
517    }
518
519    #[test]
520    fn binstall_args_include_force_only_when_latest_requested() {
521        assert_eq!(
522            binstall_args("greentic-runner", "greentic-runner", false, None),
523            vec![
524                "binstall",
525                "-y",
526                "--locked",
527                "greentic-runner",
528                "--bin",
529                "greentic-runner"
530            ]
531        );
532        assert_eq!(
533            binstall_args("greentic-runner", "greentic-runner", true, None),
534            vec![
535                "binstall",
536                "-y",
537                "--locked",
538                "greentic-runner",
539                "--bin",
540                "greentic-runner",
541                "--force"
542            ]
543        );
544    }
545
546    #[test]
547    fn binstall_args_pin_version_for_rnd_prerelease() {
548        // The `-rnd` lane publishes `X.Y.Z-research` PRERELEASES; binstall needs
549        // an explicit `--version` to select them.
550        assert_eq!(
551            binstall_args(
552                "greentic-start-rnd",
553                "greentic-start-rnd",
554                false,
555                Some("1.2.0-research.1"),
556            ),
557            vec![
558                "binstall",
559                "-y",
560                "--locked",
561                "greentic-start-rnd",
562                "--bin",
563                "greentic-start-rnd",
564                "--version",
565                "1.2.0-research.1",
566            ]
567        );
568    }
569
570    #[test]
571    fn executable_name_selects_toolchain_channel() {
572        assert_eq!(
573            ToolchainChannel::from_executable_name("greentic-dev"),
574            ToolchainChannel::Stable
575        );
576        assert_eq!(
577            ToolchainChannel::from_executable_name("greentic-dev-dev"),
578            ToolchainChannel::Development
579        );
580        assert_eq!(
581            ToolchainChannel::from_executable_name("greentic-dev-dev.exe"),
582            ToolchainChannel::Development
583        );
584        assert_eq!(
585            ToolchainChannel::from_executable_name("greentic-dev-rnd"),
586            ToolchainChannel::Rnd
587        );
588        assert_eq!(
589            ToolchainChannel::from_executable_name("greentic-dev-rnd.exe"),
590            ToolchainChannel::Rnd
591        );
592    }
593
594    #[test]
595    fn development_channel_uses_dev_binary_names() {
596        assert_eq!(
597            delegated_binary_name_for_channel("greentic-pack", ToolchainChannel::Development),
598            "greentic-pack-dev"
599        );
600        assert_eq!(
601            delegated_binary_name_for_channel("greentic-runner-cli", ToolchainChannel::Development),
602            "greentic-runner-cli-dev"
603        );
604        assert_eq!(
605            delegated_binary_name_for_channel("greentic-pack-dev", ToolchainChannel::Development),
606            "greentic-pack-dev"
607        );
608    }
609
610    #[test]
611    fn rnd_channel_uses_rnd_binary_names() {
612        assert_eq!(
613            delegated_binary_name_for_channel("greentic-pack", ToolchainChannel::Rnd),
614            "greentic-pack-rnd"
615        );
616        assert_eq!(
617            delegated_binary_name_for_channel("greentic-runner-cli", ToolchainChannel::Rnd),
618            "greentic-runner-cli-rnd"
619        );
620        assert_eq!(
621            delegated_binary_name_for_channel("greentic-pack-rnd", ToolchainChannel::Rnd),
622            "greentic-pack-rnd"
623        );
624    }
625
626    #[test]
627    fn parse_installed_binstall_version_line() {
628        let parsed = parse_installed_cargo_binstall_version("cargo-binstall 1.15.7\n")
629            .expect("parse should succeed")
630            .expect("version should exist");
631        assert_eq!(parsed.to_string(), "1.15.7");
632    }
633
634    #[test]
635    fn parse_latest_binstall_version_line() {
636        let parsed = parse_latest_cargo_binstall_version(
637            "cargo-binstall = \"1.15.7\"    # Binary installation for rust projects\n",
638        )
639        .expect("parse should succeed");
640        assert_eq!(parsed.to_string(), "1.15.7");
641    }
642
643    #[test]
644    fn external_tool_env_key_is_plain_uppercase_no_channel_suffix() {
645        // The key derives from the plain binary name; it must never carry a
646        // `-dev`/`-rnd` channel suffix.
647        assert_eq!(
648            external_tool_env_key("greentic-mcp-gen"),
649            "GREENTIC_DEV_BIN_GREENTIC_MCP_GEN"
650        );
651    }
652
653    #[test]
654    fn resolve_external_tool_errors_with_plain_name_when_absent() {
655        // A name that is not on PATH and has no env override resolves to an error
656        // that mentions the plain (unsuffixed) name.
657        let err = resolve_external_tool("greentic-mcp-gen-absent-xyz")
658            .expect_err("expected resolution to fail");
659        assert!(err.to_string().contains("greentic-mcp-gen-absent-xyz"));
660    }
661}