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}
15
16impl ToolchainChannel {
17    pub fn from_executable_name(name: &str) -> Self {
18        let stem = name.strip_suffix(".exe").unwrap_or(name);
19        if stem == "greentic-dev-dev" {
20            Self::Development
21        } else {
22            Self::Stable
23        }
24    }
25}
26
27pub fn current_toolchain_channel() -> ToolchainChannel {
28    let executable_name = env::args_os()
29        .next()
30        .and_then(|arg| PathBuf::from(arg).file_name().map(|name| name.to_owned()))
31        .or_else(|| {
32            env::current_exe()
33                .ok()
34                .and_then(|path| path.file_name().map(|name| name.to_owned()))
35        });
36    executable_name
37        .as_deref()
38        .and_then(|name| name.to_str())
39        .map(ToolchainChannel::from_executable_name)
40        .unwrap_or(ToolchainChannel::Stable)
41}
42
43pub fn delegated_binary_name(name: &str) -> String {
44    delegated_binary_name_for_channel(name, current_toolchain_channel())
45}
46
47pub fn delegated_binary_name_for_channel(name: &str, channel: ToolchainChannel) -> String {
48    match channel {
49        ToolchainChannel::Stable => name.to_string(),
50        ToolchainChannel::Development => development_binary_name(name),
51    }
52}
53
54fn development_binary_name(name: &str) -> String {
55    if name == "greentic-dev" {
56        return "greentic-dev-dev".to_string();
57    }
58    if name.ends_with("-dev") {
59        name.to_string()
60    } else {
61        format!("{name}-dev")
62    }
63}
64
65/// Resolve a binary by name using env override, then PATH.
66pub fn resolve_binary(name: &str) -> Result<PathBuf> {
67    resolve_binary_for_channel(name, current_toolchain_channel())
68}
69
70pub fn resolve_binary_for_channel(name: &str, channel: ToolchainChannel) -> Result<PathBuf> {
71    let locale = crate::i18n::select_locale(None);
72    let resolved_name = delegated_binary_name_for_channel(name, channel);
73    let env_key = format!(
74        "GREENTIC_DEV_BIN_{}",
75        resolved_name.replace('-', "_").to_uppercase()
76    );
77    if let Ok(path) = env::var(&env_key) {
78        let pb = PathBuf::from(path);
79        if pb.exists() {
80            return Ok(pb);
81        }
82        bail!(
83            "{}",
84            crate::i18n::tf(
85                &locale,
86                "runtime.passthrough.error.env_binary_missing",
87                &[
88                    ("env_key", env_key.clone()),
89                    ("path", pb.display().to_string()),
90                ],
91            )
92        );
93    }
94
95    if let Ok(path) = which::which(&resolved_name) {
96        return Ok(path);
97    }
98
99    bail!(
100        "{}",
101        crate::i18n::tf(
102            &locale,
103            "runtime.passthrough.error.binary_not_found",
104            &[("name", resolved_name), ("env_key", env_key)],
105        )
106    )
107}
108
109pub fn run_passthrough(bin: &Path, args: &[OsString], verbose: bool) -> Result<ExitStatus> {
110    let locale = crate::i18n::select_locale(None);
111    if verbose {
112        eprintln!(
113            "{}",
114            crate::i18n::tf(
115                &locale,
116                "runtime.passthrough.debug.exec",
117                &[
118                    ("bin", bin.display().to_string()),
119                    ("args", format!("{args:?}")),
120                ],
121            )
122        );
123        let _ = Command::new(bin)
124            .arg("--version")
125            .stdout(Stdio::inherit())
126            .stderr(Stdio::inherit())
127            .status();
128    }
129
130    Command::new(bin)
131        .args(args)
132        .stdin(Stdio::inherit())
133        .stdout(Stdio::inherit())
134        .stderr(Stdio::inherit())
135        .status()
136        .map_err(|e| {
137            anyhow!(crate::i18n::tf(
138                &locale,
139                "runtime.passthrough.error.execute",
140                &[("bin", bin.display().to_string()), ("error", e.to_string())],
141            ))
142        })
143}
144
145pub fn install_all_delegated_tools(latest: bool, locale: &str) -> Result<()> {
146    ensure_cargo_binstall()?;
147    let channel = current_toolchain_channel();
148    for package in GREENTIC_TOOLCHAIN_PACKAGES {
149        for bin_name in package.bins {
150            install_with_binstall(
151                package.crate_name,
152                &delegated_binary_name_for_channel(bin_name, channel),
153                latest,
154                locale,
155            )?;
156        }
157    }
158    Ok(())
159}
160
161fn install_with_binstall(
162    crate_name: &str,
163    bin_name: &str,
164    force_latest: bool,
165    locale: &str,
166) -> Result<()> {
167    eprintln!(
168        "{}",
169        crate::i18n::tf(
170            locale,
171            "runtime.tools.install.installing",
172            &[
173                ("bin_name", bin_name.to_string()),
174                ("crate_name", crate_name.to_string()),
175            ],
176        )
177    );
178
179    let mut cmd = Command::new("cargo");
180    cmd.args(binstall_args(crate_name, bin_name, force_latest));
181
182    let status = cmd
183        .stdin(Stdio::inherit())
184        .stdout(Stdio::inherit())
185        .stderr(Stdio::inherit())
186        .status()
187        .with_context(|| crate::i18n::t(locale, "runtime.tools.install.error.execute_binstall"))?;
188
189    if status.success() {
190        Ok(())
191    } else {
192        bail!(
193            "{}",
194            crate::i18n::tf(
195                locale,
196                "runtime.tools.install.error.binstall_failed",
197                &[
198                    ("bin_name", bin_name.to_string()),
199                    ("crate_name", crate_name.to_string()),
200                    ("exit_code", format!("{:?}", status.code())),
201                ],
202            )
203        );
204    }
205}
206
207fn binstall_args(crate_name: &str, bin_name: &str, force_latest: bool) -> Vec<String> {
208    let mut args = vec![
209        "binstall".to_string(),
210        "-y".to_string(),
211        "--locked".to_string(),
212        crate_name.to_string(),
213        "--bin".to_string(),
214        bin_name.to_string(),
215    ];
216    if force_latest {
217        args.push("--force".to_string());
218    }
219    args
220}
221
222fn ensure_cargo_binstall() -> Result<()> {
223    let locale = crate::i18n::select_locale(None);
224    let installed_version = installed_cargo_binstall_version()?;
225    if installed_version.is_none() {
226        eprintln!(
227            "{}",
228            crate::i18n::t(&locale, "runtime.tools.install.installing_binstall")
229        );
230        return install_cargo_binstall();
231    }
232
233    let installed_version = installed_version.expect("checked is_some above");
234    match latest_cargo_binstall_version() {
235        Ok(latest_version) => {
236            if installed_version >= latest_version {
237                return Ok(());
238            }
239
240            eprintln!(
241                "{}",
242                crate::i18n::tf(
243                    &locale,
244                    "runtime.tools.install.updating_binstall",
245                    &[
246                        ("installed_version", installed_version.to_string()),
247                        ("latest_version", latest_version.to_string()),
248                    ],
249                )
250            );
251            install_cargo_binstall()
252        }
253        Err(err) => {
254            eprintln!(
255                "{}",
256                crate::i18n::tf(
257                    &locale,
258                    "runtime.tools.install.warn.latest_check_failed",
259                    &[
260                        ("error", err.to_string()),
261                        ("installed_version", installed_version.to_string()),
262                    ],
263                )
264            );
265            Ok(())
266        }
267    }
268}
269
270fn install_cargo_binstall() -> Result<()> {
271    let status = Command::new("cargo")
272        .arg("install")
273        .arg("cargo-binstall")
274        .arg("--locked")
275        .stdin(Stdio::inherit())
276        .stdout(Stdio::inherit())
277        .stderr(Stdio::inherit())
278        .status()
279        .with_context(|| {
280            crate::i18n::t(
281                &crate::i18n::select_locale(None),
282                "runtime.tools.install.error.execute_install_binstall",
283            )
284        })?;
285
286    if status.success() {
287        Ok(())
288    } else {
289        let locale = crate::i18n::select_locale(None);
290        bail!(
291            "{}",
292            crate::i18n::tf(
293                &locale,
294                "runtime.tools.install.error.install_binstall_failed",
295                &[("exit_code", format!("{:?}", status.code()))],
296            )
297        );
298    }
299}
300
301fn installed_cargo_binstall_version() -> Result<Option<Version>> {
302    let output = Command::new("cargo")
303        .arg("binstall")
304        .arg("-V")
305        .stdin(Stdio::null())
306        .stderr(Stdio::null())
307        .output();
308    let output = match output {
309        Ok(output) => output,
310        Err(_) => return Ok(None),
311    };
312    if !output.status.success() {
313        return Ok(None);
314    }
315
316    let stdout =
317        String::from_utf8(output.stdout).context("`cargo binstall -V` returned non-UTF8 output")?;
318    parse_installed_cargo_binstall_version(&stdout)
319}
320
321fn latest_cargo_binstall_version() -> Result<Version> {
322    let output = Command::new("cargo")
323        .arg("search")
324        .arg("cargo-binstall")
325        .arg("--limit")
326        .arg("1")
327        .stdin(Stdio::null())
328        .stderr(Stdio::null())
329        .output()
330        .with_context(|| "failed to execute `cargo search cargo-binstall --limit 1`")?;
331    if !output.status.success() {
332        bail!(
333            "`cargo search cargo-binstall --limit 1` failed with exit code {:?}",
334            output.status.code()
335        );
336    }
337
338    let stdout = String::from_utf8(output.stdout)
339        .context("`cargo search cargo-binstall --limit 1` returned non-UTF8 output")?;
340    parse_latest_cargo_binstall_version(&stdout)
341}
342
343fn parse_installed_cargo_binstall_version(stdout: &str) -> Result<Option<Version>> {
344    let line = stdout.lines().next().unwrap_or_default();
345    let maybe_version = line
346        .split_whitespace()
347        .find_map(|token| Version::parse(token.trim_start_matches('v')).ok());
348    Ok(maybe_version)
349}
350
351fn parse_latest_cargo_binstall_version(stdout: &str) -> Result<Version> {
352    let first_line = stdout
353        .lines()
354        .find(|line| !line.trim().is_empty())
355        .ok_or_else(|| anyhow!("`cargo search cargo-binstall --limit 1` returned no results"))?;
356    let (_, rhs) = first_line
357        .split_once('=')
358        .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
359    let quoted = rhs
360        .split('#')
361        .next()
362        .map(str::trim)
363        .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
364    let version_text = quoted.trim_matches('"');
365    Version::parse(version_text)
366        .with_context(|| format!("failed to parse cargo-binstall version from `{first_line}`"))
367}
368
369#[cfg(test)]
370mod tests {
371    use super::{
372        ToolchainChannel, binstall_args, delegated_binary_name_for_channel,
373        parse_installed_cargo_binstall_version, parse_latest_cargo_binstall_version,
374    };
375    use crate::toolchain_catalogue::GREENTIC_TOOLCHAIN_PACKAGES;
376
377    #[test]
378    fn delegated_install_catalogue_includes_runner() {
379        let found = GREENTIC_TOOLCHAIN_PACKAGES.iter().any(|package| {
380            package.crate_name == "greentic-runner" && package.bins.contains(&"greentic-runner")
381        });
382        assert!(found);
383    }
384
385    #[test]
386    fn binstall_args_include_force_only_when_latest_requested() {
387        assert_eq!(
388            binstall_args("greentic-runner", "greentic-runner", false),
389            vec![
390                "binstall",
391                "-y",
392                "--locked",
393                "greentic-runner",
394                "--bin",
395                "greentic-runner"
396            ]
397        );
398        assert_eq!(
399            binstall_args("greentic-runner", "greentic-runner", true),
400            vec![
401                "binstall",
402                "-y",
403                "--locked",
404                "greentic-runner",
405                "--bin",
406                "greentic-runner",
407                "--force"
408            ]
409        );
410    }
411
412    #[test]
413    fn executable_name_selects_toolchain_channel() {
414        assert_eq!(
415            ToolchainChannel::from_executable_name("greentic-dev"),
416            ToolchainChannel::Stable
417        );
418        assert_eq!(
419            ToolchainChannel::from_executable_name("greentic-dev-dev"),
420            ToolchainChannel::Development
421        );
422        assert_eq!(
423            ToolchainChannel::from_executable_name("greentic-dev-dev.exe"),
424            ToolchainChannel::Development
425        );
426    }
427
428    #[test]
429    fn development_channel_uses_dev_binary_names() {
430        assert_eq!(
431            delegated_binary_name_for_channel("greentic-pack", ToolchainChannel::Development),
432            "greentic-pack-dev"
433        );
434        assert_eq!(
435            delegated_binary_name_for_channel("greentic-runner-cli", ToolchainChannel::Development),
436            "greentic-runner-cli-dev"
437        );
438        assert_eq!(
439            delegated_binary_name_for_channel("greentic-pack-dev", ToolchainChannel::Development),
440            "greentic-pack-dev"
441        );
442    }
443
444    #[test]
445    fn parse_installed_binstall_version_line() {
446        let parsed = parse_installed_cargo_binstall_version("cargo-binstall 1.15.7\n")
447            .expect("parse should succeed")
448            .expect("version should exist");
449        assert_eq!(parsed.to_string(), "1.15.7");
450    }
451
452    #[test]
453    fn parse_latest_binstall_version_line() {
454        let parsed = parse_latest_cargo_binstall_version(
455            "cargo-binstall = \"1.15.7\"    # Binary installation for rust projects\n",
456        )
457        .expect("parse should succeed");
458        assert_eq!(parsed.to_string(), "1.15.7");
459    }
460}