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_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
114pub fn run_passthrough(bin: &Path, args: &[OsString], verbose: bool) -> Result<ExitStatus> {
115    let locale = crate::i18n::select_locale(None);
116    if verbose {
117        eprintln!(
118            "{}",
119            crate::i18n::tf(
120                &locale,
121                "runtime.passthrough.debug.exec",
122                &[
123                    ("bin", bin.display().to_string()),
124                    ("args", format!("{args:?}")),
125                ],
126            )
127        );
128        // Accepted risk: delegated Greentic tool path is resolved from fixed tool names or explicit local override; no shell is invoked.
129        // foxguard: ignore[rs/no-command-injection]
130        let _ = Command::new(bin)
131            .arg("--version")
132            .stdout(Stdio::inherit())
133            .stderr(Stdio::inherit())
134            .status();
135    }
136
137    // Accepted risk: passthrough intentionally executes a resolved Greentic tool binary with argv, never through a shell.
138    // foxguard: ignore[rs/no-command-injection]
139    Command::new(bin)
140        .args(args)
141        .stdin(Stdio::inherit())
142        .stdout(Stdio::inherit())
143        .stderr(Stdio::inherit())
144        .status()
145        .map_err(|e| {
146            anyhow!(crate::i18n::tf(
147                &locale,
148                "runtime.passthrough.error.execute",
149                &[("bin", bin.display().to_string()), ("error", e.to_string())],
150            ))
151        })
152}
153
154pub fn install_all_delegated_tools(latest: bool, locale: &str) -> Result<()> {
155    ensure_cargo_binstall()?;
156    let channel = current_toolchain_channel();
157    // The research (`rnd`) lane publishes `<crate>-rnd` at `X.Y.Z-research`
158    // PRERELEASE versions. `cargo binstall`/cargo will not select a pre-release
159    // without an explicit `--version`, so for the Rnd channel we resolve the
160    // latest research version per crate and pin it. Stable/Development publish
161    // regular releases that binstall picks up without a version.
162    let resolver = (channel == ToolchainChannel::Rnd)
163        .then(crate::release_cmd::CratesIoApiVersionResolver::default);
164    for package in GREENTIC_TOOLCHAIN_PACKAGES {
165        let crate_name = delegated_binary_name_for_channel(package.crate_name, channel);
166        let version: Option<String> = match resolver.as_ref() {
167            // Research channel: resolve the `-rnd` prerelease to pin. Tools with
168            // no research build (404) come back `Absent` — skip them with a note
169            // instead of aborting the whole install, since only start/runner/
170            // setup ship `-research` builds. Use the stable channel for the rest.
171            Some(resolver) => {
172                match crate::release_cmd::CrateVersionResolver::resolve_research_version(
173                    resolver,
174                    &crate_name,
175                )
176                .with_context(|| {
177                    format!(
178                        "failed to resolve research version for `{crate_name}` \
179                         (gtc-research install needs an explicit pre-release version)"
180                    )
181                })? {
182                    crate::release_cmd::ResearchVersion::Pinned(version) => Some(version),
183                    crate::release_cmd::ResearchVersion::Absent => {
184                        eprintln!(
185                            "note: `{crate_name}` has no research build on crates.io; \
186                             skipping it on the research toolchain (use the stable \
187                             channel for this tool)"
188                        );
189                        continue;
190                    }
191                }
192            }
193            None => None,
194        };
195        for bin_name in package.bins {
196            install_with_binstall(
197                &crate_name,
198                &delegated_binary_name_for_channel(bin_name, channel),
199                latest,
200                version.as_deref(),
201                locale,
202            )?;
203        }
204    }
205    Ok(())
206}
207
208fn install_with_binstall(
209    crate_name: &str,
210    bin_name: &str,
211    force_latest: bool,
212    version: Option<&str>,
213    locale: &str,
214) -> Result<()> {
215    eprintln!(
216        "{}",
217        crate::i18n::tf(
218            locale,
219            "runtime.tools.install.installing",
220            &[
221                ("bin_name", bin_name.to_string()),
222                ("crate_name", crate_name.to_string()),
223            ],
224        )
225    );
226
227    let mut cmd = Command::new("cargo");
228    cmd.args(binstall_args(crate_name, bin_name, force_latest, version));
229
230    let status = cmd
231        .stdin(Stdio::inherit())
232        .stdout(Stdio::inherit())
233        .stderr(Stdio::inherit())
234        .status()
235        .with_context(|| crate::i18n::t(locale, "runtime.tools.install.error.execute_binstall"))?;
236
237    if status.success() {
238        Ok(())
239    } else {
240        bail!(
241            "{}",
242            crate::i18n::tf(
243                locale,
244                "runtime.tools.install.error.binstall_failed",
245                &[
246                    ("bin_name", bin_name.to_string()),
247                    ("crate_name", crate_name.to_string()),
248                    ("exit_code", format!("{:?}", status.code())),
249                ],
250            )
251        );
252    }
253}
254
255fn binstall_args(
256    crate_name: &str,
257    bin_name: &str,
258    force_latest: bool,
259    version: Option<&str>,
260) -> Vec<String> {
261    let mut args = vec![
262        "binstall".to_string(),
263        "-y".to_string(),
264        "--locked".to_string(),
265        crate_name.to_string(),
266        "--bin".to_string(),
267        bin_name.to_string(),
268    ];
269    // Pre-release (`X.Y.Z-research`) `-rnd` crates need an explicit pinned
270    // version; binstall will not select a pre-release otherwise.
271    if let Some(version) = version {
272        args.push("--version".to_string());
273        args.push(version.to_string());
274    }
275    if force_latest {
276        args.push("--force".to_string());
277    }
278    args
279}
280
281fn ensure_cargo_binstall() -> Result<()> {
282    let locale = crate::i18n::select_locale(None);
283    let installed_version = installed_cargo_binstall_version()?;
284    if installed_version.is_none() {
285        eprintln!(
286            "{}",
287            crate::i18n::t(&locale, "runtime.tools.install.installing_binstall")
288        );
289        return install_cargo_binstall();
290    }
291
292    let installed_version = installed_version.expect("checked is_some above");
293    match latest_cargo_binstall_version() {
294        Ok(latest_version) => {
295            if installed_version >= latest_version {
296                return Ok(());
297            }
298
299            eprintln!(
300                "{}",
301                crate::i18n::tf(
302                    &locale,
303                    "runtime.tools.install.updating_binstall",
304                    &[
305                        ("installed_version", installed_version.to_string()),
306                        ("latest_version", latest_version.to_string()),
307                    ],
308                )
309            );
310            install_cargo_binstall()
311        }
312        Err(err) => {
313            eprintln!(
314                "{}",
315                crate::i18n::tf(
316                    &locale,
317                    "runtime.tools.install.warn.latest_check_failed",
318                    &[
319                        ("error", err.to_string()),
320                        ("installed_version", installed_version.to_string()),
321                    ],
322                )
323            );
324            Ok(())
325        }
326    }
327}
328
329fn install_cargo_binstall() -> Result<()> {
330    let status = Command::new("cargo")
331        .arg("install")
332        .arg("cargo-binstall")
333        .arg("--locked")
334        .stdin(Stdio::inherit())
335        .stdout(Stdio::inherit())
336        .stderr(Stdio::inherit())
337        .status()
338        .with_context(|| {
339            crate::i18n::t(
340                &crate::i18n::select_locale(None),
341                "runtime.tools.install.error.execute_install_binstall",
342            )
343        })?;
344
345    if status.success() {
346        Ok(())
347    } else {
348        let locale = crate::i18n::select_locale(None);
349        bail!(
350            "{}",
351            crate::i18n::tf(
352                &locale,
353                "runtime.tools.install.error.install_binstall_failed",
354                &[("exit_code", format!("{:?}", status.code()))],
355            )
356        );
357    }
358}
359
360fn installed_cargo_binstall_version() -> Result<Option<Version>> {
361    let output = Command::new("cargo")
362        .arg("binstall")
363        .arg("-V")
364        .stdin(Stdio::null())
365        .stderr(Stdio::null())
366        .output();
367    let output = match output {
368        Ok(output) => output,
369        Err(_) => return Ok(None),
370    };
371    if !output.status.success() {
372        return Ok(None);
373    }
374
375    let stdout =
376        String::from_utf8(output.stdout).context("`cargo binstall -V` returned non-UTF8 output")?;
377    parse_installed_cargo_binstall_version(&stdout)
378}
379
380fn latest_cargo_binstall_version() -> Result<Version> {
381    let output = Command::new("cargo")
382        .arg("search")
383        .arg("cargo-binstall")
384        .arg("--limit")
385        .arg("1")
386        .stdin(Stdio::null())
387        .stderr(Stdio::null())
388        .output()
389        .with_context(|| "failed to execute `cargo search cargo-binstall --limit 1`")?;
390    if !output.status.success() {
391        bail!(
392            "`cargo search cargo-binstall --limit 1` failed with exit code {:?}",
393            output.status.code()
394        );
395    }
396
397    let stdout = String::from_utf8(output.stdout)
398        .context("`cargo search cargo-binstall --limit 1` returned non-UTF8 output")?;
399    parse_latest_cargo_binstall_version(&stdout)
400}
401
402fn parse_installed_cargo_binstall_version(stdout: &str) -> Result<Option<Version>> {
403    let line = stdout.lines().next().unwrap_or_default();
404    let maybe_version = line
405        .split_whitespace()
406        .find_map(|token| Version::parse(token.trim_start_matches('v')).ok());
407    Ok(maybe_version)
408}
409
410fn parse_latest_cargo_binstall_version(stdout: &str) -> Result<Version> {
411    let first_line = stdout
412        .lines()
413        .find(|line| !line.trim().is_empty())
414        .ok_or_else(|| anyhow!("`cargo search cargo-binstall --limit 1` returned no results"))?;
415    let (_, rhs) = first_line
416        .split_once('=')
417        .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
418    let quoted = rhs
419        .split('#')
420        .next()
421        .map(str::trim)
422        .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
423    let version_text = quoted.trim_matches('"');
424    Version::parse(version_text)
425        .with_context(|| format!("failed to parse cargo-binstall version from `{first_line}`"))
426}
427
428#[cfg(test)]
429mod tests {
430    use super::{
431        ToolchainChannel, binstall_args, delegated_binary_name_for_channel,
432        parse_installed_cargo_binstall_version, parse_latest_cargo_binstall_version,
433    };
434    use crate::toolchain_catalogue::GREENTIC_TOOLCHAIN_PACKAGES;
435
436    #[test]
437    fn delegated_install_catalogue_includes_runner() {
438        let found = GREENTIC_TOOLCHAIN_PACKAGES.iter().any(|package| {
439            package.crate_name == "greentic-runner" && package.bins.contains(&"greentic-runner")
440        });
441        assert!(found);
442    }
443
444    #[test]
445    fn binstall_args_include_force_only_when_latest_requested() {
446        assert_eq!(
447            binstall_args("greentic-runner", "greentic-runner", false, None),
448            vec![
449                "binstall",
450                "-y",
451                "--locked",
452                "greentic-runner",
453                "--bin",
454                "greentic-runner"
455            ]
456        );
457        assert_eq!(
458            binstall_args("greentic-runner", "greentic-runner", true, None),
459            vec![
460                "binstall",
461                "-y",
462                "--locked",
463                "greentic-runner",
464                "--bin",
465                "greentic-runner",
466                "--force"
467            ]
468        );
469    }
470
471    #[test]
472    fn binstall_args_pin_version_for_rnd_prerelease() {
473        // The `-rnd` lane publishes `X.Y.Z-research` PRERELEASES; binstall needs
474        // an explicit `--version` to select them.
475        assert_eq!(
476            binstall_args(
477                "greentic-start-rnd",
478                "greentic-start-rnd",
479                false,
480                Some("1.2.0-research.1"),
481            ),
482            vec![
483                "binstall",
484                "-y",
485                "--locked",
486                "greentic-start-rnd",
487                "--bin",
488                "greentic-start-rnd",
489                "--version",
490                "1.2.0-research.1",
491            ]
492        );
493    }
494
495    #[test]
496    fn executable_name_selects_toolchain_channel() {
497        assert_eq!(
498            ToolchainChannel::from_executable_name("greentic-dev"),
499            ToolchainChannel::Stable
500        );
501        assert_eq!(
502            ToolchainChannel::from_executable_name("greentic-dev-dev"),
503            ToolchainChannel::Development
504        );
505        assert_eq!(
506            ToolchainChannel::from_executable_name("greentic-dev-dev.exe"),
507            ToolchainChannel::Development
508        );
509        assert_eq!(
510            ToolchainChannel::from_executable_name("greentic-dev-rnd"),
511            ToolchainChannel::Rnd
512        );
513        assert_eq!(
514            ToolchainChannel::from_executable_name("greentic-dev-rnd.exe"),
515            ToolchainChannel::Rnd
516        );
517    }
518
519    #[test]
520    fn development_channel_uses_dev_binary_names() {
521        assert_eq!(
522            delegated_binary_name_for_channel("greentic-pack", ToolchainChannel::Development),
523            "greentic-pack-dev"
524        );
525        assert_eq!(
526            delegated_binary_name_for_channel("greentic-runner-cli", ToolchainChannel::Development),
527            "greentic-runner-cli-dev"
528        );
529        assert_eq!(
530            delegated_binary_name_for_channel("greentic-pack-dev", ToolchainChannel::Development),
531            "greentic-pack-dev"
532        );
533    }
534
535    #[test]
536    fn rnd_channel_uses_rnd_binary_names() {
537        assert_eq!(
538            delegated_binary_name_for_channel("greentic-pack", ToolchainChannel::Rnd),
539            "greentic-pack-rnd"
540        );
541        assert_eq!(
542            delegated_binary_name_for_channel("greentic-runner-cli", ToolchainChannel::Rnd),
543            "greentic-runner-cli-rnd"
544        );
545        assert_eq!(
546            delegated_binary_name_for_channel("greentic-pack-rnd", ToolchainChannel::Rnd),
547            "greentic-pack-rnd"
548        );
549    }
550
551    #[test]
552    fn parse_installed_binstall_version_line() {
553        let parsed = parse_installed_cargo_binstall_version("cargo-binstall 1.15.7\n")
554            .expect("parse should succeed")
555            .expect("version should exist");
556        assert_eq!(parsed.to_string(), "1.15.7");
557    }
558
559    #[test]
560    fn parse_latest_binstall_version_line() {
561        let parsed = parse_latest_cargo_binstall_version(
562            "cargo-binstall = \"1.15.7\"    # Binary installation for rust projects\n",
563        )
564        .expect("parse should succeed");
565        assert_eq!(parsed.to_string(), "1.15.7");
566    }
567}