1use std::collections::BTreeMap;
5use std::fs;
6use std::path::{Component, Path, PathBuf};
7
8use runx_contracts::tools::{
9 JsonPayload, RuntimeCommand, ToolBuildStatus, ToolInput, ToolInspectImportedFrom,
10 ToolInspectOrigin, ToolInspectProvenance, ToolInspectReport, ToolInspectResult,
11 ToolInspectRunx,
12};
13
14use runx_contracts::sha256_hex;
15
16use super::error::ToolCatalogError;
17use super::search::{FixtureTool, fixture_catalog_allowed, fixture_tool};
18
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct ToolInspectOptions {
21 pub root: PathBuf,
22 pub tool_ref: String,
23 pub source: Option<String>,
24 pub search_from_directory: PathBuf,
25 pub tool_roots: Vec<PathBuf>,
26 pub fixture_catalog_enabled: bool,
27 pub allow_explicit_manifest_path: bool,
28}
29
30#[derive(Clone, Debug)]
31pub struct LocalToolResolution {
32 pub manifest_path: PathBuf,
33 pub tool: runx_parser::ValidatedTool,
34}
35
36pub fn inspect_tool(options: &ToolInspectOptions) -> Result<ToolInspectReport, ToolCatalogError> {
37 match resolve_local_manifest(options) {
38 Ok(manifest_path) => {
39 let tool = read_local_tool_manifest(&manifest_path)?;
40 return Ok(ToolInspectReport {
41 status: ToolBuildStatus::Success,
42 tool: inspect_local_tool(options, &manifest_path, tool)?,
43 });
44 }
45 Err(ToolCatalogError::NotFound(_)) => {}
46 Err(error) => return Err(error),
47 }
48
49 if let Some(tool) = resolve_fixture_tool(options) {
50 return Ok(ToolInspectReport {
51 status: ToolBuildStatus::Success,
52 tool: inspect_fixture_tool(&options.tool_ref, &tool, &options.root),
53 });
54 }
55
56 Err(ToolCatalogError::NotFound(format!(
57 "Tool '{}' was not found in configured tool roots.",
58 options.tool_ref
59 )))
60}
61
62pub fn resolve_local_tool(
63 options: &ToolInspectOptions,
64) -> Result<LocalToolResolution, ToolCatalogError> {
65 let manifest_path = resolve_local_manifest(options)?;
66 let tool = read_local_tool_manifest(&manifest_path)?;
67 Ok(LocalToolResolution {
68 manifest_path,
69 tool,
70 })
71}
72
73fn resolve_fixture_tool(options: &ToolInspectOptions) -> Option<FixtureTool> {
74 let normalized_source = options
75 .source
76 .as_deref()
77 .map(|source| source.trim().to_ascii_lowercase());
78
79 if !fixture_catalog_allowed(
80 options.fixture_catalog_enabled,
81 normalized_source.as_deref(),
82 ) {
83 return None;
84 }
85 fixture_tool(&options.tool_ref)
86}
87
88fn read_local_tool_manifest(
89 manifest_path: &Path,
90) -> Result<runx_parser::ValidatedTool, ToolCatalogError> {
91 let manifest_source = fs::read_to_string(manifest_path)
92 .map_err(|error| ToolCatalogError::io("reading tool manifest", manifest_path, error))?;
93 let raw = runx_parser::parse_tool_manifest_json(&manifest_source).map_err(|error| {
94 ToolCatalogError::InvalidManifest {
95 path: manifest_path.to_path_buf(),
96 message: error.to_string(),
97 }
98 })?;
99 runx_parser::validate_tool_manifest(raw).map_err(|error| ToolCatalogError::InvalidManifest {
100 path: manifest_path.to_path_buf(),
101 message: error.to_string(),
102 })
103}
104
105fn inspect_local_tool(
106 options: &ToolInspectOptions,
107 manifest_path: &Path,
108 tool: runx_parser::ValidatedTool,
109) -> Result<ToolInspectResult, ToolCatalogError> {
110 Ok(ToolInspectResult {
111 tool_ref: options.tool_ref.clone(),
112 name: tool.name,
113 description: tool.description,
114 execution_source_type: tool.source.source_type.as_str().to_owned(),
115 inputs: convert_inputs(tool.inputs)?,
116 scopes: tool.scopes,
117 mutating: tool.mutating,
118 runtime: convert_optional_runtime(tool.runtime)?,
119 risk: convert_optional_json(tool.risk)?,
120 runx: convert_optional_object(tool.runx)?,
121 reference_path: display_path(manifest_path),
122 skill_directory: manifest_path
123 .parent()
124 .map(display_path)
125 .unwrap_or_else(|| ".".to_owned()),
126 provenance: local_provenance(),
127 })
128}
129
130fn local_provenance() -> ToolInspectProvenance {
131 ToolInspectProvenance {
132 origin: ToolInspectOrigin::Local,
133 source: None,
134 source_label: None,
135 source_type: None,
136 namespace: None,
137 external_name: None,
138 catalog_ref: None,
139 tool_id: None,
140 tags: None,
141 }
142}
143
144fn inspect_fixture_tool(tool_ref: &str, tool: &FixtureTool, root: &Path) -> ToolInspectResult {
145 ToolInspectResult {
146 tool_ref: tool_ref.to_owned(),
147 name: tool.qualified_name(),
148 description: tool.description.map(str::to_owned),
149 execution_source_type: "catalog".to_owned(),
150 inputs: fixture_inputs(tool),
151 scopes: vec![tool.qualified_name()],
152 mutating: None,
153 runtime: None,
154 risk: None,
155 runx: Some(imported_runx(tool)),
156 reference_path: format!("catalog:{}:{}", tool.source, tool.qualified_name()),
157 skill_directory: display_path(root),
158 provenance: ToolInspectProvenance {
159 origin: ToolInspectOrigin::Imported,
160 source: Some(tool.source.to_owned()),
161 source_label: Some(tool.source_label.to_owned()),
162 source_type: Some(tool.source_type.to_owned()),
163 namespace: Some(tool.namespace.to_owned()),
164 external_name: Some(tool.external_name.to_owned()),
165 catalog_ref: Some(tool.catalog_ref()),
166 tool_id: Some(tool.tool_id()),
167 tags: Some(tool.tags.iter().map(|tag| (*tag).to_owned()).collect()),
168 },
169 }
170}
171
172fn fixture_inputs(tool: &FixtureTool) -> BTreeMap<String, ToolInput> {
173 tool.inputs
174 .iter()
175 .map(|input| {
176 (
177 input.name.to_owned(),
178 ToolInput {
179 input_type: input.input_type.to_owned(),
180 required: input.required,
181 description: input.description.map(str::to_owned),
182 default: None,
183 artifact: None,
184 },
185 )
186 })
187 .collect()
188}
189
190fn imported_runx(tool: &FixtureTool) -> ToolInspectRunx {
191 let digest_payload = format!(
192 r#"{{"source":"{}","namespace":"{}","external_name":"{}","source_type":"{}"}}"#,
193 tool.source, tool.namespace, tool.external_name, tool.source_type
194 );
195 ToolInspectRunx::Imported {
196 imported_from: ToolInspectImportedFrom {
197 source: tool.source.to_owned(),
198 source_label: tool.source_label.to_owned(),
199 source_type: tool.source_type.to_owned(),
200 namespace: tool.namespace.to_owned(),
201 external_name: tool.external_name.to_owned(),
202 digest: sha256_hex(digest_payload.as_bytes()),
203 },
204 }
205}
206
207fn resolve_local_manifest(options: &ToolInspectOptions) -> Result<PathBuf, ToolCatalogError> {
208 if options.allow_explicit_manifest_path
209 && let Some(path) =
210 explicit_manifest_path(&options.tool_ref, &options.search_from_directory)?
211 {
212 return Ok(path);
213 }
214
215 let segments = tool_ref_segments(&options.tool_ref)?;
216 for root in resolve_tool_roots(options) {
217 let manifest = root
218 .join(segments.iter().collect::<PathBuf>())
219 .join("manifest.json");
220 if manifest.exists() {
221 return Ok(manifest);
222 }
223 }
224
225 Err(ToolCatalogError::NotFound(format!(
226 "Tool '{}' was not found in configured tool roots.",
227 options.tool_ref
228 )))
229}
230
231fn explicit_manifest_path(
232 tool_ref: &str,
233 search_from_directory: &Path,
234) -> Result<Option<PathBuf>, ToolCatalogError> {
235 let candidate = Path::new(tool_ref);
236 if candidate.is_absolute()
237 || candidate
238 .components()
239 .any(|component| matches!(component, Component::ParentDir))
240 {
241 return Err(ToolCatalogError::InvalidRequest(
242 "Explicit tool manifest paths must be relative and must not contain '..'.".to_owned(),
243 ));
244 }
245 let resolved = search_from_directory.join(candidate);
246 if resolved.is_file() {
247 return Ok(Some(resolved));
248 }
249 let manifest = resolved.join("manifest.json");
250 if manifest.is_file() {
251 return Ok(Some(manifest));
252 }
253 Ok(None)
254}
255
256fn tool_ref_segments(tool_ref: &str) -> Result<Vec<&str>, ToolCatalogError> {
257 let segments = tool_ref
258 .split('.')
259 .filter(|segment| !segment.is_empty())
260 .collect::<Vec<_>>();
261 if segments.len() < 2 {
262 return Err(ToolCatalogError::InvalidRequest(format!(
263 "Tool '{tool_ref}' must include a namespace, for example fs.read."
264 )));
265 }
266 Ok(segments)
267}
268
269fn resolve_tool_roots(options: &ToolInspectOptions) -> Vec<PathBuf> {
270 let mut roots = Vec::new();
271 push_existing_dirs(&mut roots, options.tool_roots.iter().cloned());
272 push_existing_dirs(&mut roots, [options.root.join("tools")]);
273 let root = options.root.as_path();
274 let mut current = options.search_from_directory.clone();
275 if !current.starts_with(root) {
276 return roots;
277 }
278 loop {
279 push_existing_dirs(&mut roots, [current.join(".runx/tools")]);
280 if current == root {
281 break;
282 }
283 let Some(parent) = current.parent().map(Path::to_path_buf) else {
284 break;
285 };
286 if parent == current {
287 break;
288 }
289 current = parent;
290 }
291 roots
292}
293
294fn push_existing_dirs(roots: &mut Vec<PathBuf>, candidates: impl IntoIterator<Item = PathBuf>) {
295 for candidate in candidates {
296 if candidate.is_dir() && !roots.iter().any(|root| root == &candidate) {
297 roots.push(candidate);
298 }
299 }
300}
301
302fn convert_inputs(
303 inputs: BTreeMap<String, runx_parser::SkillInput>,
304) -> Result<BTreeMap<String, ToolInput>, ToolCatalogError> {
305 inputs
306 .into_iter()
307 .map(|(name, input)| {
308 Ok((
309 name,
310 ToolInput {
311 input_type: input.input_type,
312 required: input.required,
313 description: input.description,
314 default: convert_optional_json(input.default)?,
315 artifact: None,
316 },
317 ))
318 })
319 .collect()
320}
321
322fn convert_optional_object(
323 value: Option<runx_contracts::JsonObject>,
324) -> Result<Option<ToolInspectRunx>, ToolCatalogError> {
325 value
326 .map(|value| convert_json(runx_contracts::JsonValue::Object(value)))
327 .transpose()?
328 .map(|value| match value {
329 JsonPayload::Object(object) => Ok(ToolInspectRunx::Object(object)),
330 _ => Err(ToolCatalogError::InvalidRequest(
331 "expected JSON object while converting tool metadata".to_owned(),
332 )),
333 })
334 .transpose()
335}
336
337fn convert_optional_runtime(
338 value: Option<runx_contracts::JsonValue>,
339) -> Result<Option<RuntimeCommand>, ToolCatalogError> {
340 value
341 .map(|value| {
342 let json = serde_json::to_string(&value)
343 .map_err(|error| ToolCatalogError::InvalidRequest(error.to_string()))?;
344 serde_json::from_str(&json)
345 .map_err(|error| ToolCatalogError::InvalidRequest(error.to_string()))
346 })
347 .transpose()
348}
349
350fn convert_optional_json(
351 value: Option<runx_contracts::JsonValue>,
352) -> Result<Option<JsonPayload>, ToolCatalogError> {
353 value.map(convert_json).transpose()
354}
355
356fn convert_json(value: runx_contracts::JsonValue) -> Result<JsonPayload, ToolCatalogError> {
357 let json = serde_json::to_string(&value)
358 .map_err(|error| ToolCatalogError::InvalidRequest(error.to_string()))?;
359 serde_json::from_str(&json).map_err(|error| ToolCatalogError::InvalidRequest(error.to_string()))
360}
361
362fn display_path(path: &Path) -> String {
363 path.to_string_lossy().replace('\\', "/")
364}