From 1583ade9fcdb17af9021f37700694e2d57289552 Mon Sep 17 00:00:00 2001 From: Huskies Agent Date: Tue, 21 Jul 2026 16:49:23 +0000 Subject: [PATCH] =?UTF-8?q?huskies:=20merge=201245=20refactor=20Deduplicat?= =?UTF-8?q?e=20validation/requests.rs=20=E2=80=94=20five=20internal=20~50-?= =?UTF-8?q?85=20line=20self-clones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/validation/requests.rs | 1100 +++++++++-------------------- 1 file changed, 331 insertions(+), 769 deletions(-) diff --git a/server/src/validation/requests.rs b/server/src/validation/requests.rs index 5bf57c0f..7e2867f2 100644 --- a/server/src/validation/requests.rs +++ b/server/src/validation/requests.rs @@ -1,8 +1,9 @@ //! 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. +//! the newtypes, then a shared acceptance-criteria completeness check. Callers +//! receive either a fully validated struct or a `Vec` with every +//! problem found. use garde::Validate; use serde_json::Value; @@ -39,27 +40,168 @@ fn validate_acceptance_criteria_nonempty(acs: &[AcceptanceCriterion], _ctx: &()) Ok(()) } +// --------------------------------------------------------------------------- +// Shared field-parsing helpers +// +// Every `from_json` follows the same shape: look up a JSON field, run it +// through a newtype's `parse`, and either keep the parsed value or push +// errors onto a shared error vec. These generics capture that shape once so +// each request's `from_json` only spells out its distinct fields. +// --------------------------------------------------------------------------- + +/// Parse a required string field: a missing key becomes `FieldMissing`; +/// a present value is handed to `parse`, with any errors appended to `errors`. +fn required_str_field( + args: &Value, + field: &str, + parse: impl FnOnce(&str) -> Result>, + errors: &mut Vec, +) -> Option { + match args.get(field).and_then(|v| v.as_str()) { + None => { + errors.push(ValidationError::FieldMissing { + field: field.into(), + }); + None + } + Some(raw) => match parse(raw) { + Ok(v) => Some(v), + Err(mut errs) => { + errors.append(&mut errs); + None + } + }, + } +} + +/// Parse an optional string field: an absent key is `None`; a present value +/// is handed to `parse`, with any errors appended to `errors`. +fn optional_str_field( + args: &Value, + field: &str, + parse: impl FnOnce(&str) -> Result>, + errors: &mut Vec, +) -> Option { + match args.get(field).and_then(|v| v.as_str()) { + None => None, + Some(raw) => match parse(raw) { + Ok(v) => Some(v), + Err(mut errs) => { + errors.append(&mut errs); + None + } + }, + } +} + +/// Parse each string in a `field[i]`-indexed JSON string array into an +/// `AcceptanceCriterion`, appending per-item errors to `errors`. +fn parse_criterion_items( + field: &str, + raw_items: &[String], + errors: &mut Vec, +) -> Vec { + let mut parsed = Vec::new(); + for (i, raw) in raw_items.iter().enumerate() { + let item_field = format!("{field}[{i}]"); + match AcceptanceCriterion::parse(&item_field, raw) { + Ok(ac) => parsed.push(ac), + Err(mut errs) => errors.append(&mut errs), + } + } + parsed +} + +/// Parse a required acceptance/success-criteria list field: a missing or +/// non-array value is `FieldMissing`; present values are parsed item by item. +fn required_criterion_list( + args: &Value, + field: &str, + errors: &mut Vec, +) -> Option> { + match args + .get(field) + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + { + None => { + errors.push(ValidationError::FieldMissing { + field: field.into(), + }); + None + } + Some(raw_items) => Some(parse_criterion_items(field, &raw_items, errors)), + } +} + +/// Parse an optional acceptance/success-criteria list field: an absent or +/// non-array value is `None`; present values are parsed item by item. +fn optional_criterion_list( + args: &Value, + field: &str, + errors: &mut Vec, +) -> Option> { + let raw_items = args + .get(field) + .and_then(|v| serde_json::from_value::>(v.clone()).ok())?; + Some(parse_criterion_items(field, &raw_items, errors)) +} + +/// Parse the optional `depends_on` array of story IDs shared by every +/// creatable item type. +fn parse_depends_on(args: &Value, errors: &mut Vec) -> Option> { + let arr = args.get("depends_on").and_then(|v| v.as_array())?; + 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) +} + +/// Re-check the acceptance-criteria nonempty/junk rule (mirrors the `garde` +/// custom validator that used to run via `Create*Request`'s derive) and +/// translate a failure into the `TooFewItems` error, using the "0 real +/// entries" convention when every criterion present is junk-only. +fn acceptance_criteria_error(acs: &[AcceptanceCriterion]) -> Option { + if validate_acceptance_criteria_nonempty(acs, &()).is_ok() { + return None; + } + let actual = acs.len(); + let all_junk = acs.iter().all(|ac| { + let lower = ac.as_ref().to_lowercase(); + JUNK_AC_MARKERS.contains(&lower.trim()) + }); + Some(ValidationError::TooFewItems { + field: "acceptance_criteria".into(), + min: 1, + actual: if all_junk && actual > 0 { 0 } else { actual }, + }) +} + // --------------------------------------------------------------------------- // CreateStoryRequest // --------------------------------------------------------------------------- /// Fully validated inputs for the `create_story` MCP tool. -#[derive(Debug, Validate)] +#[derive(Debug)] 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))] + /// At least one non-junk acceptance criterion required. pub acceptance_criteria: Vec, /// Optional list of story IDs this story depends on. - #[garde(skip)] pub depends_on: Option>, } @@ -71,143 +213,52 @@ impl CreateStoryRequest { 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); + let name = required_str_field(args, "name", StoryName::parse, &mut errors); + + let user_story = optional_str_field( + args, + "user_story", + |raw| Description::parse("user_story", raw), + &mut errors, + ); + let user_story = match user_story { + Some(d) => match check_boilerplate_user_story("user_story", d.as_str()) { + Some(boilerplate_err) => { + errors.push(boilerplate_err); None } + None => Some(d), }, - }; - - // 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) => { - if let Some(boilerplate_err) = - check_boilerplate_user_story("user_story", d.as_str()) - { - errors.push(boilerplate_err); - None - } else { - 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 - } - }, - }; + let description = optional_str_field( + args, + "description", + |raw| Description::parse("description", raw), + &mut errors, + ); - // 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) - } - }; + let acceptance_criteria = required_criterion_list(args, "acceptance_criteria", &mut errors); + let depends_on = parse_depends_on(args, &mut errors); 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, - }); - } - } + let acceptance_criteria = acceptance_criteria.unwrap(); + if let Some(e) = acceptance_criteria_error(&acceptance_criteria) { + errors.push(e); return Err(format_errors_as_json(&errors)); } - Ok(req) + Ok(CreateStoryRequest { + name: name.unwrap(), + user_story, + description, + acceptance_criteria, + depends_on, + }) } /// Extract validated `depends_on` as a plain `Vec` for downstream use. @@ -247,51 +298,19 @@ impl CreateEpicRequest { 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 - } - }, - }; + let name = required_str_field(args, "name", StoryName::parse, &mut errors); + let goal = required_str_field( + args, + "goal", + |raw| Description::parse("goal", raw), + &mut errors, + ); + let motivation = optional_str_field( + args, + "motivation", + |raw| Description::parse("motivation", raw), + &mut errors, + ); // key_files (optional, plain string — structural markup, not user prose) let key_files = args @@ -301,24 +320,7 @@ impl CreateEpicRequest { .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) - } - }; + let success_criteria = optional_criterion_list(args, "success_criteria", &mut errors); if !errors.is_empty() { return Err(format_errors_as_json(&errors)); @@ -346,28 +348,21 @@ impl CreateEpicRequest { // --------------------------------------------------------------------------- /// Fully validated inputs for the `create_bug` MCP tool. -#[derive(Debug, Validate)] +#[derive(Debug)] 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>, } @@ -376,169 +371,53 @@ impl CreateBugRequest { 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) - } - }; + let name = required_str_field(args, "name", StoryName::parse, &mut errors); + let description = required_str_field( + args, + "description", + |raw| Description::parse("description", raw), + &mut errors, + ); + let steps_to_reproduce = required_str_field( + args, + "steps_to_reproduce", + |raw| Description::parse("steps_to_reproduce", raw), + &mut errors, + ); + let actual_result = required_str_field( + args, + "actual_result", + |raw| Description::parse("actual_result", raw), + &mut errors, + ); + let expected_result = required_str_field( + args, + "expected_result", + |raw| Description::parse("expected_result", raw), + &mut errors, + ); + let acceptance_criteria = required_criterion_list(args, "acceptance_criteria", &mut errors); + let depends_on = parse_depends_on(args, &mut errors); if !errors.is_empty() { return Err(format_errors_as_json(&errors)); } - let req = CreateBugRequest { + let acceptance_criteria = acceptance_criteria.unwrap(); + if let Some(e) = acceptance_criteria_error(&acceptance_criteria) { + errors.push(e); + return Err(format_errors_as_json(&errors)); + } + + Ok(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(), + acceptance_criteria, 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. @@ -562,19 +441,15 @@ impl CreateBugRequest { // --------------------------------------------------------------------------- /// Fully validated inputs for the `create_refactor` MCP tool. -#[derive(Debug, Validate)] +#[derive(Debug)] 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>, } @@ -583,113 +458,32 @@ impl CreateRefactorRequest { 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) - } - }; + let name = required_str_field(args, "name", StoryName::parse, &mut errors); + let description = optional_str_field( + args, + "description", + |raw| Description::parse("description", raw), + &mut errors, + ); + let acceptance_criteria = required_criterion_list(args, "acceptance_criteria", &mut errors); + let depends_on = parse_depends_on(args, &mut errors); 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, - }); - } - } + let acceptance_criteria = acceptance_criteria.unwrap(); + if let Some(e) = acceptance_criteria_error(&acceptance_criteria) { + errors.push(e); return Err(format_errors_as_json(&errors)); } - Ok(req) + Ok(CreateRefactorRequest { + name: name.unwrap(), + description, + acceptance_criteria, + depends_on, + }) } /// Extract validated `depends_on` as a plain `Vec` for downstream use. @@ -713,19 +507,15 @@ impl CreateRefactorRequest { // --------------------------------------------------------------------------- /// Fully validated inputs for the `create_spike` MCP tool. -#[derive(Debug, Validate)] +#[derive(Debug)] 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>, } @@ -734,113 +524,32 @@ impl CreateSpikeRequest { 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) - } - }; + let name = required_str_field(args, "name", StoryName::parse, &mut errors); + let description = optional_str_field( + args, + "description", + |raw| Description::parse("description", raw), + &mut errors, + ); + let acceptance_criteria = required_criterion_list(args, "acceptance_criteria", &mut errors); + let depends_on = parse_depends_on(args, &mut errors); 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, - }); - } - } + let acceptance_criteria = acceptance_criteria.unwrap(); + if let Some(e) = acceptance_criteria_error(&acceptance_criteria) { + errors.push(e); return Err(format_errors_as_json(&errors)); } - Ok(req) + Ok(CreateSpikeRequest { + name: name.unwrap(), + description, + acceptance_criteria, + depends_on, + }) } /// Extract validated `depends_on` as a plain `Vec` for downstream use. @@ -888,77 +597,37 @@ impl UpdateStoryRequest { 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 - } - }, - }; - - // steps_to_reproduce (optional, bug items only) - let steps_to_reproduce = match args.get("steps_to_reproduce").and_then(|v| v.as_str()) { - None => None, - Some(raw) => match Description::parse("steps_to_reproduce", raw) { - Ok(d) => Some(d), - Err(mut errs) => { - errors.append(&mut errs); - None - } - }, - }; - - // actual_result (optional, bug items only) - let actual_result = match args.get("actual_result").and_then(|v| v.as_str()) { - None => None, - Some(raw) => match Description::parse("actual_result", raw) { - Ok(d) => Some(d), - Err(mut errs) => { - errors.append(&mut errs); - None - } - }, - }; - - // expected_result (optional, bug items only) - let expected_result = match args.get("expected_result").and_then(|v| v.as_str()) { - None => None, - Some(raw) => match Description::parse("expected_result", raw) { - Ok(d) => Some(d), - Err(mut errs) => { - errors.append(&mut errs); - None - } - }, - }; + let name = optional_str_field(args, "name", StoryName::parse, &mut errors); + let user_story = optional_str_field( + args, + "user_story", + |raw| Description::parse("user_story", raw), + &mut errors, + ); + let description = optional_str_field( + args, + "description", + |raw| Description::parse("description", raw), + &mut errors, + ); + let steps_to_reproduce = optional_str_field( + args, + "steps_to_reproduce", + |raw| Description::parse("steps_to_reproduce", raw), + &mut errors, + ); + let actual_result = optional_str_field( + args, + "actual_result", + |raw| Description::parse("actual_result", raw), + &mut errors, + ); + let expected_result = optional_str_field( + args, + "expected_result", + |raw| Description::parse("expected_result", raw), + &mut errors, + ); if !errors.is_empty() { return Err(format_errors_as_json(&errors)); @@ -991,21 +660,12 @@ impl AddCriterionRequest { 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 - } - }, - }; + let criterion = required_str_field( + args, + "criterion", + |raw| AcceptanceCriterion::parse("criterion", raw), + &mut errors, + ); if !errors.is_empty() { return Err(format_errors_as_json(&errors)); @@ -1033,21 +693,12 @@ impl EditCriterionRequest { 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 - } - }, - }; + let new_text = required_str_field( + args, + "new_text", + |raw| AcceptanceCriterion::parse("new_text", raw), + &mut errors, + ); if !errors.is_empty() { return Err(format_errors_as_json(&errors)); @@ -1076,37 +727,9 @@ impl MoveStoryRequest { 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 - } - }, - }; + let story_id = required_str_field(args, "story_id", StoryId::parse, &mut errors); + let target_stage = + required_str_field(args, "target_stage", TargetStage::parse, &mut errors); if !errors.is_empty() { return Err(format_errors_as_json(&errors)); @@ -1137,21 +760,7 @@ impl MoveStoryToMergeRequest { 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 story_id = required_str_field(args, "story_id", StoryId::parse, &mut errors); let agent_name = match args.get("agent_name").and_then(|v| v.as_str()) { None => None, @@ -1200,21 +809,7 @@ impl UnblockStoryRequest { 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 story_id = required_str_field(args, "story_id", StoryId::parse, &mut errors); if !errors.is_empty() { return Err(format_errors_as_json(&errors)); @@ -1242,21 +837,7 @@ impl FreezeStoryRequest { 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 story_id = required_str_field(args, "story_id", StoryId::parse, &mut errors); if !errors.is_empty() { return Err(format_errors_as_json(&errors)); @@ -1289,33 +870,14 @@ impl ConvertItemTypeRequest { 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 story_id = required_str_field(args, "story_id", StoryId::parse, &mut errors); - let new_type = match args.get("new_type").and_then(|v| v.as_str()) { - None => { - errors.push(ValidationError::FieldMissing { - field: "new_type".into(), - }); - None - } - Some(raw) => match crate::io::story_metadata::ItemType::from_str(raw) { - Some(t) => Some(t), - None => { - errors.push(ValidationError::InvalidValue { + let new_type = required_str_field( + args, + "new_type", + |raw| { + crate::io::story_metadata::ItemType::from_str(raw).ok_or_else(|| { + vec![ValidationError::InvalidValue { field: "new_type".into(), actual: raw.to_string(), allowed: vec![ @@ -1325,11 +887,11 @@ impl ConvertItemTypeRequest { "refactor".into(), "epic".into(), ], - }); - None - } + }] + }) }, - }; + &mut errors, + ); if !errors.is_empty() { return Err(format_errors_as_json(&errors));