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