Skip to main content

runx_cli/
tool.rs

1// rust-style-allow: large-file - command wiring keeps tool build/search/inspect output parity together.
2use std::collections::BTreeMap;
3use std::env;
4use std::path::{Path, PathBuf};
5use std::process::ExitCode;
6
7use runx_runtime::{
8    ToolBuildOptions, ToolCatalogError, ToolInspectOptions, ToolSearchOptions, build_tool_catalogs,
9    inspect_tool, search_tools,
10};
11
12use crate::router::{ToolAction, ToolPlan};
13
14pub fn run_native_tool(plan: ToolPlan) -> ExitCode {
15    match run_tool(plan) {
16        Ok(output) => crate::cli_io::write_stdout_code(&output.stdout, output.exit_code),
17        Err(error) => {
18            let _ignored = crate::cli_io::write_stderr_code(&render_cli_error(&error.to_string()));
19            ExitCode::from(error.exit_code())
20        }
21    }
22}
23
24struct ToolCliOutput {
25    stdout: String,
26    exit_code: u8,
27}
28
29fn run_tool(plan: ToolPlan) -> Result<ToolCliOutput, ToolCliError> {
30    let env = env_pairs();
31    let cwd = env::current_dir().map_err(|error| ToolCliError::Internal(error.to_string()))?;
32    match plan.action {
33        ToolAction::Build => run_build(plan, &env, &cwd),
34        ToolAction::Search => run_search(plan, &env),
35        ToolAction::Inspect => run_inspect(plan, &env, &cwd),
36    }
37}
38
39fn run_build(
40    plan: ToolPlan,
41    env: &BTreeMap<String, String>,
42    cwd: &Path,
43) -> Result<ToolCliOutput, ToolCliError> {
44    let root = runx_runtime::resolve_runx_workspace_base(env, cwd);
45    let tool_path = plan
46        .path
47        .as_deref()
48        .map(|path| resolve_user_path(path, env, cwd));
49    let toolkit_version = toolkit_version(env);
50    let report = build_tool_catalogs(&ToolBuildOptions {
51        root,
52        tool_path,
53        all: plan.all,
54        toolkit_version,
55    })?;
56    let stdout = if plan.json {
57        json_line(&report)?
58    } else {
59        render_build_report(report.built.len(), &report.errors)
60    };
61    let exit_code = if report.status == runx_contracts::tools::ToolBuildStatus::Success {
62        0
63    } else {
64        1
65    };
66    Ok(ToolCliOutput { stdout, exit_code })
67}
68
69fn run_search(
70    plan: ToolPlan,
71    env: &BTreeMap<String, String>,
72) -> Result<ToolCliOutput, ToolCliError> {
73    let query = plan
74        .ref_or_query
75        .ok_or_else(|| ToolCliError::Usage("runx tool search requires a query".to_owned()))?;
76    let report = search_tools(&ToolSearchOptions {
77        query,
78        source: plan.source,
79        limit: 20,
80        fixture_catalog_enabled: env_value(env, "RUNX_ENABLE_FIXTURE_TOOL_CATALOG")
81            .is_some_and(|value| value == "1"),
82    });
83    let stdout = if plan.json {
84        json_line(&report)?
85    } else {
86        render_search_results(&report.results)
87    };
88    Ok(ToolCliOutput {
89        stdout,
90        exit_code: 0,
91    })
92}
93
94fn run_inspect(
95    plan: ToolPlan,
96    env: &BTreeMap<String, String>,
97    cwd: &Path,
98) -> Result<ToolCliOutput, ToolCliError> {
99    let tool_ref = plan.ref_or_query.ok_or_else(|| {
100        ToolCliError::Usage("runx tool inspect requires a tool reference".to_owned())
101    })?;
102    let root = runx_runtime::resolve_runx_workspace_base(env, cwd);
103    let search_from_directory = resolve_user_path(Path::new("."), env, cwd);
104    let tool_roots = env_value(env, "RUNX_TOOL_ROOTS")
105        .map(|value| split_env_paths(&value))
106        .unwrap_or_default();
107    let report = inspect_tool(&ToolInspectOptions {
108        root,
109        tool_ref,
110        source: plan.source,
111        search_from_directory,
112        tool_roots,
113        fixture_catalog_enabled: env_value(env, "RUNX_ENABLE_FIXTURE_TOOL_CATALOG")
114            .is_some_and(|value| value == "1"),
115        allow_explicit_manifest_path: true,
116    })?;
117    let stdout = if plan.json {
118        json_line(&report)?
119    } else {
120        render_inspect_result(&report.tool)
121    };
122    Ok(ToolCliOutput {
123        stdout,
124        exit_code: 0,
125    })
126}
127
128fn env_pairs() -> BTreeMap<String, String> {
129    env::vars().collect()
130}
131
132fn env_value(env: &BTreeMap<String, String>, key: &str) -> Option<String> {
133    env.get(key).cloned()
134}
135
136fn resolve_user_path(user_path: &Path, env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
137    runx_runtime::resolve_path_from_user_input(&user_path.to_string_lossy(), env, cwd, true)
138}
139
140fn split_env_paths(value: &str) -> Vec<PathBuf> {
141    env::split_paths(value).collect()
142}
143
144fn toolkit_version(env: &BTreeMap<String, String>) -> String {
145    env_value(env, "RUNX_AUTHORING_TOOLKIT_VERSION")
146        .or_else(|| env_value(env, "RUNX_AUTHORING_PACKAGE_VERSION"))
147        .map(|value| value.trim_start_matches('^').to_owned())
148        .filter(|value| !value.is_empty())
149        .unwrap_or_else(|| "0.1.4".to_owned())
150}
151
152fn json_line<T: serde::Serialize>(value: &T) -> Result<String, ToolCliError> {
153    serde_json::to_string_pretty(value)
154        .map(|json| format!("{json}\n"))
155        .map_err(|error| ToolCliError::Internal(error.to_string()))
156}
157
158fn render_build_report(count: usize, errors: &[String]) -> String {
159    let mut lines = vec!["".to_owned(), format!("  tool build  {count} tool(s)")];
160    for error in errors {
161        lines.push(format!("  {error}"));
162    }
163    lines.push(String::new());
164    lines.join("\n")
165}
166
167fn render_search_results(results: &[runx_contracts::tools::ToolCatalogSearchResult]) -> String {
168    if results.is_empty() {
169        return "\n  No imported tools found.\n\n".to_owned();
170    }
171    let mut lines = vec!["".to_owned(), "  Imported Tools".to_owned()];
172    for result in results {
173        lines.push(format!("  {}  {}", result.name, result.source_label));
174        lines.push(format!("  type      {}", result.source_type));
175        lines.push(format!("  namespace {}", result.namespace));
176        lines.push(format!("  external  {}", result.external_name));
177        lines.push(format!("  catalog   {}", result.catalog_ref));
178        if !result.required_scopes.is_empty() {
179            lines.push(format!("  scopes    {}", result.required_scopes.join(", ")));
180        }
181        if let Some(summary) = &result.summary {
182            lines.push(format!("  summary   {summary}"));
183        }
184        lines.push(String::new());
185    }
186    format!("{}\n", lines.join("\n"))
187}
188
189fn render_inspect_result(result: &runx_contracts::tools::ToolInspectResult) -> String {
190    let mut lines = inspect_header_lines(result);
191    if matches!(
192        result.provenance.origin,
193        runx_contracts::tools::ToolInspectOrigin::Imported
194    ) {
195        lines.extend(imported_tool_lines(result));
196    }
197    if !result.scopes.is_empty() {
198        lines.push(format!("  scopes    {}", result.scopes.join(", ")));
199    }
200    if let Some(description) = &result.description {
201        lines.push(format!("  summary   {description}"));
202    }
203    if !result.inputs.is_empty() {
204        lines.push("  inputs".to_owned());
205        lines.extend(input_lines(result));
206    }
207    lines.push(String::new());
208    format!("{}\n", lines.join("\n"))
209}
210
211fn inspect_header_lines(result: &runx_contracts::tools::ToolInspectResult) -> Vec<String> {
212    let origin = match result.provenance.origin {
213        runx_contracts::tools::ToolInspectOrigin::Local => "local",
214        runx_contracts::tools::ToolInspectOrigin::Imported => "imported",
215    };
216    vec![
217        String::new(),
218        format!("  {}  {origin}", result.name),
219        format!("  exec      {}", result.execution_source_type),
220        format!("  path      {}", result.reference_path),
221        format!("  root      {}", result.skill_directory),
222    ]
223}
224
225fn imported_tool_lines(result: &runx_contracts::tools::ToolInspectResult) -> Vec<String> {
226    vec![
227        format!(
228            "  catalog   {}",
229            result
230                .provenance
231                .catalog_ref
232                .as_deref()
233                .unwrap_or("unknown")
234        ),
235        format!("  source    {}", inspect_source_label(result)),
236        format!(
237            "  kind      {}",
238            result
239                .provenance
240                .source_type
241                .as_deref()
242                .unwrap_or("unknown")
243        ),
244        format!(
245            "  external  {}",
246            result
247                .provenance
248                .external_name
249                .as_deref()
250                .unwrap_or("unknown")
251        ),
252    ]
253}
254
255fn inspect_source_label(result: &runx_contracts::tools::ToolInspectResult) -> &str {
256    result
257        .provenance
258        .source_label
259        .as_deref()
260        .or(result.provenance.source.as_deref())
261        .unwrap_or("unknown")
262}
263
264fn input_lines(result: &runx_contracts::tools::ToolInspectResult) -> Vec<String> {
265    result
266        .inputs
267        .iter()
268        .map(|(name, input)| {
269            let required = if input.required {
270                "required"
271            } else {
272                "optional"
273            };
274            let description = input
275                .description
276                .as_ref()
277                .map(|value| format!(" · {value}"))
278                .unwrap_or_default();
279            format!("    {name}: {} · {required}{description}", input.input_type)
280        })
281        .collect()
282}
283
284fn render_cli_error(message: &str) -> String {
285    format!("\n  ✗  {message}\n")
286}
287
288#[derive(Debug)]
289enum ToolCliError {
290    Usage(String),
291    Runtime(ToolCatalogError),
292    Internal(String),
293}
294
295impl ToolCliError {
296    fn exit_code(&self) -> u8 {
297        match self {
298            Self::Usage(_) => 2,
299            Self::Runtime(_) | Self::Internal(_) => 1,
300        }
301    }
302}
303
304impl std::fmt::Display for ToolCliError {
305    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306        match self {
307            Self::Usage(message) | Self::Internal(message) => formatter.write_str(message),
308            Self::Runtime(error) => write!(formatter, "{error}"),
309        }
310    }
311}
312
313impl From<ToolCatalogError> for ToolCliError {
314    fn from(value: ToolCatalogError) -> Self {
315        Self::Runtime(value)
316    }
317}