1use std::collections::BTreeMap;
2
3use runx_contracts::{JsonObject, JsonValue};
4use runx_core::policy::admit_agent_tool_ref;
5
6use crate::ValidationError;
7
8use super::{
9 FIELDS, RawSkillIr, SkillArtifactContract, SkillGovernance, SkillIdempotencyPolicy, SkillInput,
10 SkillRetryPolicy, field_value, first_value, nested_value, validate_execution_semantics,
11};
12
13pub(super) fn validate_skill_governance(
14 raw: &RawSkillIr,
15 runx: Option<&JsonObject>,
16 risk: Option<&JsonValue>,
17) -> Result<SkillGovernance, ValidationError> {
18 Ok(SkillGovernance {
19 retry: validate_retry(
20 first_value(raw.frontmatter.get("retry"), field_value(runx, "retry")),
21 "retry",
22 )?,
23 idempotency: validate_idempotency(
24 first_value(
25 raw.frontmatter.get("idempotency"),
26 field_value(runx, "idempotency"),
27 ),
28 "idempotency",
29 )?,
30 mutating: validate_mutating(
31 first_value(
32 first_value(
33 raw.frontmatter.get("mutating"),
34 nested_value(risk, "mutating"),
35 ),
36 field_value(runx, "mutating"),
37 ),
38 "mutating",
39 )?,
40 artifacts: validate_artifact_contract(field_value(runx, "artifacts"), "runx.artifacts")?,
41 allowed_tools: validate_allowed_tools(
42 field_value(runx, "allowed_tools"),
43 "runx.allowed_tools",
44 )?,
45 execution: validate_execution_semantics(
46 first_value(
47 raw.frontmatter.get("execution"),
48 field_value(runx, "execution"),
49 ),
50 "execution",
51 )?,
52 })
53}
54
55pub fn validate_skill_artifact_contract(
56 value: Option<&JsonValue>,
57 field: &str,
58) -> Result<Option<SkillArtifactContract>, ValidationError> {
59 validate_artifact_contract(value, field)
60}
61
62pub(super) fn validate_artifact_contract(
63 value: Option<&JsonValue>,
64 field: &str,
65) -> Result<Option<SkillArtifactContract>, ValidationError> {
66 let Some(record) = FIELDS.optional_object(value, field)? else {
67 return Ok(None);
68 };
69 let emits = match record.get("emits") {
70 Some(JsonValue::String(value)) => Some(vec![value.clone()]),
71 value => FIELDS.optional_string_array(value, &format!("{field}.emits"))?,
72 };
73 let named_emits = validate_named_emits(
74 first_value(record.get("named_emits"), record.get("namedEmits")),
75 &format!("{field}.named_emits"),
76 )?;
77 let wrap_as = FIELDS.optional_non_empty_string(
78 first_value(record.get("wrap_as"), record.get("wrapAs")),
79 &format!("{field}.wrap_as"),
80 )?;
81 if emits.is_none() && named_emits.is_none() && wrap_as.is_none() {
82 return Ok(None);
83 }
84 Ok(Some(SkillArtifactContract {
85 emits,
86 named_emits,
87 wrap_as,
88 }))
89}
90
91pub(super) fn validate_inputs(
92 inputs: JsonObject,
93) -> Result<BTreeMap<String, SkillInput>, ValidationError> {
94 inputs
95 .into_iter()
96 .map(|(name, value)| {
97 let field = format!("inputs.{name}");
98 let input = FIELDS.required_object(Some(&value), &field)?;
99 Ok((
100 name.clone(),
101 SkillInput {
102 input_type: FIELDS
103 .optional_string(input.get("type"), &format!("{field}.type"))?
104 .unwrap_or_else(|| "string".to_owned()),
105 required: FIELDS
106 .optional_bool(input.get("required"), &format!("{field}.required"))?
107 .unwrap_or(false),
108 description: FIELDS.optional_string(
109 input.get("description"),
110 &format!("{field}.description"),
111 )?,
112 default: input.get("default").cloned(),
113 },
114 ))
115 })
116 .collect()
117}
118
119pub(super) fn validate_retry(
120 value: Option<&JsonValue>,
121 field: &str,
122) -> Result<Option<SkillRetryPolicy>, ValidationError> {
123 let Some(retry) = FIELDS.optional_object(value, field)? else {
124 return Ok(None);
125 };
126 let max_attempts = FIELDS
127 .optional_u64(retry.get("max_attempts"), &format!("{field}.max_attempts"))?
128 .unwrap_or(1);
129 if max_attempts == 0 {
130 return Err(
131 FIELDS.validation_error(format!("{field}.max_attempts must be a positive integer."))
132 );
133 }
134 Ok(Some(SkillRetryPolicy { max_attempts }))
135}
136
137pub(super) fn validate_idempotency(
138 value: Option<&JsonValue>,
139 field: &str,
140) -> Result<Option<SkillIdempotencyPolicy>, ValidationError> {
141 match value {
142 None | Some(JsonValue::Null) => Ok(None),
143 Some(JsonValue::String(value)) if value.trim().is_empty() => {
144 Err(FIELDS.validation_error(format!("{field} must not be empty.")))
145 }
146 Some(JsonValue::String(value)) => Ok(Some(SkillIdempotencyPolicy {
147 key: Some(value.clone()),
148 })),
149 Some(value) => {
150 let record = FIELDS.required_object(Some(value), field)?;
151 Ok(Some(SkillIdempotencyPolicy {
152 key: FIELDS
153 .optional_non_empty_string(record.get("key"), &format!("{field}.key"))?,
154 }))
155 }
156 }
157}
158
159pub(super) fn validate_mutating(
160 value: Option<&JsonValue>,
161 field: &str,
162) -> Result<Option<bool>, ValidationError> {
163 FIELDS.optional_bool(value, field)
164}
165
166fn validate_named_emits(
167 value: Option<&JsonValue>,
168 field: &str,
169) -> Result<Option<BTreeMap<String, String>>, ValidationError> {
170 let Some(record) = FIELDS.optional_object(value, field)? else {
171 return Ok(None);
172 };
173 record
174 .into_iter()
175 .map(|(key, value)| {
176 let JsonValue::String(value) = value else {
177 return Err(
178 FIELDS.validation_error(format!("{field}.{key} must be a non-empty string."))
179 );
180 };
181 if value.trim().is_empty() {
182 return Err(
183 FIELDS.validation_error(format!("{field}.{key} must be a non-empty string."))
184 );
185 }
186 Ok((key, value))
187 })
188 .collect::<Result<BTreeMap<_, _>, _>>()
189 .map(Some)
190}
191
192pub(super) fn validate_allowed_tools(
193 value: Option<&JsonValue>,
194 field: &str,
195) -> Result<Option<Vec<String>>, ValidationError> {
196 let Some(values) = FIELDS.optional_string_array(value, field)? else {
197 return Ok(None);
198 };
199 for value in &values {
200 let admission = admit_agent_tool_ref(value);
201 if !admission.allowed {
202 return Err(FIELDS.validation_error(format!(
203 "{field} entry {value:?} is not an admissible agent tool ref: {}.",
204 admission.reason
205 )));
206 }
207 }
208 Ok(Some(values))
209}