Skip to main content

runx_runtime/tool_catalogs/
inspect.rs

1// rust-style-allow: large-file - tool inspection keeps local manifest
2// resolution, fixture fallback, provenance, and JSON projection in one
3// read-only diagnostic surface.
4use 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}