huskies: merge 1026
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
//! 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<ValidationError>` 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, StoryName};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<Description>,
|
||||
/// Optional background description.
|
||||
#[garde(skip)]
|
||||
pub description: Option<Description>,
|
||||
/// At least one non-junk acceptance criterion required (garde-enforced).
|
||||
#[garde(custom(validate_acceptance_criteria_nonempty))]
|
||||
pub acceptance_criteria: Vec<AcceptanceCriterion>,
|
||||
/// Optional list of story IDs this story depends on.
|
||||
#[garde(skip)]
|
||||
pub depends_on: Option<Vec<DependsOnId>>,
|
||||
}
|
||||
|
||||
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<Self, String> {
|
||||
let mut errors: Vec<ValidationError> = 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::<Vec<String>>(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<Vec<DependsOnId>> =
|
||||
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<u32>` for downstream use.
|
||||
pub fn depends_on_ids(&self) -> Option<Vec<u32>> {
|
||||
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<Description>,
|
||||
/// Optional key files text (plain string, minimal validation).
|
||||
#[garde(skip)]
|
||||
pub key_files: Option<String>,
|
||||
/// Optional success criteria list.
|
||||
#[garde(skip)]
|
||||
pub success_criteria: Option<Vec<AcceptanceCriterion>>,
|
||||
}
|
||||
|
||||
impl CreateEpicRequest {
|
||||
/// Parse and validate a `create_epic` JSON argument map.
|
||||
pub fn from_json(args: &Value) -> Result<Self, String> {
|
||||
let mut errors: Vec<ValidationError> = 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::<Vec<String>>(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<String> {
|
||||
self.success_criteria
|
||||
.as_ref()
|
||||
.map(|sc| sc.iter().map(|c| c.as_ref().to_string()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[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 </description> 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": ["<thinking>bad</thinking>"]
|
||||
});
|
||||
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": "<thinking>bad</thinking>",
|
||||
"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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user