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