diff --git a/server/src/http/mcp/story_tools/criteria.rs b/server/src/http/mcp/story_tools/criteria.rs index 7c10a20e..c2e85f49 100644 --- a/server/src/http/mcp/story_tools/criteria.rs +++ b/server/src/http/mcp/story_tools/criteria.rs @@ -15,6 +15,7 @@ use crate::http::workflow::{ use crate::io::story_metadata::parse_unchecked_todos; use crate::service::story::parse_test_cases; use crate::slog_warn; +use crate::validation::{AddCriterionRequest, EditCriterionRequest}; #[allow(unused_imports)] use crate::workflow::{ TestCaseResult, TestStatus, WorkflowState, evaluate_acceptance_with_coverage, @@ -268,13 +269,10 @@ pub(crate) fn tool_edit_criterion(args: &Value, ctx: &AppContext) -> Result Result Result { @@ -10,23 +11,20 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result().ok()); @@ -46,11 +44,10 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result { - let s = value.as_str().filter(|s| !s.trim().is_empty()); - if s.is_none() { - return Err("name must not be empty".to_string()); - } - if !crate::crdt_state::set_name(story_id, s) { + let raw = value.as_str().unwrap_or(""); + let validated = crate::validation::StoryName::parse(raw) + .map_err(|errs| crate::validation::format_errors_as_json(&errs))?; + if !crate::crdt_state::set_name(story_id, Some(validated.as_ref())) { return Err(format!( "Story '{story_id}' not found in CRDT — name was not updated." )); @@ -188,8 +185,13 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result, + /// Validated user-story text, if provided. + pub user_story: Option, + /// Validated background description, if provided. + pub description: Option, +} + +impl UpdateStoryRequest { + /// Parse and validate the text fields from an `update_story` JSON argument map. + pub fn from_json(args: &Value) -> Result { + let mut errors: Vec = Vec::new(); + + // name (optional) + let name = match args.get("name").and_then(|v| v.as_str()) { + None => None, + Some(raw) => match StoryName::parse(raw) { + Ok(n) => Some(n), + Err(mut errs) => { + errors.append(&mut errs); + None + } + }, + }; + + // user_story (optional) + let user_story = match args.get("user_story").and_then(|v| v.as_str()) { + None => None, + Some(raw) => match Description::parse("user_story", raw) { + Ok(d) => Some(d), + Err(mut errs) => { + errors.append(&mut errs); + None + } + }, + }; + + // description (optional) + let description = match args.get("description").and_then(|v| v.as_str()) { + None => None, + Some(raw) => match Description::parse("description", raw) { + Ok(d) => Some(d), + Err(mut errs) => { + errors.append(&mut errs); + None + } + }, + }; + + if !errors.is_empty() { + return Err(format_errors_as_json(&errors)); + } + + Ok(UpdateStoryRequest { + name, + user_story, + description, + }) + } +} + +// --------------------------------------------------------------------------- +// AddCriterionRequest +// --------------------------------------------------------------------------- + +/// Fully validated inputs for the `add_criterion` MCP tool. +#[derive(Debug)] +pub struct AddCriterionRequest { + /// The validated acceptance criterion text to add. + pub criterion: AcceptanceCriterion, +} + +impl AddCriterionRequest { + /// Parse and validate an `add_criterion` JSON argument map. + pub fn from_json(args: &Value) -> Result { + let mut errors: Vec = Vec::new(); + + let criterion = match args.get("criterion").and_then(|v| v.as_str()) { + None => { + errors.push(ValidationError::FieldMissing { + field: "criterion".into(), + }); + None + } + Some(raw) => match AcceptanceCriterion::parse("criterion", raw) { + Ok(ac) => Some(ac), + Err(mut errs) => { + errors.append(&mut errs); + None + } + }, + }; + + if !errors.is_empty() { + return Err(format_errors_as_json(&errors)); + } + + Ok(AddCriterionRequest { + criterion: criterion.unwrap(), + }) + } +} + +// --------------------------------------------------------------------------- +// EditCriterionRequest +// --------------------------------------------------------------------------- + +/// Fully validated inputs for the `edit_criterion` MCP tool. +#[derive(Debug)] +pub struct EditCriterionRequest { + /// The validated replacement text for the criterion. + pub new_text: AcceptanceCriterion, +} + +impl EditCriterionRequest { + /// Parse and validate an `edit_criterion` JSON argument map. + pub fn from_json(args: &Value) -> Result { + let mut errors: Vec = Vec::new(); + + let new_text = match args.get("new_text").and_then(|v| v.as_str()) { + None => { + errors.push(ValidationError::FieldMissing { + field: "new_text".into(), + }); + None + } + Some(raw) => match AcceptanceCriterion::parse("new_text", raw) { + Ok(ac) => Some(ac), + Err(mut errs) => { + errors.append(&mut errs); + None + } + }, + }; + + if !errors.is_empty() { + return Err(format_errors_as_json(&errors)); + } + + Ok(EditCriterionRequest { + new_text: new_text.unwrap(), + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1252,4 +1407,142 @@ mod tests { let parsed: serde_json::Value = serde_json::from_str(&err).unwrap(); assert!(parsed.is_array()); } + // --- UpdateStoryRequest --- + + #[test] + fn update_story_request_all_absent_is_valid() { + let args = json!({}); + let req = UpdateStoryRequest::from_json(&args).unwrap(); + assert!(req.name.is_none()); + assert!(req.user_story.is_none()); + assert!(req.description.is_none()); + } + + #[test] + fn update_story_request_valid_name() { + let args = json!({"name": "Better Name"}); + let req = UpdateStoryRequest::from_json(&args).unwrap(); + assert_eq!(req.name.unwrap().as_ref(), "Better Name"); + } + + #[test] + fn update_story_request_empty_name_rejected() { + let args = json!({"name": " "}); + let err = UpdateStoryRequest::from_json(&args).unwrap_err(); + assert!(err.contains("EmptyAfterTrim")); + } + + #[test] + fn update_story_request_grammar_token_in_name_rejected() { + let args = json!({"name": "Bad name"}); + let err = UpdateStoryRequest::from_json(&args).unwrap_err(); + assert!(err.contains("AntiGrammarToken")); + } + + #[test] + fn update_story_request_valid_descriptions() { + let args = json!({ + "user_story": "As a user I want X", + "description": "Some background" + }); + let req = UpdateStoryRequest::from_json(&args).unwrap(); + assert_eq!(req.user_story.unwrap().as_str(), "As a user I want X"); + assert_eq!(req.description.unwrap().as_str(), "Some background"); + } + + #[test] + fn update_story_request_grammar_token_in_description_rejected() { + let args = json!({"description": "text more"}); + let err = UpdateStoryRequest::from_json(&args).unwrap_err(); + assert!(err.contains("AntiGrammarToken")); + } + + #[test] + fn update_story_request_errors_are_json() { + let args = json!({ + "name": "bad", + "description": "bad" + }); + let err = UpdateStoryRequest::from_json(&args).unwrap_err(); + let parsed: serde_json::Value = serde_json::from_str(&err).unwrap(); + assert!(parsed.is_array()); + } + + // --- AddCriterionRequest --- + + #[test] + fn add_criterion_request_valid() { + let args = json!({"criterion": "System returns 200 OK"}); + let req = AddCriterionRequest::from_json(&args).unwrap(); + assert_eq!(req.criterion.as_ref(), "System returns 200 OK"); + } + + #[test] + fn add_criterion_request_missing_criterion() { + let args = json!({}); + let err = AddCriterionRequest::from_json(&args).unwrap_err(); + assert!(err.contains("FieldMissing")); + assert!(err.contains("criterion")); + } + + #[test] + fn add_criterion_request_empty_criterion_rejected() { + let args = json!({"criterion": ""}); + let err = AddCriterionRequest::from_json(&args).unwrap_err(); + assert!(err.contains("EmptyAfterTrim")); + } + + #[test] + fn add_criterion_request_grammar_token_rejected() { + let args = json!({"criterion": "inject"}); + let err = AddCriterionRequest::from_json(&args).unwrap_err(); + assert!(err.contains("AntiGrammarToken")); + } + + #[test] + fn add_criterion_request_too_long_rejected() { + let long = "x".repeat(1001); + let args = json!({"criterion": long}); + let err = AddCriterionRequest::from_json(&args).unwrap_err(); + assert!(err.contains("FieldTooLong")); + } + + // --- EditCriterionRequest --- + + #[test] + fn edit_criterion_request_valid() { + let args = json!({"new_text": "Updated criterion text"}); + let req = EditCriterionRequest::from_json(&args).unwrap(); + assert_eq!(req.new_text.as_ref(), "Updated criterion text"); + } + + #[test] + fn edit_criterion_request_missing_new_text() { + let args = json!({}); + let err = EditCriterionRequest::from_json(&args).unwrap_err(); + assert!(err.contains("FieldMissing")); + assert!(err.contains("new_text")); + } + + #[test] + fn edit_criterion_request_empty_new_text_rejected() { + let args = json!({"new_text": " "}); + let err = EditCriterionRequest::from_json(&args).unwrap_err(); + assert!(err.contains("EmptyAfterTrim")); + } + + #[test] + fn edit_criterion_request_grammar_token_rejected() { + let args = json!({"new_text": "bad"}); + let err = EditCriterionRequest::from_json(&args).unwrap_err(); + assert!(err.contains("AntiGrammarToken")); + } + + #[test] + fn edit_criterion_request_too_long_rejected() { + let long = "y".repeat(1001); + let args = json!({"new_text": long}); + let err = EditCriterionRequest::from_json(&args).unwrap_err(); + assert!(err.contains("FieldTooLong")); + } }