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