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