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
65pub 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}