just_mcp_lib/
validator.rs

1use crate::Recipe;
2use snafu::prelude::*;
3
4#[derive(Debug, Clone, PartialEq)]
5pub struct ValidationResult {
6    pub is_valid: bool,
7    pub errors: Vec<ValidationError>,
8}
9
10#[derive(Debug, Clone, PartialEq)]
11pub struct ValidationError {
12    pub parameter: String,
13    pub message: String,
14}
15
16#[derive(Debug, Clone, PartialEq)]
17pub struct SignatureHelp {
18    pub recipe_name: String,
19    pub parameters: Vec<ParameterInfo>,
20    pub documentation: Option<String>,
21}
22
23#[derive(Debug, Clone, PartialEq)]
24pub struct ParameterInfo {
25    pub name: String,
26    pub required: bool,
27    pub default_value: Option<String>,
28    pub description: Option<String>,
29}
30
31#[derive(Debug, Snafu)]
32pub enum ValidationSnafu {
33    #[snafu(display("Recipe '{}' not found", recipe_name))]
34    RecipeNotFound { recipe_name: String },
35}
36
37pub type Result<T> = std::result::Result<T, ValidationSnafu>;
38
39/// Validate arguments against recipe parameters
40pub fn validate_arguments(recipe: &Recipe, args: &[String]) -> ValidationResult {
41    let mut errors = Vec::new();
42    let params = &recipe.parameters;
43
44    // Check if we have too many arguments
45    if args.len() > params.len() {
46        errors.push(ValidationError {
47            parameter: "<extra>".to_string(),
48            message: format!(
49                "Too many arguments: expected at most {}, got {}",
50                params.len(),
51                args.len()
52            ),
53        });
54    }
55
56    // Check each parameter
57    for (i, param) in params.iter().enumerate() {
58        if i >= args.len() {
59            // No argument provided for this parameter
60            if param.default_value.is_none() {
61                errors.push(ValidationError {
62                    parameter: param.name.clone(),
63                    message: format!("Missing required parameter: {}", param.name),
64                });
65            }
66        }
67        // If an argument is provided, it's valid (we don't do type checking yet)
68    }
69
70    ValidationResult {
71        is_valid: errors.is_empty(),
72        errors,
73    }
74}
75
76/// Get signature help for a recipe
77pub fn get_signature_help(recipe: &Recipe) -> SignatureHelp {
78    let parameters = recipe
79        .parameters
80        .iter()
81        .map(|param| ParameterInfo {
82            name: param.name.clone(),
83            required: param.default_value.is_none(),
84            default_value: param.default_value.clone(),
85            description: None, // Could be enhanced to parse parameter documentation
86        })
87        .collect();
88
89    SignatureHelp {
90        recipe_name: recipe.name.clone(),
91        parameters,
92        documentation: recipe.documentation.clone(),
93    }
94}
95
96/// Format signature help for display
97pub fn format_signature_help(help: &SignatureHelp) -> String {
98    let mut result = String::new();
99
100    // Recipe name and parameters
101    result.push_str(&format!("{}(", help.recipe_name));
102
103    let param_strings: Vec<String> = help
104        .parameters
105        .iter()
106        .map(|param| {
107            if param.required {
108                param.name.clone()
109            } else {
110                format!(
111                    "{}={}",
112                    param.name,
113                    param.default_value.as_deref().unwrap_or("")
114                )
115            }
116        })
117        .collect();
118
119    result.push_str(&param_strings.join(", "));
120    result.push(')');
121
122    // Add documentation and parameter sections with proper spacing
123    let has_doc = help.documentation.is_some();
124    let has_params = !help.parameters.is_empty();
125
126    // Documentation
127    if let Some(ref doc) = help.documentation {
128        result.push_str(&format!("\n\n{doc}"));
129    }
130
131    // Parameter details
132    if has_params {
133        if has_doc {
134            result.push_str("\n\nParameters:");
135        } else {
136            result.push_str("\nParameters:");
137        }
138        for param in &help.parameters {
139            result.push_str(&format!("\n  {}", param.name));
140            if param.required {
141                result.push_str(" (required)");
142            } else {
143                let default_display = match param.default_value.as_deref() {
144                    Some("") => "none",
145                    Some(val) => val,
146                    None => "none",
147                };
148                result.push_str(&format!(" (optional, default: {default_display})"));
149            }
150            if let Some(ref desc) = param.description {
151                result.push_str(&format!(" - {desc}"));
152            }
153        }
154    }
155
156    result
157}
158
159/// Validate arguments and provide helpful error messages
160pub fn validate_with_help(recipe: &Recipe, args: &[String]) -> ValidationResult {
161    let mut result = validate_arguments(recipe, args);
162
163    // Enhance error messages with signature help
164    if !result.is_valid {
165        let help = get_signature_help(recipe);
166        let formatted_help = format_signature_help(&help);
167
168        // Add signature help to the first error
169        if let Some(first_error) = result.errors.first_mut() {
170            first_error.message = format!(
171                "{}\n\nExpected signature:\n{}",
172                first_error.message, formatted_help
173            );
174        }
175    }
176
177    result
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::Parameter;
184
185    fn create_test_recipe(name: &str, params: Vec<Parameter>) -> Recipe {
186        Recipe {
187            name: name.to_string(),
188            parameters: params,
189            documentation: Some(format!("Test recipe {}", name)),
190            body: String::new(),
191            dependencies: Vec::new(),
192        }
193    }
194
195    #[test]
196    fn test_validate_arguments_success() {
197        let params = vec![
198            Parameter {
199                name: "env".to_string(),
200                default_value: None,
201            },
202            Parameter {
203                name: "target".to_string(),
204                default_value: Some("prod".to_string()),
205            },
206        ];
207        let recipe = create_test_recipe("deploy", params);
208
209        // Valid: provide required parameter, use default for optional
210        let result = validate_arguments(&recipe, &["staging".to_string()]);
211        assert!(result.is_valid);
212        assert!(result.errors.is_empty());
213
214        // Valid: provide both parameters
215        let result = validate_arguments(&recipe, &["staging".to_string(), "dev".to_string()]);
216        assert!(result.is_valid);
217        assert!(result.errors.is_empty());
218    }
219
220    #[test]
221    fn test_validate_arguments_missing_required() {
222        let params = vec![
223            Parameter {
224                name: "env".to_string(),
225                default_value: None,
226            },
227            Parameter {
228                name: "target".to_string(),
229                default_value: Some("prod".to_string()),
230            },
231        ];
232        let recipe = create_test_recipe("deploy", params);
233
234        let result = validate_arguments(&recipe, &[]);
235        assert!(!result.is_valid);
236        assert_eq!(result.errors.len(), 1);
237        assert_eq!(result.errors[0].parameter, "env");
238        assert!(
239            result.errors[0]
240                .message
241                .contains("Missing required parameter")
242        );
243    }
244
245    #[test]
246    fn test_validate_arguments_too_many() {
247        let params = vec![Parameter {
248            name: "env".to_string(),
249            default_value: None,
250        }];
251        let recipe = create_test_recipe("deploy", params);
252
253        let result = validate_arguments(&recipe, &["staging".to_string(), "extra".to_string()]);
254        assert!(!result.is_valid);
255        assert_eq!(result.errors.len(), 1);
256        assert_eq!(result.errors[0].parameter, "<extra>");
257        assert!(result.errors[0].message.contains("Too many arguments"));
258    }
259
260    #[test]
261    fn test_validate_arguments_no_parameters() {
262        let recipe = create_test_recipe("build", vec![]);
263
264        // Valid: no args for no params
265        let result = validate_arguments(&recipe, &[]);
266        assert!(result.is_valid);
267
268        // Invalid: args for no params
269        let result = validate_arguments(&recipe, &["unexpected".to_string()]);
270        assert!(!result.is_valid);
271    }
272
273    #[test]
274    fn test_get_signature_help() {
275        let params = vec![
276            Parameter {
277                name: "env".to_string(),
278                default_value: None,
279            },
280            Parameter {
281                name: "target".to_string(),
282                default_value: Some("prod".to_string()),
283            },
284            Parameter {
285                name: "verbose".to_string(),
286                default_value: Some("false".to_string()),
287            },
288        ];
289        let recipe = create_test_recipe("deploy", params);
290
291        let help = get_signature_help(&recipe);
292
293        assert_eq!(help.recipe_name, "deploy");
294        assert_eq!(help.parameters.len(), 3);
295        assert_eq!(help.documentation, Some("Test recipe deploy".to_string()));
296
297        // Check parameter info
298        assert_eq!(help.parameters[0].name, "env");
299        assert!(help.parameters[0].required);
300        assert_eq!(help.parameters[0].default_value, None);
301
302        assert_eq!(help.parameters[1].name, "target");
303        assert!(!help.parameters[1].required);
304        assert_eq!(help.parameters[1].default_value, Some("prod".to_string()));
305
306        assert_eq!(help.parameters[2].name, "verbose");
307        assert!(!help.parameters[2].required);
308        assert_eq!(help.parameters[2].default_value, Some("false".to_string()));
309    }
310
311    #[test]
312    fn test_format_signature_help() {
313        let params = vec![
314            Parameter {
315                name: "env".to_string(),
316                default_value: None,
317            },
318            Parameter {
319                name: "target".to_string(),
320                default_value: Some("prod".to_string()),
321            },
322        ];
323        let recipe = create_test_recipe("deploy", params);
324        let help = get_signature_help(&recipe);
325
326        let formatted = format_signature_help(&help);
327
328        assert!(formatted.contains("deploy(env, target=prod)"));
329        assert!(formatted.contains("Test recipe deploy"));
330        assert!(formatted.contains("env (required)"));
331        assert!(formatted.contains("target (optional, default: prod)"));
332    }
333
334    #[test]
335    fn test_validate_with_help() {
336        let params = vec![Parameter {
337            name: "env".to_string(),
338            default_value: None,
339        }];
340        let recipe = create_test_recipe("deploy", params);
341
342        let result = validate_with_help(&recipe, &[]);
343        assert!(!result.is_valid);
344        assert_eq!(result.errors.len(), 1);
345        assert!(
346            result.errors[0]
347                .message
348                .contains("Missing required parameter")
349        );
350        assert!(result.errors[0].message.contains("Expected signature"));
351        assert!(result.errors[0].message.contains("deploy(env)"));
352    }
353}