Skip to main content

component_shape_mcp/
validation.rs

1use super::*;
2
3/// Where a generated MCP validation issue applies.
4#[derive(Clone, Copy, Debug, Eq, IntoStaticStr, PartialEq)]
5#[strum(serialize_all = "snake_case", const_into_str)]
6pub enum McpValidationScope {
7    /// The whole form failed validation without a precise field.
8    Form,
9    /// A field-level validator failed.
10    Field,
11    /// A validator for one item inside a collection field failed.
12    Element,
13    /// A table filter argument failed validation.
14    Filter,
15}
16
17impl McpValidationScope {
18    pub const fn as_str(self) -> &'static str {
19        self.into_str()
20    }
21}
22
23/// Koruma target selector recorded for an MCP validation rule.
24#[derive(Clone, Copy, Debug, Eq, IntoStaticStr, PartialEq)]
25#[strum(serialize_all = "snake_case", const_into_str)]
26pub enum McpValidationTarget {
27    /// Koruma selected the default target for the field type.
28    Default,
29    /// The validator targets the full field value, such as an `Option<T>`.
30    Full,
31    /// The validator targets the unwrapped field value.
32    Unwrapped,
33}
34
35impl McpValidationTarget {
36    pub const fn as_str(self) -> &'static str {
37        self.into_str()
38    }
39}
40
41/// Type argument syntax used by a generated validator descriptor.
42#[derive(Clone, Copy, Debug, Eq, IntoStaticStr, PartialEq)]
43#[strum(serialize_all = "snake_case", const_into_str)]
44pub enum McpValidationTypeArgMode {
45    /// The validator path did not supply a type argument.
46    None,
47    /// The validator used `::<_>`.
48    Infer,
49    /// The validator supplied an explicit type argument.
50    Explicit,
51}
52
53impl McpValidationTypeArgMode {
54    pub const fn as_str(self) -> &'static str {
55        self.into_str()
56    }
57}
58
59/// One builder argument captured from a generated validator chain.
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub struct McpValidationParam {
62    name: &'static str,
63    literal: Option<&'static str>,
64    expr: Option<&'static str>,
65}
66
67impl McpValidationParam {
68    /// Record a literal argument value that can be reflected into schemas.
69    pub const fn literal(name: &'static str, literal: &'static str) -> Self {
70        Self {
71            name,
72            literal: Some(literal),
73            expr: None,
74        }
75    }
76
77    /// Record a non-literal expression for tool clients to display or inspect.
78    pub const fn expr(name: &'static str, expr: &'static str) -> Self {
79        Self {
80            name,
81            literal: None,
82            expr: Some(expr),
83        }
84    }
85
86    /// Builder method or argument name.
87    pub const fn name(self) -> &'static str {
88        self.name
89    }
90
91    /// Literal argument value, when the derive could statically identify one.
92    pub const fn literal_value(self) -> Option<&'static str> {
93        self.literal
94    }
95
96    /// Non-literal argument expression, when no literal value is available.
97    pub const fn expr_value(self) -> Option<&'static str> {
98        self.expr
99    }
100
101    pub fn to_value(self) -> Value {
102        let mut object = Map::new();
103        object.insert("name".to_string(), Value::String(self.name.to_string()));
104        if let Some(literal) = self.literal {
105            object.insert("value".to_string(), Value::String(literal.to_string()));
106        }
107        if let Some(expr) = self.expr {
108            object.insert("expr".to_string(), Value::String(expr.to_string()));
109        }
110        Value::Object(object)
111    }
112}
113
114/// Shared empty validation parameter slice for generated descriptors.
115pub const MCP_VALIDATION_PARAMS_NONE: &[McpValidationParam] = &[];
116
117/// Static validator metadata attached to an MCP-visible field or filter.
118#[derive(Clone, Copy, Debug, Eq, PartialEq)]
119pub struct McpValidationRule {
120    scope: McpValidationScope,
121    validator: &'static str,
122    path: &'static str,
123    label: Option<&'static str>,
124    target: Option<McpValidationTarget>,
125    type_arg_mode: McpValidationTypeArgMode,
126    params: &'static [McpValidationParam],
127}
128
129impl McpValidationRule {
130    /// Create a validation rule descriptor for generated MCP metadata.
131    pub const fn new(
132        scope: McpValidationScope,
133        validator: &'static str,
134        path: &'static str,
135        label: Option<&'static str>,
136        type_arg_mode: McpValidationTypeArgMode,
137        params: &'static [McpValidationParam],
138    ) -> Self {
139        Self {
140            scope,
141            validator,
142            path,
143            label,
144            target: None,
145            type_arg_mode,
146            params,
147        }
148    }
149
150    /// Attach a Koruma target selector to a rule descriptor.
151    pub const fn with_target(mut self, target: McpValidationTarget) -> Self {
152        self.target = Some(target);
153        self
154    }
155
156    /// Scope where this validator runs.
157    pub const fn scope(self) -> McpValidationScope {
158        self.scope
159    }
160
161    /// Terminal validator type name.
162    pub const fn validator(self) -> &'static str {
163        self.validator
164    }
165
166    /// Validator path as written in the source attribute.
167    pub const fn path(self) -> &'static str {
168        self.path
169    }
170
171    /// Optional source label assigned to this validator.
172    pub const fn label(self) -> Option<&'static str> {
173        self.label
174    }
175
176    /// Optional Koruma target selector used by this validator.
177    pub const fn target(self) -> Option<McpValidationTarget> {
178        self.target
179    }
180
181    /// Type argument syntax used by this validator.
182    pub const fn type_arg_mode(self) -> McpValidationTypeArgMode {
183        self.type_arg_mode
184    }
185
186    /// Captured builder parameters for this validator.
187    pub const fn params(self) -> &'static [McpValidationParam] {
188        self.params
189    }
190
191    pub fn to_value(self) -> Value {
192        let mut object = Map::new();
193        object.insert(
194            "scope".to_string(),
195            Value::String(self.scope.as_str().to_string()),
196        );
197        object.insert(
198            "validator".to_string(),
199            Value::String(self.validator.to_string()),
200        );
201        object.insert("path".to_string(), Value::String(self.path.to_string()));
202        if let Some(label) = self.label {
203            object.insert("label".to_string(), Value::String(label.to_string()));
204        }
205        if let Some(target) = self.target {
206            object.insert(
207                "target".to_string(),
208                Value::String(target.as_str().to_string()),
209            );
210        }
211        object.insert(
212            "type_arg_mode".to_string(),
213            Value::String(self.type_arg_mode.as_str().to_string()),
214        );
215        if !self.params.is_empty() {
216            object.insert(
217                "params".to_string(),
218                Value::Array(self.params.iter().map(|param| param.to_value()).collect()),
219            );
220        }
221        Value::Object(object)
222    }
223}
224
225/// Structured validation failure returned in MCP error details and snapshots.
226#[derive(Clone, Debug, Eq, PartialEq)]
227pub struct McpValidationIssue {
228    field: Option<String>,
229    filter: Option<String>,
230    scope: McpValidationScope,
231    validator: Option<String>,
232    path: Option<String>,
233    label: Option<String>,
234    target: Option<McpValidationTarget>,
235    element_index: Option<usize>,
236    message: String,
237    params: Vec<McpValidationParam>,
238}
239
240impl McpValidationIssue {
241    /// Build a form-scoped issue when no field-specific metadata is available.
242    pub fn form(message: impl Into<String>) -> Self {
243        Self::custom(McpValidationScope::Form, message)
244    }
245
246    /// Build a generic issue for integrations that already have structured metadata.
247    pub fn custom(scope: McpValidationScope, message: impl Into<String>) -> Self {
248        Self {
249            field: None,
250            filter: None,
251            scope,
252            validator: None,
253            path: None,
254            label: None,
255            target: None,
256            element_index: None,
257            message: message.into(),
258            params: Vec::new(),
259        }
260    }
261
262    /// Build a required-field issue.
263    pub fn required(field: impl AsRef<str>) -> Self {
264        let field = field.as_ref();
265        Self::custom(
266            McpValidationScope::Field,
267            format!("missing required field `{field}`"),
268        )
269        .with_field(field)
270        .with_validator("required")
271    }
272
273    /// Build an issue from a static validation rule descriptor.
274    pub fn for_rule(
275        field: impl AsRef<str>,
276        rule: McpValidationRule,
277        message: impl Into<String>,
278    ) -> Self {
279        Self::custom(rule.scope(), message)
280            .with_field(field)
281            .with_rule(rule)
282    }
283
284    /// Build a table-filter issue from a static validation rule descriptor.
285    pub fn for_filter_rule(
286        filter: impl Into<String>,
287        rule: McpValidationRule,
288        message: impl Into<String>,
289    ) -> Self {
290        Self::custom(rule.scope(), message)
291            .with_filter(filter)
292            .with_rule(rule)
293    }
294
295    fn with_rule(mut self, rule: McpValidationRule) -> Self {
296        self.validator = Some(rule.validator().to_string());
297        self.path = Some(rule.path().to_string());
298        self.label = rule.label().map(str::to_string);
299        self.target = rule.target();
300        self.params = rule.params().to_vec();
301        self
302    }
303
304    /// Attach the failing collection element index to an element issue.
305    pub fn with_element_index(mut self, element_index: usize) -> Self {
306        self.element_index = Some(element_index);
307        self
308    }
309
310    /// Attach a field name to an issue.
311    pub fn with_field(mut self, field: impl AsRef<str>) -> Self {
312        self.field = Some(field.as_ref().to_string());
313        self
314    }
315
316    /// Attach a table filter name to an issue.
317    pub fn with_filter(mut self, filter: impl Into<String>) -> Self {
318        self.filter = Some(filter.into());
319        self
320    }
321
322    /// Attach a validator name to a generic issue.
323    pub fn with_validator(mut self, validator: impl Into<String>) -> Self {
324        self.validator = Some(validator.into());
325        self
326    }
327
328    /// Attach a source label to a generic issue.
329    pub fn with_label(mut self, label: impl Into<String>) -> Self {
330        self.label = Some(label.into());
331        self
332    }
333
334    /// Field name for field or element issues.
335    pub fn field(&self) -> Option<&str> {
336        self.field.as_deref()
337    }
338
339    /// Table filter name for filter issues.
340    pub fn filter(&self) -> Option<&str> {
341        self.filter.as_deref()
342    }
343
344    /// Human-readable validation message.
345    pub fn message(&self) -> &str {
346        &self.message
347    }
348
349    /// Convert the issue to JSON for MCP structured content.
350    pub fn to_value(&self) -> Value {
351        let mut object = Map::new();
352        object.insert(
353            "scope".to_string(),
354            Value::String(self.scope.as_str().to_string()),
355        );
356        object.insert("message".to_string(), Value::String(self.message.clone()));
357        if let Some(field) = &self.field {
358            object.insert("field".to_string(), Value::String(field.clone()));
359        }
360        if let Some(filter) = &self.filter {
361            object.insert("filter".to_string(), Value::String(filter.clone()));
362        }
363        if let Some(validator) = &self.validator {
364            object.insert("validator".to_string(), Value::String(validator.clone()));
365        }
366        if let Some(path) = &self.path {
367            object.insert("path".to_string(), Value::String(path.clone()));
368        }
369        if let Some(label) = &self.label {
370            object.insert("label".to_string(), Value::String(label.clone()));
371        }
372        if let Some(target) = self.target {
373            object.insert(
374                "target".to_string(),
375                Value::String(target.as_str().to_string()),
376            );
377        }
378        if let Some(element_index) = self.element_index {
379            object.insert(
380                "element_index".to_string(),
381                Value::Number((element_index as u64).into()),
382            );
383        }
384        if !self.params.is_empty() {
385            object.insert(
386                "params".to_string(),
387                Value::Array(self.params.iter().map(|param| param.to_value()).collect()),
388            );
389        }
390        Value::Object(object)
391    }
392}
393
394/// Convert validation issues into a structured MCP validation error.
395pub fn validation_issues_error(issues: Vec<McpValidationIssue>) -> McpToolError {
396    let message = issues
397        .iter()
398        .map(McpValidationIssue::message)
399        .collect::<Vec<_>>()
400        .join("; ");
401    McpToolError::validation_structured_details(
402        message,
403        issues.into_iter().map(|issue| issue.to_value()),
404    )
405}