1use 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}