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    for package in GREENTIC_TOOLCHAIN_PACKAGES {
158        let crate_name = delegated_binary_name_for_channel(package.crate_name, channel);
159        for bin_name in package.bins {
160            install_with_binstall(
161                &crate_name,
162                &delegated_binary_name_for_channel(bin_name, channel),
163                latest,
164                locale,
165            )?;
166        }
167    }
168    Ok(())
169}
170
171fn install_with_binstall(
172    crate_name: &str,
173    bin_name: &str,
174    force_latest: bool,
175    locale: &str,
176) -> Result<()> {
177    eprintln!(
178        "{}",
179        crate::i18n::tf(
180            locale,
181            "runtime.tools.install.installing",
182            &[
183                ("bin_name", bin_name.to_string()),
184                ("crate_name", crate_name.to_string()),
185            ],
186        )
187    );
188
189    let mut cmd = Command::new("cargo");
190    cmd.args(binstall_args(crate_name, bin_name, force_latest));
191
192    let status = cmd
193        .stdin(Stdio::inherit())
194        .stdout(Stdio::inherit())
195        .stderr(Stdio::inherit())
196        .status()
197        .with_context(|| crate::i18n::t(locale, "runtime.tools.install.error.execute_binstall"))?;
198
199    if status.success() {
200        Ok(())
201    } else {
202        bail!(
203            "{}",
204            crate::i18n::tf(
205                locale,
206                "runtime.tools.install.error.binstall_failed",
207                &[
208                    ("bin_name", bin_name.to_string()),
209                    ("crate_name", crate_name.to_string()),
210                    ("exit_code", format!("{:?}", status.code())),
211                ],
212            )
213        );
214    }
215}
216
217fn binstall_args(crate_name: &str, bin_name: &str, force_latest: bool) -> Vec<String> {
218    let mut args = vec![
219        "binstall".to_string(),
220        "-y".to_string(),
221        "--locked".to_string(),
222        crate_name.to_string(),
223        "--bin".to_string(),
224        bin_name.to_string(),
225    ];
226    if force_latest {
227        args.push("--force".to_string());
228    }
229    args
230}
231
232fn ensure_cargo_binstall() -> Result<()> {
233    let locale = crate::i18n::select_locale(None);
234    let installed_version = installed_cargo_binstall_version()?;
235    if installed_version.is_none() {
236        eprintln!(
237            "{}",
238            crate::i18n::t(&locale, "runtime.tools.install.installing_binstall")
239        );
240        return install_cargo_binstall();
241    }
242
243    let installed_version = installed_version.expect("checked is_some above");
244    match latest_cargo_binstall_version() {
245        Ok(latest_version) => {
246            if installed_version >= latest_version {
247                return Ok(());
248            }
249
250            eprintln!(
251                "{}",
252                crate::i18n::tf(
253                    &locale,
254                    "runtime.tools.install.updating_binstall",
255                    &[
256                        ("installed_version", installed_version.to_string()),
257                        ("latest_version", latest_version.to_string()),
258                    ],
259                )
260            );
261            install_cargo_binstall()
262        }
263        Err(err) => {
264            eprintln!(
265                "{}",
266                crate::i18n::tf(
267                    &locale,
268                    "runtime.tools.install.warn.latest_check_failed",
269                    &[
270                        ("error", err.to_string()),
271                        ("installed_version", installed_version.to_string()),
272                    ],
273                )
274            );
275            Ok(())
276        }
277    }
278}
279
280fn install_cargo_binstall() -> Result<()> {
281    let status = Command::new("cargo")
282        .arg("install")
283        .arg("cargo-binstall")
284        .arg("--locked")
285        .stdin(Stdio::inherit())
286        .stdout(Stdio::inherit())
287        .stderr(Stdio::inherit())
288        .status()
289        .with_context(|| {
290            crate::i18n::t(
291                &crate::i18n::select_locale(None),
292                "runtime.tools.install.error.execute_install_binstall",
293            )
294        })?;
295
296    if status.success() {
297        Ok(())
298    } else {
299        let locale = crate::i18n::select_locale(None);
300        bail!(
301            "{}",
302            crate::i18n::tf(
303                &locale,
304                "runtime.tools.install.error.install_binstall_failed",
305                &[("exit_code", format!("{:?}", status.code()))],
306            )
307        );
308    }
309}
310
311fn installed_cargo_binstall_version() -> Result<Option<Version>> {
312    let output = Command::new("cargo")
313        .arg("binstall")
314        .arg("-V")
315        .stdin(Stdio::null())
316        .stderr(Stdio::null())
317        .output();
318    let output = match output {
319        Ok(output) => output,
320        Err(_) => return Ok(None),
321    };
322    if !output.status.success() {
323        return Ok(None);
324    }
325
326    let stdout =
327        String::from_utf8(output.stdout).context("`cargo binstall -V` returned non-UTF8 output")?;
328    parse_installed_cargo_binstall_version(&stdout)
329}
330
331fn latest_cargo_binstall_version() -> Result<Version> {
332    let output = Command::new("cargo")
333        .arg("search")
334        .arg("cargo-binstall")
335        .arg("--limit")
336        .arg("1")
337        .stdin(Stdio::null())
338        .stderr(Stdio::null())
339        .output()
340        .with_context(|| "failed to execute `cargo search cargo-binstall --limit 1`")?;
341    if !output.status.success() {
342        bail!(
343            "`cargo search cargo-binstall --limit 1` failed with exit code {:?}",
344            output.status.code()
345        );
346    }
347
348    let stdout = String::from_utf8(output.stdout)
349        .context("`cargo search cargo-binstall --limit 1` returned non-UTF8 output")?;
350    parse_latest_cargo_binstall_version(&stdout)
351}
352
353fn parse_installed_cargo_binstall_version(stdout: &str) -> Result<Option<Version>> {
354    let line = stdout.lines().next().unwrap_or_default();
355    let maybe_version = line
356        .split_whitespace()
357        .find_map(|token| Version::parse(token.trim_start_matches('v')).ok());
358    Ok(maybe_version)
359}
360
361fn parse_latest_cargo_binstall_version(stdout: &str) -> Result<Version> {
362    let first_line = stdout
363        .lines()
364        .find(|line| !line.trim().is_empty())
365        .ok_or_else(|| anyhow!("`cargo search cargo-binstall --limit 1` returned no results"))?;
366    let (_, rhs) = first_line
367        .split_once('=')
368        .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
369    let quoted = rhs
370        .split('#')
371        .next()
372        .map(str::trim)
373        .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
374    let version_text = quoted.trim_matches('"');
375    Version::parse(version_text)
376        .with_context(|| format!("failed to parse cargo-binstall version from `{first_line}`"))
377}
378
379#[cfg(test)]
380mod tests {
381    use super::{
382        ToolchainChannel, binstall_args, delegated_binary_name_for_channel,
383        parse_installed_cargo_binstall_version, parse_latest_cargo_binstall_version,
384    };
385    use crate::toolchain_catalogue::GREENTIC_TOOLCHAIN_PACKAGES;
386
387    #[test]
388    fn delegated_install_catalogue_includes_runner() {
389        let found = GREENTIC_TOOLCHAIN_PACKAGES.iter().any(|package| {
390            package.crate_name == "greentic-runner" && package.bins.contains(&"greentic-runner")
391        });
392        assert!(found);
393    }
394
395    #[test]
396    fn binstall_args_include_force_only_when_latest_requested() {
397        assert_eq!(
398            binstall_args("greentic-runner", "greentic-runner", false),
399            vec![
400                "binstall",
401                "-y",
402                "--locked",
403                "greentic-runner",
404                "--bin",
405                "greentic-runner"
406            ]
407        );
408        assert_eq!(
409            binstall_args("greentic-runner", "greentic-runner", true),
410            vec![
411                "binstall",
412                "-y",
413                "--locked",
414                "greentic-runner",
415                "--bin",
416                "greentic-runner",
417                "--force"
418            ]
419        );
420    }
421
422    #[test]
423    fn executable_name_selects_toolchain_channel() {
424        assert_eq!(
425            ToolchainChannel::from_executable_name("greentic-dev"),
426            ToolchainChannel::Stable
427        );
428        assert_eq!(
429            ToolchainChannel::from_executable_name("greentic-dev-dev"),
430            ToolchainChannel::Development
431        );
432        assert_eq!(
433            ToolchainChannel::from_executable_name("greentic-dev-dev.exe"),
434            ToolchainChannel::Development
435        );
436        assert_eq!(
437            ToolchainChannel::from_executable_name("greentic-dev-rnd"),
438            ToolchainChannel::Rnd
439        );
440        assert_eq!(
441            ToolchainChannel::from_executable_name("greentic-dev-rnd.exe"),
442            ToolchainChannel::Rnd
443        );
444    }
445
446    #[test]
447    fn development_channel_uses_dev_binary_names() {
448        assert_eq!(
449            delegated_binary_name_for_channel("greentic-pack", ToolchainChannel::Development),
450            "greentic-pack-dev"
451        );
452        assert_eq!(
453            delegated_binary_name_for_channel("greentic-runner-cli", ToolchainChannel::Development),
454            "greentic-runner-cli-dev"
455        );
456        assert_eq!(
457            delegated_binary_name_for_channel("greentic-pack-dev", ToolchainChannel::Development),
458            "greentic-pack-dev"
459        );
460    }
461
462    #[test]
463    fn rnd_channel_uses_rnd_binary_names() {
464        assert_eq!(
465            delegated_binary_name_for_channel("greentic-pack", ToolchainChannel::Rnd),
466            "greentic-pack-rnd"
467        );
468        assert_eq!(
469            delegated_binary_name_for_channel("greentic-runner-cli", ToolchainChannel::Rnd),
470            "greentic-runner-cli-rnd"
471        );
472        assert_eq!(
473            delegated_binary_name_for_channel("greentic-pack-rnd", ToolchainChannel::Rnd),
474            "greentic-pack-rnd"
475        );
476    }
477
478    #[test]
479    fn parse_installed_binstall_version_line() {
480        let parsed = parse_installed_cargo_binstall_version("cargo-binstall 1.15.7\n")
481            .expect("parse should succeed")
482            .expect("version should exist");
483        assert_eq!(parsed.to_string(), "1.15.7");
484    }
485
486    #[test]
487    fn parse_latest_binstall_version_line() {
488        let parsed = parse_latest_cargo_binstall_version(
489            "cargo-binstall = \"1.15.7\"    # Binary installation for rust projects\n",
490        )
491        .expect("parse should succeed");
492        assert_eq!(parsed.to_string(), "1.15.7");
493    }
494}