//! Validated request structs for MCP write tools. //! //! Each struct is populated by `from_json`, which runs field-level validation via //! the newtypes, then cross-field rules via `garde`. Callers receive either a //! fully validated struct or a `Vec` with every problem found. use garde::Validate; use serde_json::Value; use super::error::{ValidationError, format_errors_as_json}; use super::newtypes::{ AcceptanceCriterion, DependsOnId, Description, StoryId, StoryName, TargetStage, }; // --------------------------------------------------------------------------- // Cross-field validators (used by garde derive) // --------------------------------------------------------------------------- /// Junk-only acceptance-criteria indicators — placeholders agents fill in but /// that contain no actionable requirement. const JUNK_AC_MARKERS: &[&str] = &["todo", "tbd", "fixme", "xxx", "???"]; fn validate_acceptance_criteria_nonempty(acs: &[AcceptanceCriterion], _ctx: &()) -> garde::Result { if acs.is_empty() { return Err(garde::Error::new( "acceptance_criteria must contain at least one entry", )); } let all_junk = acs.iter().all(|ac| { let lower = ac.as_ref().to_lowercase(); JUNK_AC_MARKERS.contains(&lower.trim()) }); if all_junk { return Err(garde::Error::new( "acceptance_criteria must contain at least one real entry (not just TODO/TBD/FIXME)", )); } Ok(()) } // --------------------------------------------------------------------------- // CreateStoryRequest // --------------------------------------------------------------------------- /// Fully validated inputs for the `create_story` MCP tool. #[derive(Debug, Validate)] pub struct CreateStoryRequest { /// Validated story name. #[garde(skip)] pub name: StoryName, /// Optional user story text. #[garde(skip)] pub user_story: Option, /// Optional background description. #[garde(skip)] pub description: Option, /// At least one non-junk acceptance criterion required (garde-enforced). #[garde(custom(validate_acceptance_criteria_nonempty))] pub acceptance_criteria: Vec, /// Optional list of story IDs this story depends on. #[garde(skip)] pub depends_on: Option>, } impl CreateStoryRequest { /// Parse and validate a `create_story` JSON argument map. /// /// Runs all field-level validation and cross-field garde rules in a single /// pass. Returns every error found, not just the first. pub fn from_json(args: &Value) -> Result { let mut errors: Vec = Vec::new(); // name (required) let name = match args.get("name").and_then(|v| v.as_str()) { None => { errors.push(ValidationError::FieldMissing { field: "name".into(), }); 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 } }, }; // acceptance_criteria (required) let acceptance_criteria = match args .get("acceptance_criteria") .and_then(|v| serde_json::from_value::>(v.clone()).ok()) { None => { errors.push(ValidationError::FieldMissing { field: "acceptance_criteria".into(), }); None } Some(raw_acs) => { let mut parsed = Vec::new(); for (i, raw) in raw_acs.iter().enumerate() { let field = format!("acceptance_criteria[{i}]"); match AcceptanceCriterion::parse(&field, raw) { Ok(ac) => parsed.push(ac), Err(mut errs) => errors.append(&mut errs), } } Some(parsed) } }; // depends_on (optional) let depends_on: Option> = match args.get("depends_on").and_then(|v| v.as_array()) { None => None, Some(arr) => { let mut ids = Vec::new(); for (i, val) in arr.iter().enumerate() { let field = format!("depends_on[{i}]"); match val.as_u64().map(|n| n as u32) { None => errors.push(ValidationError::InvalidUtf8 { field: field.clone(), }), Some(id) => match DependsOnId::parse(&field, id) { Ok(d) => ids.push(d), Err(mut errs) => errors.append(&mut errs), }, } } Some(ids) } }; if !errors.is_empty() { return Err(format_errors_as_json(&errors)); } let req = CreateStoryRequest { name: name.unwrap(), user_story, description, acceptance_criteria: acceptance_criteria.unwrap(), depends_on, }; // Cross-field garde validation if let Err(report) = req.validate_with(&()) { for (_, _field_error) in report.iter() { // Map garde errors back to typed ValidationError. // The only garde rule here is the AC nonempty/junk check. let actual = req.acceptance_criteria.len(); let all_junk = req.acceptance_criteria.iter().all(|ac| { let lower = ac.as_ref().to_lowercase(); JUNK_AC_MARKERS.contains(&lower.trim()) }); if all_junk && actual > 0 { errors.push(ValidationError::TooFewItems { field: "acceptance_criteria".into(), min: 1, // Semantic "0 real entries" actual: 0, }); } else { errors.push(ValidationError::TooFewItems { field: "acceptance_criteria".into(), min: 1, actual, }); } } return Err(format_errors_as_json(&errors)); } Ok(req) } /// Extract validated `depends_on` as a plain `Vec` for downstream use. pub fn depends_on_ids(&self) -> Option> { self.depends_on .as_ref() .map(|ids| ids.iter().map(|d| d.get()).collect()) } } // --------------------------------------------------------------------------- // CreateEpicRequest // --------------------------------------------------------------------------- /// Fully validated inputs for the `create_epic` MCP tool. #[derive(Debug, Validate)] pub struct CreateEpicRequest { /// Validated epic name. #[garde(skip)] pub name: StoryName, /// Validated goal statement. #[garde(skip)] pub goal: Description, /// Optional motivation text. #[garde(skip)] pub motivation: Option, /// Optional key files text (plain string, minimal validation). #[garde(skip)] pub key_files: Option, /// Optional success criteria list. #[garde(skip)] pub success_criteria: Option>, } impl CreateEpicRequest { /// Parse and validate a `create_epic` JSON argument map. pub fn from_json(args: &Value) -> Result { let mut errors: Vec = Vec::new(); // name (required) let name = match args.get("name").and_then(|v| v.as_str()) { None => { errors.push(ValidationError::FieldMissing { field: "name".into(), }); None } Some(raw) => match StoryName::parse(raw) { Ok(n) => Some(n), Err(mut errs) => { errors.append(&mut errs); None } }, }; // goal (required) let goal = match args.get("goal").and_then(|v| v.as_str()) { None => { errors.push(ValidationError::FieldMissing { field: "goal".into(), }); None } Some(raw) => match Description::parse("goal", raw) { Ok(d) => Some(d), Err(mut errs) => { errors.append(&mut errs); None } }, }; // motivation (optional) let motivation = match args.get("motivation").and_then(|v| v.as_str()) { None => None, Some(raw) => match Description::parse("motivation", raw) { Ok(d) => Some(d), Err(mut errs) => { errors.append(&mut errs); None } }, }; // key_files (optional, plain string — structural markup, not user prose) let key_files = args .get("key_files") .and_then(|v| v.as_str()) .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_string); // success_criteria (optional list) let success_criteria = match args .get("success_criteria") .and_then(|v| serde_json::from_value::>(v.clone()).ok()) { None => None, Some(raw_sc) => { let mut parsed = Vec::new(); for (i, raw) in raw_sc.iter().enumerate() { let field = format!("success_criteria[{i}]"); match AcceptanceCriterion::parse(&field, raw) { Ok(ac) => parsed.push(ac), Err(mut errs) => errors.append(&mut errs), } } Some(parsed) } }; if !errors.is_empty() { return Err(format_errors_as_json(&errors)); } Ok(CreateEpicRequest { name: name.unwrap(), goal: goal.unwrap(), motivation, key_files, success_criteria, }) } /// Extract success criteria as plain strings for downstream use. pub fn success_criteria_strings(&self) -> Vec { self.success_criteria .as_ref() .map(|sc| sc.iter().map(|c| c.as_ref().to_string()).collect()) .unwrap_or_default() } } // --------------------------------------------------------------------------- // CreateBugRequest // --------------------------------------------------------------------------- /// Fully validated inputs for the `create_bug` MCP tool. #[derive(Debug, Validate)] pub struct CreateBugRequest { /// Validated bug name. #[garde(skip)] pub name: StoryName, /// Required description of the bug. #[garde(skip)] pub description: Description, /// Steps needed to reproduce the bug. #[garde(skip)] pub steps_to_reproduce: Description, /// What actually happens. #[garde(skip)] pub actual_result: Description, /// What should happen. #[garde(skip)] pub expected_result: Description, /// At least one non-junk acceptance criterion required. #[garde(custom(validate_acceptance_criteria_nonempty))] pub acceptance_criteria: Vec, /// Optional list of story IDs this bug depends on. #[garde(skip)] pub depends_on: Option>, } impl CreateBugRequest { /// Parse and validate a `create_bug` JSON argument map. pub fn from_json(args: &Value) -> Result { let mut errors: Vec = Vec::new(); let name = match args.get("name").and_then(|v| v.as_str()) { None => { errors.push(ValidationError::FieldMissing { field: "name".into(), }); None } Some(raw) => match StoryName::parse(raw) { Ok(n) => Some(n), Err(mut errs) => { errors.append(&mut errs); None } }, }; let description = match args.get("description").and_then(|v| v.as_str()) { None => { errors.push(ValidationError::FieldMissing { field: "description".into(), }); None } Some(raw) => match Description::parse("description", raw) { Ok(d) => Some(d), Err(mut errs) => { errors.append(&mut errs); None } }, }; let steps_to_reproduce = match args.get("steps_to_reproduce").and_then(|v| v.as_str()) { None => { errors.push(ValidationError::FieldMissing { field: "steps_to_reproduce".into(), }); None } Some(raw) => match Description::parse("steps_to_reproduce", raw) { Ok(d) => Some(d), Err(mut errs) => { errors.append(&mut errs); None } }, }; let actual_result = match args.get("actual_result").and_then(|v| v.as_str()) { None => { errors.push(ValidationError::FieldMissing { field: "actual_result".into(), }); None } Some(raw) => match Description::parse("actual_result", raw) { Ok(d) => Some(d), Err(mut errs) => { errors.append(&mut errs); None } }, }; let expected_result = match args.get("expected_result").and_then(|v| v.as_str()) { None => { errors.push(ValidationError::FieldMissing { field: "expected_result".into(), }); None } Some(raw) => match Description::parse("expected_result", raw) { Ok(d) => Some(d), Err(mut errs) => { errors.append(&mut errs); None } }, }; let acceptance_criteria = match args .get("acceptance_criteria") .and_then(|v| serde_json::from_value::>(v.clone()).ok()) { None => { errors.push(ValidationError::FieldMissing { field: "acceptance_criteria".into(), }); None } Some(raw_acs) => { let mut parsed = Vec::new(); for (i, raw) in raw_acs.iter().enumerate() { let field = format!("acceptance_criteria[{i}]"); match AcceptanceCriterion::parse(&field, raw) { Ok(ac) => parsed.push(ac), Err(mut errs) => errors.append(&mut errs), } } Some(parsed) } }; let depends_on: Option> = match args.get("depends_on").and_then(|v| v.as_array()) { None => None, Some(arr) => { let mut ids = Vec::new(); for (i, val) in arr.iter().enumerate() { let field = format!("depends_on[{i}]"); match val.as_u64().map(|n| n as u32) { None => errors.push(ValidationError::InvalidUtf8 { field: field.clone(), }), Some(id) => match DependsOnId::parse(&field, id) { Ok(d) => ids.push(d), Err(mut errs) => errors.append(&mut errs), }, } } Some(ids) } }; if !errors.is_empty() { return Err(format_errors_as_json(&errors)); } let req = CreateBugRequest { name: name.unwrap(), description: description.unwrap(), steps_to_reproduce: steps_to_reproduce.unwrap(), actual_result: actual_result.unwrap(), expected_result: expected_result.unwrap(), acceptance_criteria: acceptance_criteria.unwrap(), depends_on, }; if let Err(report) = req.validate_with(&()) { for (_, _) in report.iter() { let actual = req.acceptance_criteria.len(); let all_junk = req.acceptance_criteria.iter().all(|ac| { let lower = ac.as_ref().to_lowercase(); JUNK_AC_MARKERS.contains(&lower.trim()) }); if all_junk && actual > 0 { errors.push(ValidationError::TooFewItems { field: "acceptance_criteria".into(), min: 1, actual: 0, }); } else { errors.push(ValidationError::TooFewItems { field: "acceptance_criteria".into(), min: 1, actual, }); } } return Err(format_errors_as_json(&errors)); } Ok(req) } /// Extract validated `depends_on` as a plain `Vec` for downstream use. pub fn depends_on_ids(&self) -> Option> { self.depends_on .as_ref() .map(|ids| ids.iter().map(|d| d.get()).collect()) } /// Extract acceptance criteria as plain strings for downstream use. pub fn acceptance_criteria_strings(&self) -> Vec { self.acceptance_criteria .iter() .map(|ac| ac.as_ref().to_string()) .collect() } } // --------------------------------------------------------------------------- // CreateRefactorRequest // --------------------------------------------------------------------------- /// Fully validated inputs for the `create_refactor` MCP tool. #[derive(Debug, Validate)] pub struct CreateRefactorRequest { /// Validated refactor name. #[garde(skip)] pub name: StoryName, /// Optional background description. #[garde(skip)] pub description: Option, /// At least one non-junk acceptance criterion required. #[garde(custom(validate_acceptance_criteria_nonempty))] pub acceptance_criteria: Vec, /// Optional list of story IDs this refactor depends on. #[garde(skip)] pub depends_on: Option>, } impl CreateRefactorRequest { /// Parse and validate a `create_refactor` JSON argument map. pub fn from_json(args: &Value) -> Result { let mut errors: Vec = Vec::new(); let name = match args.get("name").and_then(|v| v.as_str()) { None => { errors.push(ValidationError::FieldMissing { field: "name".into(), }); None } Some(raw) => match StoryName::parse(raw) { Ok(n) => Some(n), Err(mut errs) => { errors.append(&mut errs); None } }, }; 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 } }, }; let acceptance_criteria = match args .get("acceptance_criteria") .and_then(|v| serde_json::from_value::>(v.clone()).ok()) { None => { errors.push(ValidationError::FieldMissing { field: "acceptance_criteria".into(), }); None } Some(raw_acs) => { let mut parsed = Vec::new(); for (i, raw) in raw_acs.iter().enumerate() { let field = format!("acceptance_criteria[{i}]"); match AcceptanceCriterion::parse(&field, raw) { Ok(ac) => parsed.push(ac), Err(mut errs) => errors.append(&mut errs), } } Some(parsed) } }; let depends_on: Option> = match args.get("depends_on").and_then(|v| v.as_array()) { None => None, Some(arr) => { let mut ids = Vec::new(); for (i, val) in arr.iter().enumerate() { let field = format!("depends_on[{i}]"); match val.as_u64().map(|n| n as u32) { None => errors.push(ValidationError::InvalidUtf8 { field: field.clone(), }), Some(id) => match DependsOnId::parse(&field, id) { Ok(d) => ids.push(d), Err(mut errs) => errors.append(&mut errs), }, } } Some(ids) } }; if !errors.is_empty() { return Err(format_errors_as_json(&errors)); } let req = CreateRefactorRequest { name: name.unwrap(), description, acceptance_criteria: acceptance_criteria.unwrap(), depends_on, }; if let Err(report) = req.validate_with(&()) { for (_, _) in report.iter() { let actual = req.acceptance_criteria.len(); let all_junk = req.acceptance_criteria.iter().all(|ac| { let lower = ac.as_ref().to_lowercase(); JUNK_AC_MARKERS.contains(&lower.trim()) }); if all_junk && actual > 0 { errors.push(ValidationError::TooFewItems { field: "acceptance_criteria".into(), min: 1, actual: 0, }); } else { errors.push(ValidationError::TooFewItems { field: "acceptance_criteria".into(), min: 1, actual, }); } } return Err(format_errors_as_json(&errors)); } Ok(req) } /// Extract validated `depends_on` as a plain `Vec` for downstream use. pub fn depends_on_ids(&self) -> Option> { self.depends_on .as_ref() .map(|ids| ids.iter().map(|d| d.get()).collect()) } /// Extract acceptance criteria as plain strings for downstream use. pub fn acceptance_criteria_strings(&self) -> Vec { self.acceptance_criteria .iter() .map(|ac| ac.as_ref().to_string()) .collect() } } // --------------------------------------------------------------------------- // CreateSpikeRequest // --------------------------------------------------------------------------- /// Fully validated inputs for the `create_spike` MCP tool. #[derive(Debug, Validate)] pub struct CreateSpikeRequest { /// Validated spike name. #[garde(skip)] pub name: StoryName, /// Optional background description. #[garde(skip)] pub description: Option, /// At least one non-junk acceptance criterion required. #[garde(custom(validate_acceptance_criteria_nonempty))] pub acceptance_criteria: Vec, /// Optional list of story IDs this spike depends on. #[garde(skip)] pub depends_on: Option>, } impl CreateSpikeRequest { /// Parse and validate a `create_spike` JSON argument map. pub fn from_json(args: &Value) -> Result { let mut errors: Vec = Vec::new(); let name = match args.get("name").and_then(|v| v.as_str()) { None => { errors.push(ValidationError::FieldMissing { field: "name".into(), }); None } Some(raw) => match StoryName::parse(raw) { Ok(n) => Some(n), Err(mut errs) => { errors.append(&mut errs); None } }, }; 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 } }, }; let acceptance_criteria = match args .get("acceptance_criteria") .and_then(|v| serde_json::from_value::>(v.clone()).ok()) { None => { errors.push(ValidationError::FieldMissing { field: "acceptance_criteria".into(), }); None } Some(raw_acs) => { let mut parsed = Vec::new(); for (i, raw) in raw_acs.iter().enumerate() { let field = format!("acceptance_criteria[{i}]"); match AcceptanceCriterion::parse(&field, raw) { Ok(ac) => parsed.push(ac), Err(mut errs) => errors.append(&mut errs), } } Some(parsed) } }; let depends_on: Option> = match args.get("depends_on").and_then(|v| v.as_array()) { None => None, Some(arr) => { let mut ids = Vec::new(); for (i, val) in arr.iter().enumerate() { let field = format!("depends_on[{i}]"); match val.as_u64().map(|n| n as u32) { None => errors.push(ValidationError::InvalidUtf8 { field: field.clone(), }), Some(id) => match DependsOnId::parse(&field, id) { Ok(d) => ids.push(d), Err(mut errs) => errors.append(&mut errs), }, } } Some(ids) } }; if !errors.is_empty() { return Err(format_errors_as_json(&errors)); } let req = CreateSpikeRequest { name: name.unwrap(), description, acceptance_criteria: acceptance_criteria.unwrap(), depends_on, }; if let Err(report) = req.validate_with(&()) { for (_, _) in report.iter() { let actual = req.acceptance_criteria.len(); let all_junk = req.acceptance_criteria.iter().all(|ac| { let lower = ac.as_ref().to_lowercase(); JUNK_AC_MARKERS.contains(&lower.trim()) }); if all_junk && actual > 0 { errors.push(ValidationError::TooFewItems { field: "acceptance_criteria".into(), min: 1, actual: 0, }); } else { errors.push(ValidationError::TooFewItems { field: "acceptance_criteria".into(), min: 1, actual, }); } } return Err(format_errors_as_json(&errors)); } Ok(req) } /// Extract validated `depends_on` as a plain `Vec` for downstream use. pub fn depends_on_ids(&self) -> Option> { self.depends_on .as_ref() .map(|ids| ids.iter().map(|d| d.get()).collect()) } /// Extract acceptance criteria as plain strings for downstream use. pub fn acceptance_criteria_strings(&self) -> Vec { self.acceptance_criteria .iter() .map(|ac| ac.as_ref().to_string()) .collect() } } // --------------------------------------------------------------------------- // UpdateStoryRequest // --------------------------------------------------------------------------- /// Fully validated inputs for the `update_story` MCP tool (text fields only). /// /// All fields are optional; callers that supply none receive an empty-but-valid /// struct. The `front_matter` routing and CRDT wiring live in the tool handler. #[derive(Debug)] pub struct UpdateStoryRequest { /// Validated new story name, if provided. pub name: Option, /// 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(), }) } } // --------------------------------------------------------------------------- // MoveStoryRequest // --------------------------------------------------------------------------- /// Fully validated inputs for the `move_story` MCP tool. #[derive(Debug)] pub struct MoveStoryRequest { /// Validated story identifier. pub story_id: StoryId, /// Validated target pipeline stage. pub target_stage: TargetStage, } impl MoveStoryRequest { /// Parse and validate a `move_story` JSON argument map. pub fn from_json(args: &serde_json::Value) -> Result { let mut errors: Vec = Vec::new(); let story_id = match args.get("story_id").and_then(|v| v.as_str()) { None => { errors.push(ValidationError::FieldMissing { field: "story_id".into(), }); None } Some(raw) => match StoryId::parse(raw) { Ok(id) => Some(id), Err(mut errs) => { errors.append(&mut errs); None } }, }; let target_stage = match args.get("target_stage").and_then(|v| v.as_str()) { None => { errors.push(ValidationError::FieldMissing { field: "target_stage".into(), }); None } Some(raw) => match TargetStage::parse(raw) { Ok(s) => Some(s), Err(mut errs) => { errors.append(&mut errs); None } }, }; if !errors.is_empty() { return Err(format_errors_as_json(&errors)); } Ok(MoveStoryRequest { story_id: story_id.unwrap(), target_stage: target_stage.unwrap(), }) } } // --------------------------------------------------------------------------- // MoveStoryToMergeRequest // --------------------------------------------------------------------------- /// Fully validated inputs for the `move_story_to_merge` MCP tool. #[derive(Debug)] pub struct MoveStoryToMergeRequest { /// Validated story identifier. pub story_id: StoryId, /// Optional agent name override; defaults to `"mergemaster"` if absent. pub agent_name: Option, } impl MoveStoryToMergeRequest { /// Parse and validate a `move_story_to_merge` JSON argument map. pub fn from_json(args: &serde_json::Value) -> Result { let mut errors: Vec = Vec::new(); let story_id = match args.get("story_id").and_then(|v| v.as_str()) { None => { errors.push(ValidationError::FieldMissing { field: "story_id".into(), }); None } Some(raw) => match StoryId::parse(raw) { Ok(id) => Some(id), Err(mut errs) => { errors.append(&mut errs); None } }, }; let agent_name = match args.get("agent_name").and_then(|v| v.as_str()) { None => None, Some(raw) => { let trimmed = raw.trim(); if trimmed.is_empty() { errors.push(ValidationError::EmptyAfterTrim { field: "agent_name".into(), }); None } else { Some(trimmed.to_string()) } } }; if !errors.is_empty() { return Err(format_errors_as_json(&errors)); } Ok(MoveStoryToMergeRequest { story_id: story_id.unwrap(), agent_name, }) } /// Return the resolved agent name, defaulting to `"mergemaster"`. pub fn resolved_agent_name(&self) -> &str { self.agent_name.as_deref().unwrap_or("mergemaster") } } // --------------------------------------------------------------------------- // UnblockStoryRequest // --------------------------------------------------------------------------- /// Fully validated inputs for the `unblock_story` MCP tool. #[derive(Debug)] pub struct UnblockStoryRequest { /// Validated story identifier. pub story_id: StoryId, } impl UnblockStoryRequest { /// Parse and validate an `unblock_story` JSON argument map. pub fn from_json(args: &serde_json::Value) -> Result { let mut errors: Vec = Vec::new(); let story_id = match args.get("story_id").and_then(|v| v.as_str()) { None => { errors.push(ValidationError::FieldMissing { field: "story_id".into(), }); None } Some(raw) => match StoryId::parse(raw) { Ok(id) => Some(id), Err(mut errs) => { errors.append(&mut errs); None } }, }; if !errors.is_empty() { return Err(format_errors_as_json(&errors)); } Ok(UnblockStoryRequest { story_id: story_id.unwrap(), }) } } // --------------------------------------------------------------------------- // FreezeStoryRequest // --------------------------------------------------------------------------- /// Fully validated inputs for the `freeze_story` MCP tool. #[derive(Debug)] pub struct FreezeStoryRequest { /// Validated story identifier. pub story_id: StoryId, } impl FreezeStoryRequest { /// Parse and validate a `freeze_story` JSON argument map. pub fn from_json(args: &serde_json::Value) -> Result { let mut errors: Vec = Vec::new(); let story_id = match args.get("story_id").and_then(|v| v.as_str()) { None => { errors.push(ValidationError::FieldMissing { field: "story_id".into(), }); None } Some(raw) => match StoryId::parse(raw) { Ok(id) => Some(id), Err(mut errs) => { errors.append(&mut errs); None } }, }; if !errors.is_empty() { return Err(format_errors_as_json(&errors)); } Ok(FreezeStoryRequest { story_id: story_id.unwrap(), }) } } #[cfg(test)] mod tests { use super::*; use serde_json::json; // --- CreateStoryRequest --- #[test] fn create_story_request_valid_minimal() { let args = json!({ "name": "My Story", "acceptance_criteria": ["It works"] }); let req = CreateStoryRequest::from_json(&args).unwrap(); assert_eq!(req.name.as_ref(), "My Story"); assert_eq!(req.acceptance_criteria.len(), 1); } #[test] fn create_story_request_missing_name() { let args = json!({"acceptance_criteria": ["AC1"]}); let err = CreateStoryRequest::from_json(&args).unwrap_err(); assert!(err.contains("FieldMissing")); assert!(err.contains("name")); } #[test] fn create_story_request_missing_acs() { let args = json!({"name": "My Story"}); let err = CreateStoryRequest::from_json(&args).unwrap_err(); assert!(err.contains("FieldMissing")); assert!(err.contains("acceptance_criteria")); } #[test] fn create_story_request_empty_acs() { let args = json!({"name": "My Story", "acceptance_criteria": []}); let err = CreateStoryRequest::from_json(&args).unwrap_err(); assert!(err.contains("TooFewItems")); } #[test] fn create_story_request_all_junk_acs() { let args = json!({"name": "My Story", "acceptance_criteria": ["TODO", "TBD"]}); let err = CreateStoryRequest::from_json(&args).unwrap_err(); assert!(err.contains("TooFewItems")); } #[test] fn create_story_request_mixed_junk_and_real() { let args = json!({ "name": "My Story", "acceptance_criteria": ["TODO", "Real criterion"] }); let req = CreateStoryRequest::from_json(&args).unwrap(); assert_eq!(req.acceptance_criteria.len(), 2); } #[test] fn create_story_request_grammar_token_in_name() { let args = json!({ "name": "Story bad", "acceptance_criteria": ["AC1"] }); let err = CreateStoryRequest::from_json(&args).unwrap_err(); assert!(err.contains("AntiGrammarToken")); } #[test] fn create_story_request_grammar_token_in_ac() { let args = json!({ "name": "Valid Name", "acceptance_criteria": ["bad"] }); let err = CreateStoryRequest::from_json(&args).unwrap_err(); assert!(err.contains("AntiGrammarToken")); } #[test] fn create_story_request_with_all_optional_fields() { let args = json!({ "name": "Full Story", "user_story": "As a user I want this", "description": "Background context", "acceptance_criteria": ["AC1", "AC2"], "depends_on": [1, 2, 3] }); let req = CreateStoryRequest::from_json(&args).unwrap(); assert_eq!(req.depends_on_ids(), Some(vec![1, 2, 3])); } #[test] fn create_story_request_errors_contain_json() { let args = json!({ "name": "bad", "acceptance_criteria": [] }); let err = CreateStoryRequest::from_json(&args).unwrap_err(); // Errors are JSON, parseable let parsed: serde_json::Value = serde_json::from_str(&err).unwrap(); assert!(parsed.is_array()); } // --- CreateEpicRequest --- #[test] fn create_epic_request_valid_minimal() { let args = json!({ "name": "My Epic", "goal": "Achieve something great" }); let req = CreateEpicRequest::from_json(&args).unwrap(); assert_eq!(req.name.as_ref(), "My Epic"); assert_eq!(req.goal.as_str(), "Achieve something great"); } #[test] fn create_epic_request_missing_name() { let args = json!({"goal": "some goal"}); let err = CreateEpicRequest::from_json(&args).unwrap_err(); assert!(err.contains("FieldMissing")); assert!(err.contains("name")); } #[test] fn create_epic_request_missing_goal() { let args = json!({"name": "Epic"}); let err = CreateEpicRequest::from_json(&args).unwrap_err(); assert!(err.contains("FieldMissing")); assert!(err.contains("goal")); } #[test] fn create_epic_request_with_success_criteria() { let args = json!({ "name": "My Epic", "goal": "Achieve world peace", "success_criteria": ["All wars end", "People prosper"] }); let req = CreateEpicRequest::from_json(&args).unwrap(); let sc = req.success_criteria_strings(); assert_eq!(sc.len(), 2); } // --- CreateBugRequest --- #[test] fn create_bug_request_valid_minimal() { let args = json!({ "name": "Login crash", "description": "App crashes on login", "steps_to_reproduce": "1. Open app 2. Click login", "actual_result": "500 error", "expected_result": "Successful login", "acceptance_criteria": ["Login succeeds"] }); let req = CreateBugRequest::from_json(&args).unwrap(); assert_eq!(req.name.as_ref(), "Login crash"); assert_eq!(req.acceptance_criteria.len(), 1); assert_eq!(req.acceptance_criteria_strings(), vec!["Login succeeds"]); } #[test] fn create_bug_request_missing_name() { let args = json!({ "description": "d", "steps_to_reproduce": "s", "actual_result": "a", "expected_result": "e", "acceptance_criteria": ["AC"] }); let err = CreateBugRequest::from_json(&args).unwrap_err(); assert!(err.contains("FieldMissing")); assert!(err.contains("name")); } #[test] fn create_bug_request_missing_description() { let args = json!({ "name": "Bug", "steps_to_reproduce": "s", "actual_result": "a", "expected_result": "e", "acceptance_criteria": ["AC"] }); let err = CreateBugRequest::from_json(&args).unwrap_err(); assert!(err.contains("FieldMissing")); assert!(err.contains("description")); } #[test] fn create_bug_request_missing_steps_to_reproduce() { let args = json!({ "name": "Bug", "description": "d", "actual_result": "a", "expected_result": "e", "acceptance_criteria": ["AC"] }); let err = CreateBugRequest::from_json(&args).unwrap_err(); assert!(err.contains("FieldMissing")); assert!(err.contains("steps_to_reproduce")); } #[test] fn create_bug_request_empty_acs() { let args = json!({ "name": "Bug", "description": "d", "steps_to_reproduce": "s", "actual_result": "a", "expected_result": "e", "acceptance_criteria": [] }); let err = CreateBugRequest::from_json(&args).unwrap_err(); assert!(err.contains("TooFewItems")); } #[test] fn create_bug_request_all_junk_acs() { let args = json!({ "name": "Bug", "description": "d", "steps_to_reproduce": "s", "actual_result": "a", "expected_result": "e", "acceptance_criteria": ["TODO", "TBD"] }); let err = CreateBugRequest::from_json(&args).unwrap_err(); assert!(err.contains("TooFewItems")); } #[test] fn create_bug_request_grammar_token_in_description() { let args = json!({ "name": "Bug", "description": "bad token", "steps_to_reproduce": "s", "actual_result": "a", "expected_result": "e", "acceptance_criteria": ["AC"] }); let err = CreateBugRequest::from_json(&args).unwrap_err(); assert!(err.contains("AntiGrammarToken")); } #[test] fn create_bug_request_with_depends_on() { let args = json!({ "name": "Bug", "description": "d", "steps_to_reproduce": "s", "actual_result": "a", "expected_result": "e", "acceptance_criteria": ["Fixed"], "depends_on": [1, 2] }); let req = CreateBugRequest::from_json(&args).unwrap(); assert_eq!(req.depends_on_ids(), Some(vec![1, 2])); } // --- CreateRefactorRequest --- #[test] fn create_refactor_request_valid_minimal() { let args = json!({ "name": "Clean up auth", "acceptance_criteria": ["Code is clean"] }); let req = CreateRefactorRequest::from_json(&args).unwrap(); assert_eq!(req.name.as_ref(), "Clean up auth"); assert!(req.description.is_none()); assert_eq!(req.acceptance_criteria_strings(), vec!["Code is clean"]); } #[test] fn create_refactor_request_missing_name() { let args = json!({"acceptance_criteria": ["AC"]}); let err = CreateRefactorRequest::from_json(&args).unwrap_err(); assert!(err.contains("FieldMissing")); assert!(err.contains("name")); } #[test] fn create_refactor_request_missing_acs() { let args = json!({"name": "Refactor"}); let err = CreateRefactorRequest::from_json(&args).unwrap_err(); assert!(err.contains("FieldMissing")); assert!(err.contains("acceptance_criteria")); } #[test] fn create_refactor_request_empty_acs() { let args = json!({"name": "Refactor", "acceptance_criteria": []}); let err = CreateRefactorRequest::from_json(&args).unwrap_err(); assert!(err.contains("TooFewItems")); } #[test] fn create_refactor_request_all_junk_acs() { let args = json!({"name": "Refactor", "acceptance_criteria": ["todo", "tbd"]}); let err = CreateRefactorRequest::from_json(&args).unwrap_err(); assert!(err.contains("TooFewItems")); } #[test] fn create_refactor_request_with_optional_description() { let args = json!({ "name": "Refactor auth", "description": "Background context", "acceptance_criteria": ["AC1"] }); let req = CreateRefactorRequest::from_json(&args).unwrap(); assert!(req.description.is_some()); } #[test] fn create_refactor_request_grammar_token_in_name() { let args = json!({ "name": "Refactor ", "acceptance_criteria": ["AC"] }); let err = CreateRefactorRequest::from_json(&args).unwrap_err(); assert!(err.contains("AntiGrammarToken")); } // --- CreateSpikeRequest --- #[test] fn create_spike_request_valid_minimal() { let args = json!({ "name": "Compare encoders", "acceptance_criteria": ["Findings documented"] }); let req = CreateSpikeRequest::from_json(&args).unwrap(); assert_eq!(req.name.as_ref(), "Compare encoders"); assert!(req.description.is_none()); assert_eq!( req.acceptance_criteria_strings(), vec!["Findings documented"] ); } #[test] fn create_spike_request_missing_name() { let args = json!({"acceptance_criteria": ["AC"]}); let err = CreateSpikeRequest::from_json(&args).unwrap_err(); assert!(err.contains("FieldMissing")); assert!(err.contains("name")); } #[test] fn create_spike_request_missing_acs() { let args = json!({"name": "Spike"}); let err = CreateSpikeRequest::from_json(&args).unwrap_err(); assert!(err.contains("FieldMissing")); assert!(err.contains("acceptance_criteria")); } #[test] fn create_spike_request_empty_acs() { let args = json!({"name": "Spike", "acceptance_criteria": []}); let err = CreateSpikeRequest::from_json(&args).unwrap_err(); assert!(err.contains("TooFewItems")); } #[test] fn create_spike_request_all_junk_acs() { let args = json!({"name": "Spike", "acceptance_criteria": ["TODO", "FIXME"]}); let err = CreateSpikeRequest::from_json(&args).unwrap_err(); assert!(err.contains("TooFewItems")); } #[test] fn create_spike_request_with_optional_description() { let args = json!({ "name": "Spike", "description": "Some context", "acceptance_criteria": ["Findings documented"] }); let req = CreateSpikeRequest::from_json(&args).unwrap(); assert!(req.description.is_some()); } #[test] fn create_spike_request_grammar_token_in_ac() { let args = json!({ "name": "Spike", "acceptance_criteria": ["bad"] }); let err = CreateSpikeRequest::from_json(&args).unwrap_err(); assert!(err.contains("AntiGrammarToken")); } #[test] fn create_spike_request_errors_are_json() { let args = json!({ "name": "bad", "acceptance_criteria": [] }); let err = CreateSpikeRequest::from_json(&args).unwrap_err(); 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")); } // --- MoveStoryRequest --- #[test] fn move_story_request_valid() { let args = json!({"story_id": "42", "target_stage": "qa"}); let req = MoveStoryRequest::from_json(&args).unwrap(); assert_eq!(req.story_id.as_str(), "42"); assert_eq!(req.target_stage.as_str(), "qa"); } #[test] fn move_story_request_valid_with_slug() { let args = json!({"story_id": "42_story_foo", "target_stage": "backlog"}); let req = MoveStoryRequest::from_json(&args).unwrap(); assert_eq!(req.story_id.as_str(), "42_story_foo"); } #[test] fn move_story_request_missing_story_id() { let args = json!({"target_stage": "qa"}); let err = MoveStoryRequest::from_json(&args).unwrap_err(); assert!(err.contains("FieldMissing")); assert!(err.contains("story_id")); } #[test] fn move_story_request_missing_target_stage() { let args = json!({"story_id": "42"}); let err = MoveStoryRequest::from_json(&args).unwrap_err(); assert!(err.contains("FieldMissing")); assert!(err.contains("target_stage")); } #[test] fn move_story_request_invalid_target_stage() { let args = json!({"story_id": "42", "target_stage": "archived"}); let err = MoveStoryRequest::from_json(&args).unwrap_err(); assert!(err.contains("InvalidValue")); assert!(err.contains("target_stage")); } #[test] fn move_story_request_invalid_story_id() { let args = json!({"story_id": "not-a-number", "target_stage": "qa"}); let err = MoveStoryRequest::from_json(&args).unwrap_err(); assert!(err.contains("InvalidCharacter")); } #[test] fn move_story_request_errors_are_json() { let args = json!({}); let err = MoveStoryRequest::from_json(&args).unwrap_err(); let parsed: serde_json::Value = serde_json::from_str(&err).unwrap(); assert!(parsed.is_array()); assert_eq!(parsed.as_array().unwrap().len(), 2); } // --- MoveStoryToMergeRequest --- #[test] fn move_story_to_merge_request_valid_minimal() { let args = json!({"story_id": "99"}); let req = MoveStoryToMergeRequest::from_json(&args).unwrap(); assert_eq!(req.story_id.as_str(), "99"); assert_eq!(req.resolved_agent_name(), "mergemaster"); } #[test] fn move_story_to_merge_request_custom_agent() { let args = json!({"story_id": "99", "agent_name": "custom-agent"}); let req = MoveStoryToMergeRequest::from_json(&args).unwrap(); assert_eq!(req.resolved_agent_name(), "custom-agent"); } #[test] fn move_story_to_merge_request_empty_agent_name() { let args = json!({"story_id": "99", "agent_name": " "}); let err = MoveStoryToMergeRequest::from_json(&args).unwrap_err(); assert!(err.contains("EmptyAfterTrim")); assert!(err.contains("agent_name")); } #[test] fn move_story_to_merge_request_missing_story_id() { let args = json!({}); let err = MoveStoryToMergeRequest::from_json(&args).unwrap_err(); assert!(err.contains("FieldMissing")); assert!(err.contains("story_id")); } // --- UnblockStoryRequest --- #[test] fn unblock_story_request_valid() { let args = json!({"story_id": "7"}); let req = UnblockStoryRequest::from_json(&args).unwrap(); assert_eq!(req.story_id.as_str(), "7"); assert_eq!(req.story_id.numeric_prefix(), "7"); } #[test] fn unblock_story_request_valid_with_slug() { let args = json!({"story_id": "100_some_story"}); let req = UnblockStoryRequest::from_json(&args).unwrap(); assert_eq!(req.story_id.numeric_prefix(), "100"); } #[test] fn unblock_story_request_missing_story_id() { let args = json!({}); let err = UnblockStoryRequest::from_json(&args).unwrap_err(); assert!(err.contains("FieldMissing")); assert!(err.contains("story_id")); } #[test] fn unblock_story_request_invalid_story_id() { let args = json!({"story_id": ""}); let err = UnblockStoryRequest::from_json(&args).unwrap_err(); assert!(err.contains("EmptyAfterTrim")); } // --- FreezeStoryRequest --- #[test] fn freeze_story_request_valid() { let args = json!({"story_id": "55_story_example"}); let req = FreezeStoryRequest::from_json(&args).unwrap(); assert_eq!(req.story_id.as_str(), "55_story_example"); } #[test] fn freeze_story_request_missing_story_id() { let args = json!({}); let err = FreezeStoryRequest::from_json(&args).unwrap_err(); assert!(err.contains("FieldMissing")); assert!(err.contains("story_id")); } #[test] fn freeze_story_request_grammar_token_in_story_id() { let args = json!({"story_id": "42"}); let err = FreezeStoryRequest::from_json(&args).unwrap_err(); assert!(err.contains("AntiGrammarToken")); } #[test] fn freeze_story_request_errors_are_json() { let args = json!({"story_id": ""}); let err = FreezeStoryRequest::from_json(&args).unwrap_err(); let parsed: serde_json::Value = serde_json::from_str(&err).unwrap(); assert!(parsed.is_array()); } }