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        "--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    // Pre-release (`X.Y.Z-research`) `-rnd` crates need an explicit pinned
346    // version; binstall will not select a pre-release otherwise.
347    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        // The `-rnd` lane publishes `X.Y.Z-research` PRERELEASES; binstall needs
555        // an explicit `--version` to select them.
556        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        // The key derives from the plain binary name; it must never carry a
654        // `-dev`/`-rnd` channel suffix.
655        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        // A name that is not on PATH and has no env override resolves to an error
664        // that mentions the plain (unsuffixed) name.
665        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}