From 95014125983013e9583964e8be57618b64480d8d Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 14 May 2026 13:22:16 +0000 Subject: [PATCH] huskies: merge 1030 --- server/src/http/mcp/story_tools/bug.rs | 63 +- server/src/http/mcp/story_tools/refactor.rs | 41 +- server/src/http/mcp/story_tools/spike.rs | 42 +- server/src/validation/mod.rs | 5 +- server/src/validation/requests.rs | 779 ++++++++++++++++++++ 5 files changed, 816 insertions(+), 114 deletions(-) diff --git a/server/src/http/mcp/story_tools/bug.rs b/server/src/http/mcp/story_tools/bug.rs index 47ef07de..8174d232 100644 --- a/server/src/http/mcp/story_tools/bug.rs +++ b/server/src/http/mcp/story_tools/bug.rs @@ -22,56 +22,19 @@ use std::collections::HashMap; use std::fs; pub(crate) fn tool_create_bug(args: &Value, ctx: &AppContext) -> Result { - let name = args - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: name")?; - let description = args - .get("description") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: description")?; - let steps_to_reproduce = args - .get("steps_to_reproduce") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: steps_to_reproduce")?; - let actual_result = args - .get("actual_result") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: actual_result")?; - let expected_result = args - .get("expected_result") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: expected_result")?; - let acceptance_criteria: Vec = args - .get("acceptance_criteria") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .ok_or("Missing required argument: acceptance_criteria")?; - if acceptance_criteria.is_empty() { - return Err("acceptance_criteria must contain at least one entry".to_string()); - } - const JUNK_AC: &[&str] = &["", "todo", "tbd", "fixme", "xxx", "???"]; - let all_junk = acceptance_criteria - .iter() - .all(|ac| JUNK_AC.contains(&ac.trim().to_lowercase().as_str())); - if all_junk { - return Err( - "acceptance_criteria must contain at least one real entry (not just TODO/TBD/FIXME/XXX/???)." - .to_string(), - ); - } - let depends_on: Option> = args - .get("depends_on") - .and_then(|v| serde_json::from_value(v.clone()).ok()); + let req = crate::validation::CreateBugRequest::from_json(args)?; + let acs = req.acceptance_criteria_strings(); + let depends_on = req.depends_on_ids(); let root = ctx.state.get_project_root()?; let bug_id = create_bug_file( &root, - name, - description, - steps_to_reproduce, - actual_result, - expected_result, - &acceptance_criteria, + req.name.as_ref(), + req.description.as_str(), + req.steps_to_reproduce.as_str(), + req.actual_result.as_str(), + req.expected_result.as_str(), + &acs, depends_on.as_deref(), )?; @@ -235,8 +198,8 @@ mod tests { ) .unwrap_err(); assert!( - err.contains("empty") || err.contains("whitespace"), - "error should mention empty/whitespace, got: {err}" + err.contains("EmptyAfterTrim"), + "error should mention EmptyAfterTrim, got: {err}" ); } @@ -416,8 +379,8 @@ mod tests { ); assert!(result.is_err()); assert!( - result.unwrap_err().contains("real entry"), - "error should mention real entry" + result.unwrap_err().contains("TooFewItems"), + "error should contain TooFewItems" ); } diff --git a/server/src/http/mcp/story_tools/refactor.rs b/server/src/http/mcp/story_tools/refactor.rs index 427a52e6..1f8e5330 100644 --- a/server/src/http/mcp/story_tools/refactor.rs +++ b/server/src/http/mcp/story_tools/refactor.rs @@ -22,38 +22,17 @@ use std::collections::HashMap; use std::fs; pub(crate) fn tool_create_refactor(args: &Value, ctx: &AppContext) -> Result { - let name = args - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: name")?; - let description = args.get("description").and_then(|v| v.as_str()); - let acceptance_criteria: Vec = args - .get("acceptance_criteria") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .ok_or("Missing required argument: acceptance_criteria")?; - if acceptance_criteria.is_empty() { - return Err("acceptance_criteria must contain at least one entry".to_string()); - } - const JUNK_AC: &[&str] = &["", "todo", "tbd", "fixme", "xxx", "???"]; - let all_junk = acceptance_criteria - .iter() - .all(|ac| JUNK_AC.contains(&ac.trim().to_lowercase().as_str())); - if all_junk { - return Err( - "acceptance_criteria must contain at least one real entry (not just TODO/TBD/FIXME/XXX/???)." - .to_string(), - ); - } - let depends_on: Option> = args - .get("depends_on") - .and_then(|v| serde_json::from_value(v.clone()).ok()); + let req = crate::validation::CreateRefactorRequest::from_json(args)?; + let acs = req.acceptance_criteria_strings(); + let description = req.description.as_ref().map(|d| d.as_str()); + let depends_on = req.depends_on_ids(); let root = ctx.state.get_project_root()?; let refactor_id = create_refactor_file( &root, - name, + req.name.as_ref(), description, - &acceptance_criteria, + &acs, depends_on.as_deref(), )?; @@ -88,8 +67,8 @@ mod tests { ) .unwrap_err(); assert!( - err.contains("empty") || err.contains("whitespace"), - "error should mention empty/whitespace, got: {err}" + err.contains("EmptyAfterTrim"), + "error should mention EmptyAfterTrim, got: {err}" ); } @@ -142,8 +121,8 @@ mod tests { ); assert!(result.is_err()); assert!( - result.unwrap_err().contains("real entry"), - "error should mention real entry" + result.unwrap_err().contains("TooFewItems"), + "error should contain TooFewItems" ); } diff --git a/server/src/http/mcp/story_tools/spike.rs b/server/src/http/mcp/story_tools/spike.rs index 5e47a5aa..871f2e53 100644 --- a/server/src/http/mcp/story_tools/spike.rs +++ b/server/src/http/mcp/story_tools/spike.rs @@ -22,39 +22,17 @@ use std::collections::HashMap; use std::fs; pub(crate) fn tool_create_spike(args: &Value, ctx: &AppContext) -> Result { - let name = args - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: name")?; - let description = args.get("description").and_then(|v| v.as_str()); - let acceptance_criteria: Vec = args - .get("acceptance_criteria") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .ok_or("Missing required argument: acceptance_criteria")?; - if acceptance_criteria.is_empty() { - return Err("acceptance_criteria must contain at least one entry".to_string()); - } - const JUNK_AC: &[&str] = &["", "todo", "tbd", "fixme", "xxx", "???"]; - let all_junk = acceptance_criteria - .iter() - .all(|ac| JUNK_AC.contains(&ac.trim().to_lowercase().as_str())); - if all_junk { - return Err( - "acceptance_criteria must contain at least one real entry (not just TODO/TBD/FIXME/XXX/???)." - .to_string(), - ); - } - - let depends_on: Option> = args - .get("depends_on") - .and_then(|v| serde_json::from_value(v.clone()).ok()); + let req = crate::validation::CreateSpikeRequest::from_json(args)?; + let acs = req.acceptance_criteria_strings(); + let description = req.description.as_ref().map(|d| d.as_str()); + let depends_on = req.depends_on_ids(); let root = ctx.state.get_project_root()?; let spike_id = create_spike_file( &root, - name, + req.name.as_ref(), description, - &acceptance_criteria, + &acs, depends_on.as_deref(), )?; @@ -113,8 +91,8 @@ mod tests { ) .unwrap_err(); assert!( - err.contains("empty") || err.contains("whitespace"), - "error should mention empty/whitespace, got: {err}" + err.contains("EmptyAfterTrim"), + "error should mention EmptyAfterTrim, got: {err}" ); } @@ -219,8 +197,8 @@ mod tests { ); assert!(result.is_err()); assert!( - result.unwrap_err().contains("real entry"), - "error should mention real entry" + result.unwrap_err().contains("TooFewItems"), + "error should contain TooFewItems" ); } diff --git a/server/src/validation/mod.rs b/server/src/validation/mod.rs index 26fd7b8d..d00ff356 100644 --- a/server/src/validation/mod.rs +++ b/server/src/validation/mod.rs @@ -19,4 +19,7 @@ mod sanitize; pub use error::{ValidationError, format_errors_as_json}; pub use newtypes::{AcceptanceCriterion, DependsOnId, Description, StoryName}; -pub use requests::{CreateEpicRequest, CreateStoryRequest}; +pub use requests::{ + CreateBugRequest, CreateEpicRequest, CreateRefactorRequest, CreateSpikeRequest, + CreateStoryRequest, +}; diff --git a/server/src/validation/requests.rs b/server/src/validation/requests.rs index 12f10b8c..e9fafa45 100644 --- a/server/src/validation/requests.rs +++ b/server/src/validation/requests.rs @@ -330,6 +330,524 @@ impl CreateEpicRequest { } } +// --------------------------------------------------------------------------- +// 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() + } +} + #[cfg(test)] mod tests { use super::*; @@ -473,4 +991,265 @@ mod tests { 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()); + } }