Skip to main content

runx_runtime/tool_catalogs/
build.rs

1// rust-style-allow: large-file because tool-manifest build keeps source/schema
2// hashing, raw payload normalization, output binding shape, and stable JSON
3// emission together so the TS doctor and the rust runtime agree byte-for-byte.
4use std::collections::{BTreeMap, BTreeSet};
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use runx_contracts::sha256_prefixed;
9use runx_contracts::tools::{
10    BuiltToolItem, JsonPayload, JsonPayloadObject, RuntimeCommand, ToolBuildReport,
11    ToolBuildReportSchema, ToolBuildStatus, ToolManifest, ToolManifestSchema, ToolOutput,
12};
13use serde::Deserialize;
14
15use super::error::ToolCatalogError;
16use super::hash::sha256_stable;
17
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct ToolBuildOptions {
20    pub root: PathBuf,
21    pub tool_path: Option<PathBuf>,
22    pub all: bool,
23    pub toolkit_version: String,
24}
25
26#[derive(Deserialize)]
27struct RawToolManifest {
28    #[serde(default)]
29    name: String,
30    version: Option<String>,
31    description: Option<String>,
32    source: runx_contracts::tools::ToolSource,
33    #[serde(default)]
34    inputs: BTreeMap<String, runx_contracts::tools::ToolInput>,
35    output: Option<ToolOutput>,
36    #[serde(default)]
37    scopes: Vec<String>,
38    risk: Option<JsonPayload>,
39    runtime: Option<RuntimeCommand>,
40    retry: Option<runx_contracts::tools::ToolRetryPolicy>,
41    idempotency: Option<runx_contracts::tools::ToolIdempotencyPolicy>,
42    mutating: Option<bool>,
43    runx: Option<JsonPayloadObject>,
44}
45
46pub fn build_tool_catalogs(
47    options: &ToolBuildOptions,
48) -> Result<ToolBuildReport, ToolCatalogError> {
49    let tool_dirs = if options.all {
50        discover_tool_directories(&options.root)?
51    } else {
52        vec![resolve_tool_path(
53            &options.root,
54            options.tool_path.as_deref(),
55        )?]
56    };
57    let mut built = Vec::new();
58    let mut errors = Vec::new();
59    for tool_dir in tool_dirs {
60        match build_tool_manifest(&options.root, &tool_dir, &options.toolkit_version) {
61            Ok(item) => built.push(item),
62            Err(error) => errors.push(format!(
63                "{}: {}",
64                project_path(&options.root, &tool_dir),
65                error.concise_message()
66            )),
67        }
68    }
69    Ok(ToolBuildReport {
70        schema: ToolBuildReportSchema::V1,
71        status: if errors.is_empty() {
72            ToolBuildStatus::Success
73        } else {
74            ToolBuildStatus::Failure
75        },
76        built,
77        errors,
78    })
79}
80
81fn build_tool_manifest(
82    root: &Path,
83    tool_dir: &Path,
84    toolkit_version: &str,
85) -> Result<BuiltToolItem, ToolCatalogError> {
86    let manifest_path = tool_dir.join("manifest.json");
87    let source = fs::read_to_string(&manifest_path)
88        .map_err(|error| ToolCatalogError::io("reading tool manifest", &manifest_path, error))?;
89    let raw_payload: JsonPayload = serde_json::from_str(&source)
90        .map_err(|error| ToolCatalogError::json("parsing tool manifest", &manifest_path, error))?;
91    let JsonPayload::Object(raw_object) = raw_payload else {
92        return Err(ToolCatalogError::InvalidManifest {
93            path: manifest_path,
94            message: "manifest.json must be an object.".to_owned(),
95        });
96    };
97    let normalized_object = normalize_tool_manifest_shape(raw_object.clone());
98    let raw: RawToolManifest = serde_json::from_value(
99        serde_json::to_value(JsonPayload::Object(normalized_object.clone())).map_err(|error| {
100            ToolCatalogError::json("normalizing tool manifest", &manifest_path, error)
101        })?,
102    )
103    .map_err(|error| ToolCatalogError::json("parsing tool manifest", &manifest_path, error))?;
104    let output = raw
105        .output
106        .unwrap_or_else(|| normalize_tool_output(raw.runx.as_ref()));
107    let source_hash = hash_tool_source(tool_dir)?;
108    let schema_hash = schema_hash(&raw_object, &output);
109    let manifest = ToolManifest {
110        schema: ToolManifestSchema::V1,
111        name: raw.name,
112        version: raw.version,
113        description: raw.description,
114        source: raw.source,
115        runtime: raw
116            .runtime
117            .unwrap_or_else(|| runtime_from_source(&raw_object)),
118        inputs: raw.inputs,
119        output,
120        scopes: raw.scopes,
121        risk: raw.risk,
122        retry: raw.retry,
123        idempotency: raw.idempotency,
124        mutating: raw.mutating,
125        runx: raw.runx,
126        source_hash,
127        schema_hash,
128        toolkit_version: Some(toolkit_version.to_owned()),
129    };
130    validate_manifest(&manifest, &manifest_path)?;
131    write_manifest(&manifest_path, &manifest)?;
132    Ok(BuiltToolItem {
133        path: project_path(root, tool_dir),
134        manifest: project_path(root, &manifest_path),
135        source_hash: manifest.source_hash,
136        schema_hash: manifest.schema_hash,
137    })
138}
139
140fn normalize_tool_manifest_shape(mut raw: JsonPayloadObject) -> JsonPayloadObject {
141    let Some(JsonPayload::Object(source)) = raw.get_mut("source") else {
142        return raw;
143    };
144    if !matches!(
145        source.get("type"),
146        Some(JsonPayload::String(value)) if value == "http"
147    ) || source.contains_key("http")
148    {
149        return raw;
150    }
151    let mut http = JsonPayloadObject::new();
152    for key in ["url", "method", "headers", "allow_private_network"] {
153        if let Some(value) = source.remove(key) {
154            http.insert(key.to_owned(), value);
155        }
156    }
157    if !http.is_empty() {
158        source.insert("http".to_owned(), JsonPayload::Object(http));
159    }
160    raw
161}
162
163fn normalize_tool_output(runx: Option<&JsonPayloadObject>) -> ToolOutput {
164    let artifacts = runx
165        .and_then(|runx| runx.get("artifacts"))
166        .and_then(|value| match value {
167            JsonPayload::Object(value) => Some(value),
168            _ => None,
169        });
170    let wrap_as = artifacts
171        .and_then(|artifacts| artifacts.get("wrap_as"))
172        .and_then(|value| match value {
173            JsonPayload::String(value) => Some(value.clone()),
174            _ => None,
175        });
176    let mut extra = JsonPayloadObject::new();
177    if let Some(JsonPayload::Object(named_emits)) =
178        artifacts.and_then(|artifacts| artifacts.get("named_emits"))
179    {
180        extra.insert(
181            "named_emits".to_owned(),
182            JsonPayload::Object(named_emits.clone()),
183        );
184    }
185    ToolOutput {
186        packet: None,
187        wrap_as,
188        named_emits: BTreeMap::new(),
189        outputs: BTreeMap::new(),
190        extra,
191    }
192}
193
194fn runtime_from_source(raw_object: &JsonPayloadObject) -> RuntimeCommand {
195    let source = raw_object.get("source").and_then(|value| match value {
196        JsonPayload::Object(value) => Some(value),
197        _ => None,
198    });
199    let command = source
200        .and_then(|source| source.get("command"))
201        .and_then(|value| match value {
202            JsonPayload::String(value) => Some(value.clone()),
203            _ => None,
204        })
205        .unwrap_or_else(|| "node".to_owned());
206    let args = source
207        .and_then(|source| source.get("args"))
208        .and_then(|value| match value {
209            JsonPayload::Array(values) => Some(
210                values
211                    .iter()
212                    .filter_map(|value| match value {
213                        JsonPayload::String(value) => Some(value.clone()),
214                        _ => None,
215                    })
216                    .collect::<Vec<_>>(),
217            ),
218            _ => None,
219        })
220        .filter(|args| !args.is_empty())
221        .unwrap_or_else(|| vec!["./run.mjs".to_owned()]);
222    RuntimeCommand {
223        command,
224        args,
225        cwd: None,
226        env: BTreeMap::new(),
227    }
228}
229
230fn schema_hash(raw: &JsonPayloadObject, output: &ToolOutput) -> String {
231    let mut payload = JsonPayloadObject::new();
232    if let Some(inputs) = raw.get("inputs") {
233        payload.insert("inputs".to_owned(), inputs.clone());
234    }
235    payload.insert("output".to_owned(), tool_output_payload(output));
236    if let Some(artifacts) = raw.get("runx").and_then(|value| match value {
237        JsonPayload::Object(value) => value.get("artifacts"),
238        _ => None,
239    }) {
240        payload.insert("artifacts".to_owned(), artifacts.clone());
241    }
242    sha256_stable(&JsonPayload::Object(payload))
243}
244
245fn tool_output_payload(output: &ToolOutput) -> JsonPayload {
246    let mut object = output.extra.clone();
247    if let Some(packet) = &output.packet {
248        object.insert("packet".to_owned(), JsonPayload::String(packet.clone()));
249    }
250    if let Some(wrap_as) = &output.wrap_as {
251        object.insert("wrap_as".to_owned(), JsonPayload::String(wrap_as.clone()));
252    }
253    if !output.named_emits.is_empty() {
254        let mut named = JsonPayloadObject::new();
255        for (label, key) in &output.named_emits {
256            named.insert(label.clone(), JsonPayload::String(key.clone()));
257        }
258        object.insert("named_emits".to_owned(), JsonPayload::Object(named));
259    }
260    if !output.outputs.is_empty() {
261        let mut outputs = JsonPayloadObject::new();
262        for (name, binding) in &output.outputs {
263            outputs.insert(name.clone(), tool_output_binding_payload(binding));
264        }
265        object.insert("outputs".to_owned(), JsonPayload::Object(outputs));
266    }
267    JsonPayload::Object(object)
268}
269
270fn tool_output_binding_payload(binding: &runx_contracts::tools::ToolOutputBinding) -> JsonPayload {
271    let mut object = binding.extra.clone();
272    if let Some(packet) = &binding.packet {
273        object.insert("packet".to_owned(), JsonPayload::String(packet.clone()));
274    }
275    if let Some(wrap_as) = &binding.wrap_as {
276        object.insert("wrap_as".to_owned(), JsonPayload::String(wrap_as.clone()));
277    }
278    JsonPayload::Object(object)
279}
280
281pub(crate) fn hash_tool_source(tool_dir: &Path) -> Result<String, ToolCatalogError> {
282    let roots = [tool_dir.join("src/index.ts"), tool_dir.join("run.mjs")];
283    let files = tool_source_closure(&roots)?;
284    let mut bytes = Vec::new();
285    let hash_root = fs::canonicalize(tool_dir).unwrap_or_else(|_| tool_dir.to_path_buf());
286    for file_path in &files {
287        bytes.extend(source_hash_path(&hash_root, file_path).as_bytes());
288        bytes.push(0);
289        bytes.extend(
290            fs::read(file_path)
291                .map_err(|error| ToolCatalogError::io("reading tool source", file_path, error))?,
292        );
293        bytes.push(0);
294    }
295    if files.is_empty() {
296        bytes.extend(b"no-source");
297    }
298    Ok(sha256_prefixed(&bytes))
299}
300
301fn tool_source_closure(roots: &[PathBuf]) -> Result<Vec<PathBuf>, ToolCatalogError> {
302    let mut pending = roots.to_vec();
303    let mut seen = BTreeSet::new();
304    let mut index = 0;
305    while index < pending.len() {
306        let source_path = pending[index].clone();
307        index += 1;
308        if !source_path.exists() {
309            continue;
310        }
311        let source_path = fs::canonicalize(&source_path)
312            .map_err(|error| ToolCatalogError::io("resolving tool source", &source_path, error))?;
313        if !seen.insert(source_path.clone()) {
314            continue;
315        }
316        let source = fs::read_to_string(&source_path)
317            .map_err(|error| ToolCatalogError::io("reading tool source", &source_path, error))?;
318        for specifier in local_import_specifiers(&source) {
319            if let Some(dependency) = resolve_local_source_import(&source_path, &specifier)? {
320                pending.push(dependency);
321            }
322        }
323    }
324    Ok(seen.into_iter().collect())
325}
326
327fn local_import_specifiers(source: &str) -> Vec<String> {
328    let mut specifiers = Vec::new();
329    let mut chars = source.char_indices().peekable();
330    while let Some((start, quote)) = chars.next() {
331        if quote != '"' && quote != '\'' {
332            continue;
333        }
334        let mut escaped = false;
335        let mut end = start + quote.len_utf8();
336        for (index, character) in chars.by_ref() {
337            end = index;
338            if escaped {
339                escaped = false;
340                continue;
341            }
342            if character == '\\' {
343                escaped = true;
344                continue;
345            }
346            if character == quote {
347                break;
348            }
349        }
350        let value = &source[start + quote.len_utf8()..end];
351        if value.starts_with("./") || value.starts_with("../") {
352            specifiers.push(value.to_owned());
353        }
354    }
355    specifiers
356}
357
358fn resolve_local_source_import(
359    from_file: &Path,
360    specifier: &str,
361) -> Result<Option<PathBuf>, ToolCatalogError> {
362    let clean_specifier = specifier
363        .split(['?', '#'])
364        .next()
365        .filter(|value| !value.is_empty())
366        .unwrap_or(specifier);
367    let base = from_file
368        .parent()
369        .unwrap_or_else(|| Path::new(""))
370        .join(clean_specifier);
371    for candidate in source_import_candidates(&base) {
372        if candidate.exists() {
373            return fs::canonicalize(&candidate)
374                .map(Some)
375                .map_err(|error| ToolCatalogError::io("resolving tool source", &candidate, error));
376        }
377    }
378    Ok(None)
379}
380
381fn source_import_candidates(base: &Path) -> Vec<PathBuf> {
382    let Some(extension) = base.extension().and_then(|extension| extension.to_str()) else {
383        let extensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs"];
384        return extensions
385            .iter()
386            .map(|extension| PathBuf::from(format!("{}{}", base.display(), extension)))
387            .chain(
388                extensions
389                    .iter()
390                    .map(|extension| base.join(format!("index{extension}"))),
391            )
392            .collect();
393    };
394    let mut candidates = vec![base.to_path_buf()];
395    match extension {
396        "js" => {
397            candidates.push(base.with_extension("ts"));
398            candidates.push(base.with_extension("tsx"));
399        }
400        "mjs" => {
401            candidates.push(base.with_extension("mts"));
402            candidates.push(base.with_extension("ts"));
403        }
404        "cjs" => {
405            candidates.push(base.with_extension("cts"));
406            candidates.push(base.with_extension("ts"));
407        }
408        _ => {}
409    }
410    candidates
411}
412
413fn source_hash_path(root: &Path, file_path: &Path) -> String {
414    let root_components = path_component_strings(root);
415    let file_components = path_component_strings(file_path);
416    let common_len = root_components
417        .iter()
418        .zip(&file_components)
419        .take_while(|(left, right)| left == right)
420        .count();
421    if common_len == 0 {
422        return file_path.to_string_lossy().replace('\\', "/");
423    }
424    let mut parts = Vec::new();
425    for _ in common_len..root_components.len() {
426        parts.push("..".to_owned());
427    }
428    parts.extend(file_components[common_len..].iter().cloned());
429    if parts.is_empty() {
430        ".".to_owned()
431    } else {
432        parts.join("/")
433    }
434}
435
436fn path_component_strings(path: &Path) -> Vec<String> {
437    path.components()
438        .map(|component| component.as_os_str().to_string_lossy().into_owned())
439        .collect()
440}
441
442fn validate_manifest(manifest: &ToolManifest, path: &Path) -> Result<(), ToolCatalogError> {
443    let json = serde_json::to_string(manifest)
444        .map_err(|error| ToolCatalogError::json("serializing tool manifest", path, error))?;
445    let raw = runx_parser::parse_tool_manifest_json(&json).map_err(|error| {
446        ToolCatalogError::InvalidManifest {
447            path: path.to_path_buf(),
448            message: error.to_string(),
449        }
450    })?;
451    runx_parser::validate_tool_manifest(raw).map_err(|error| {
452        ToolCatalogError::InvalidManifest {
453            path: path.to_path_buf(),
454            message: error.to_string(),
455        }
456    })?;
457    Ok(())
458}
459
460fn write_manifest(path: &Path, manifest: &ToolManifest) -> Result<(), ToolCatalogError> {
461    let json = serde_json::to_string_pretty(manifest)
462        .map_err(|error| ToolCatalogError::json("serializing tool manifest", path, error))?;
463    fs::write(path, format!("{json}\n"))
464        .map_err(|error| ToolCatalogError::io("writing tool manifest", path, error))
465}
466
467fn discover_tool_directories(root: &Path) -> Result<Vec<PathBuf>, ToolCatalogError> {
468    let tools_root = root.join("tools");
469    let mut directories = Vec::new();
470    for namespace in read_dirs(&tools_root)? {
471        for tool in read_dirs(&namespace)? {
472            if tool.join("manifest.json").exists() {
473                directories.push(tool);
474            }
475        }
476    }
477    directories.sort();
478    Ok(directories)
479}
480
481fn read_dirs(path: &Path) -> Result<Vec<PathBuf>, ToolCatalogError> {
482    let entries = match fs::read_dir(path) {
483        Ok(entries) => entries,
484        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
485        Err(error) => return Err(ToolCatalogError::io("reading directory", path, error)),
486    };
487    let mut dirs = Vec::new();
488    for entry in entries {
489        let entry =
490            entry.map_err(|error| ToolCatalogError::io("reading directory", path, error))?;
491        let file_type = entry.file_type().map_err(|error| {
492            ToolCatalogError::io("reading directory entry", entry.path(), error)
493        })?;
494        if file_type.is_dir() {
495            dirs.push(entry.path());
496        }
497    }
498    dirs.sort();
499    Ok(dirs)
500}
501
502fn resolve_tool_path(root: &Path, tool_path: Option<&Path>) -> Result<PathBuf, ToolCatalogError> {
503    let Some(tool_path) = tool_path else {
504        return Err(ToolCatalogError::InvalidRequest(
505            "runx tool build requires a tool directory or --all".to_owned(),
506        ));
507    };
508    if tool_path.is_absolute() {
509        Ok(tool_path.to_path_buf())
510    } else {
511        Ok(root.join(tool_path))
512    }
513}
514
515pub(crate) fn project_path(root: &Path, path: &Path) -> String {
516    path.strip_prefix(root)
517        .map_or(path, |path| path)
518        .to_string_lossy()
519        .replace('\\', "/")
520}