Skip to main content

runx_parser/
tool.rs

1use std::collections::BTreeMap;
2
3use runx_contracts::{JsonObject, JsonValue};
4use serde::{Deserialize, Serialize};
5
6use crate::skill::{
7    SkillArtifactContract, SkillIdempotencyPolicy, SkillInput, SkillRetryPolicy, SkillSource,
8    validate_skill_artifact_contract, validate_skill_source,
9};
10use crate::{
11    ParseError, ValidationError, assert_yaml_parity_subset,
12    json_fields::{self, JsonFieldReader},
13};
14
15const FIELDS: JsonFieldReader = JsonFieldReader::new("tool_manifest");
16
17#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
18pub struct RawToolManifestIr {
19    pub document: JsonObject,
20    pub raw: String,
21}
22
23#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct ValidatedTool {
26    pub name: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub description: Option<String>,
29    pub source: SkillSource,
30    pub inputs: BTreeMap<String, SkillInput>,
31    pub scopes: Vec<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub risk: Option<JsonValue>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub runtime: Option<JsonValue>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub retry: Option<SkillRetryPolicy>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub idempotency: Option<SkillIdempotencyPolicy>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub mutating: Option<bool>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub artifacts: Option<SkillArtifactContract>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub runx: Option<JsonObject>,
46    pub raw: RawToolManifestIr,
47}
48
49pub fn parse_tool_manifest_yaml(yaml: &str) -> Result<RawToolManifestIr, ParseError> {
50    assert_yaml_parity_subset("tool_manifest", yaml)?;
51    let parsed: JsonValue =
52        serde_norway::from_str(yaml).map_err(|error| ParseError::InvalidYaml {
53            field: "tool_manifest".to_owned(),
54            message: error.to_string(),
55        })?;
56    manifest_from_value(parsed, yaml, "Tool manifest YAML must parse to an object.")
57}
58
59pub fn parse_tool_manifest_json(json: &str) -> Result<RawToolManifestIr, ParseError> {
60    let parsed: JsonValue =
61        serde_json::from_str(json).map_err(|error| ParseError::InvalidJson {
62            field: "tool_manifest".to_owned(),
63            message: format!("Tool manifest JSON is invalid: {error}"),
64        })?;
65    manifest_from_value(parsed, json, "Tool manifest JSON must parse to an object.")
66}
67
68pub fn validate_tool_manifest(raw: RawToolManifestIr) -> Result<ValidatedTool, ValidationError> {
69    let runx = FIELDS.optional_object(raw.document.get("runx"), "runx")?;
70    let risk = raw.document.get("risk").cloned();
71    let source = validate_tool_source(
72        validate_skill_source(
73            &FIELDS
74                .required_object(raw.document.get("source"), "source")?
75                .clone(),
76            runx.as_ref(),
77        )?,
78        "source.type",
79    )?;
80    Ok(ValidatedTool {
81        name: FIELDS.required_string(raw.document.get("name"), "name")?,
82        description: FIELDS.optional_string(raw.document.get("description"), "description")?,
83        source,
84        inputs: validate_inputs(
85            FIELDS
86                .optional_object(raw.document.get("inputs"), "inputs")?
87                .unwrap_or_default(),
88        )?,
89        scopes: FIELDS
90            .optional_string_array(raw.document.get("scopes"), "scopes")?
91            .unwrap_or_default(),
92        risk: risk.clone(),
93        runtime: raw.document.get("runtime").cloned(),
94        retry: validate_retry(
95            json_fields::first_value(
96                raw.document.get("retry"),
97                json_fields::field_value(runx.as_ref(), "retry"),
98            ),
99            "retry",
100        )?,
101        idempotency: validate_idempotency(
102            json_fields::first_value(
103                raw.document.get("idempotency"),
104                json_fields::field_value(runx.as_ref(), "idempotency"),
105            ),
106            "idempotency",
107        )?,
108        mutating: validate_mutating(
109            json_fields::first_value(
110                json_fields::first_value(
111                    raw.document.get("mutating"),
112                    json_fields::nested_value(risk.as_ref(), "mutating"),
113                ),
114                json_fields::field_value(runx.as_ref(), "mutating"),
115            ),
116            "mutating",
117        )?,
118        artifacts: validate_skill_artifact_contract(
119            json_fields::field_value(runx.as_ref(), "artifacts"),
120            "runx.artifacts",
121        )?,
122        runx,
123        raw,
124    })
125}
126
127fn validate_tool_source(source: SkillSource, field: &str) -> Result<SkillSource, ValidationError> {
128    if matches!(
129        source.source_type.as_str(),
130        "cli-tool" | "mcp" | "a2a" | "catalog" | "http"
131    ) {
132        return Ok(source);
133    }
134    Err(FIELDS.validation_error(format!(
135        "{field} must be one of cli-tool, mcp, a2a, catalog, or http for tool manifests."
136    )))
137}
138
139fn manifest_from_value(
140    value: JsonValue,
141    raw: &str,
142    object_error: &str,
143) -> Result<RawToolManifestIr, ParseError> {
144    let JsonValue::Object(document) = value else {
145        return Err(ParseError::InvalidDocument {
146            field: "tool_manifest".to_owned(),
147            message: object_error.to_owned(),
148        });
149    };
150    Ok(RawToolManifestIr {
151        document,
152        raw: raw.to_owned(),
153    })
154}
155
156fn validate_inputs(inputs: JsonObject) -> Result<BTreeMap<String, SkillInput>, ValidationError> {
157    inputs
158        .into_iter()
159        .map(|(name, value)| {
160            let field = format!("inputs.{name}");
161            let input = FIELDS.required_object(Some(&value), &field)?;
162            Ok((
163                name.clone(),
164                SkillInput {
165                    input_type: FIELDS
166                        .optional_string(input.get("type"), &format!("{field}.type"))?
167                        .unwrap_or_else(|| "string".to_owned()),
168                    required: FIELDS
169                        .optional_bool(input.get("required"), &format!("{field}.required"))?
170                        .unwrap_or(false),
171                    description: FIELDS.optional_string(
172                        input.get("description"),
173                        &format!("{field}.description"),
174                    )?,
175                    default: input.get("default").cloned(),
176                },
177            ))
178        })
179        .collect()
180}
181
182fn validate_retry(
183    value: Option<&JsonValue>,
184    field: &str,
185) -> Result<Option<SkillRetryPolicy>, ValidationError> {
186    let Some(retry) = FIELDS.optional_object(value, field)? else {
187        return Ok(None);
188    };
189    let max_attempts = FIELDS
190        .optional_u64(retry.get("max_attempts"), &format!("{field}.max_attempts"))?
191        .unwrap_or(1);
192    if max_attempts == 0 {
193        return Err(
194            FIELDS.validation_error(format!("{field}.max_attempts must be a positive integer."))
195        );
196    }
197    Ok(Some(SkillRetryPolicy { max_attempts }))
198}
199
200fn validate_idempotency(
201    value: Option<&JsonValue>,
202    field: &str,
203) -> Result<Option<SkillIdempotencyPolicy>, ValidationError> {
204    match value {
205        None | Some(JsonValue::Null) => Ok(None),
206        Some(JsonValue::String(value)) if value.trim().is_empty() => {
207            Err(FIELDS.validation_error(format!("{field} must not be empty.")))
208        }
209        Some(JsonValue::String(value)) => Ok(Some(SkillIdempotencyPolicy {
210            key: Some(value.clone()),
211        })),
212        Some(value) => {
213            let record = FIELDS.required_object(Some(value), field)?;
214            Ok(Some(SkillIdempotencyPolicy {
215                key: FIELDS
216                    .optional_non_empty_string(record.get("key"), &format!("{field}.key"))?,
217            }))
218        }
219    }
220}
221
222fn validate_mutating(
223    value: Option<&JsonValue>,
224    field: &str,
225) -> Result<Option<bool>, ValidationError> {
226    FIELDS.optional_bool(value, field)
227}