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}