soroban_cli/commands/
plugin.rs

1use std::{path::PathBuf, process::Command};
2
3use clap::CommandFactory;
4use which::which;
5
6use crate::{utils, Root};
7
8#[derive(thiserror::Error, Debug)]
9pub enum Error {
10    #[error("Plugin not provided. Should be `stellar plugin` for a binary `stellar-plugin`")]
11    MissingSubcommand,
12    #[error(transparent)]
13    IO(#[from] std::io::Error),
14    #[error(
15        r#"error: no such command: `{0}`
16        
17        {1}View all installed plugins with `stellar --list`"#
18    )]
19    ExecutableNotFound(String, String),
20    #[error(transparent)]
21    Which(#[from] which::Error),
22    #[error(transparent)]
23    Regex(#[from] regex::Error),
24}
25
26const SUBCOMMAND_TOLERANCE: f64 = 0.75;
27const PLUGIN_TOLERANCE: f64 = 0.75;
28const MIN_LENGTH: usize = 4;
29
30/// Tries to run a plugin, if the plugin's name is similar enough to any of the current subcommands return Ok.
31/// Otherwise only errors can be returned because this process will exit with the plugin.
32pub fn run() -> Result<(), Error> {
33    let (name, args) = {
34        let mut args = std::env::args().skip(1);
35        let name = args.next().ok_or(Error::MissingSubcommand)?;
36        (name, args)
37    };
38
39    if Root::command().get_subcommands().any(|c| {
40        let sc_name = c.get_name();
41        sc_name.starts_with(&name)
42            || (name.len() >= MIN_LENGTH && strsim::jaro(sc_name, &name) >= SUBCOMMAND_TOLERANCE)
43    }) {
44        return Ok(());
45    }
46
47    let bin = find_bin(&name).map_err(|_| {
48        let suggestion = if let Ok(bins) = list() {
49            let suggested_name = bins
50                .iter()
51                .map(|b| (b, strsim::jaro_winkler(&name, b)))
52                .filter(|(_, i)| *i > PLUGIN_TOLERANCE)
53                .min_by(|a, b| a.1.total_cmp(&b.1))
54                .map(|(a, _)| a.to_string())
55                .unwrap_or_default();
56
57            if suggested_name.is_empty() {
58                suggested_name
59            } else {
60                format!(
61                    r#"Did you mean `{suggested_name}`?
62        "#
63                )
64            }
65        } else {
66            String::new()
67        };
68
69        Error::ExecutableNotFound(name, suggestion)
70    })?;
71
72    std::process::exit(
73        Command::new(bin)
74            .args(args)
75            .spawn()?
76            .wait()?
77            .code()
78            .unwrap(),
79    );
80}
81
82const MAX_HEX_LENGTH: usize = 10;
83
84fn find_bin(name: &str) -> Result<PathBuf, which::Error> {
85    if let Ok(path) = which(format!("stellar-{name}")) {
86        Ok(path)
87    } else {
88        which(format!("soroban-{name}"))
89    }
90}
91
92pub fn list() -> Result<Vec<String>, Error> {
93    let re_str = if cfg!(target_os = "windows") {
94        r"^(soroban|stellar)-.*.exe$"
95    } else {
96        r"^(soroban|stellar)-.*"
97    };
98
99    let re = regex::Regex::new(re_str)?;
100
101    Ok(which::which_re(re)?
102        .filter_map(|b| {
103            let s = b.file_name()?.to_str()?;
104            Some(s.strip_suffix(".exe").unwrap_or(s).to_string())
105        })
106        .filter(|s| !(utils::is_hex_string(s) && s.len() > MAX_HEX_LENGTH))
107        .map(|s| s.replace("soroban-", "").replace("stellar-", ""))
108        .collect())
109}