Skip to main content

xianyun_cli/commands/
tool.rs

1//! `caproni tool`: run an arbitrary executable on PATH with access to the
2//! cluster.
3//!
4//! Port of Go's `cmd/tool` package. Flag parsing is skipped: the first
5//! trailing argument names the tool, the rest are forwarded verbatim.
6
7use anyhow::Context as _;
8use clap::Args;
9use thiserror::Error;
10use xianyun_cluster::clustertool::{Runner, Tool};
11use xianyun_core::config::caproni::load_from_context;
12use xianyun_core::config::global::Context;
13
14#[derive(Args, Debug)]
15pub struct ToolArgs {
16    /// The tool to run, followed by its arguments.
17    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
18    pub args: Vec<String>,
19}
20
21/// Ported from `tool.ErrNoToolSpecified`.
22#[derive(Debug, Error, PartialEq, Eq)]
23#[error("please specify a tool to run")]
24pub struct NoToolSpecifiedError;
25
26/// Ported from `tool.knownTools`.
27fn known_tools() -> Vec<Tool> {
28    vec![
29        Tool {
30            name: "k9s".to_string(),
31            install_hint: "install it with `mise use -g k9s`, or see https://k9scli.io".to_string(),
32            require_terminal: true,
33        },
34        Tool {
35            name: "kubectl".to_string(),
36            install_hint: "install it with `mise use -g kubectl`, or see https://kubernetes.io"
37                .to_string(),
38            require_terminal: false,
39        },
40        Tool {
41            name: "helm".to_string(),
42            install_hint: "install it with `mise use -g helm`, or see https://helm.sh".to_string(),
43            require_terminal: false,
44        },
45        Tool {
46            name: "stern".to_string(),
47            install_hint:
48                "install it with `mise use -g stern`, or see https://github.com/stern/stern"
49                    .to_string(),
50            require_terminal: false,
51        },
52    ]
53}
54
55/// Ported from `tool.findTool`: returns the known tool matching `name`, or
56/// a bare `Tool` with no install hint when `name` isn't one of the
57/// well-known tools (an unknown name is still runnable — it just gets no
58/// install hint if it's missing from PATH).
59fn find_tool(name: &str) -> Tool {
60    known_tools()
61        .into_iter()
62        .find(|t| t.name == name)
63        .unwrap_or(Tool {
64            name: name.to_string(),
65            install_hint: String::new(),
66            require_terminal: false,
67        })
68}
69
70pub fn run(args: &ToolArgs, ctx: &Context) -> anyhow::Result<()> {
71    run_tool(ctx, &args.args)
72}
73
74/// Ported from `tool.CreateCommand`'s action plus `tool.runTool`.
75pub fn run_tool(ctx: &Context, args: &[String]) -> anyhow::Result<()> {
76    let Some((name, rest)) = args.split_first() else {
77        return Err(NoToolSpecifiedError.into());
78    };
79
80    crate::drivers::ensure_registered();
81
82    let container = load_from_context(ctx).context("loading configuration")?;
83
84    let manager = xianyun_cluster::registry::new_manager(
85        container.caproni.cluster.clone(),
86        container.global.profile_name.clone(),
87    )
88    .context("creating cluster manager")?;
89
90    let tool = find_tool(name);
91
92    let code = Runner::new()
93        .run(
94            manager.as_ref(),
95            &container.global.profile_name,
96            &tool,
97            rest,
98        )
99        .with_context(|| format!("running {name}"))?;
100
101    if code != 0 {
102        // The tool has already reported whatever went wrong; reproduce its
103        // exit status without printing a second error line.
104        return Err(crate::exitcode::CliExit::new(code).into());
105    }
106
107    Ok(())
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    /// There's no `cmd/tool_test.go` in Go to port (the package has no
115    /// test file at all), so this is new coverage for the Rust port.
116    #[test]
117    fn run_tool_requires_a_tool_name() {
118        let err = run_tool(&Context::default(), &[]).unwrap_err();
119        assert!(err.downcast_ref::<NoToolSpecifiedError>().is_some());
120    }
121
122    #[test]
123    fn find_tool_returns_known_tool_metadata() {
124        let tool = find_tool("k9s");
125        assert_eq!(tool.name, "k9s");
126        assert!(tool.require_terminal);
127        assert!(tool.install_hint.contains("k9scli.io"));
128
129        let tool = find_tool("kubectl");
130        assert!(!tool.require_terminal);
131        assert!(tool.install_hint.contains("kubernetes.io"));
132
133        let tool = find_tool("helm");
134        assert!(tool.install_hint.contains("helm.sh"));
135
136        let tool = find_tool("stern");
137        assert!(tool.install_hint.contains("stern"));
138    }
139
140    #[test]
141    fn find_tool_falls_back_to_a_bare_tool_for_unknown_names() {
142        let tool = find_tool("some-custom-script");
143        assert_eq!(tool.name, "some-custom-script");
144        assert!(tool.install_hint.is_empty());
145        assert!(!tool.require_terminal);
146    }
147}