1911 lines
64 KiB
Rust
1911 lines
64 KiB
Rust
//! 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, 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<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()
|
|
}
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// 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<AcceptanceCriterion>,
|
|
/// Optional list of story IDs this bug depends on.
|
|
#[garde(skip)]
|
|
pub depends_on: Option<Vec<DependsOnId>>,
|
|
}
|
|
|
|
impl CreateBugRequest {
|
|
/// Parse and validate a `create_bug` JSON argument map.
|
|
pub fn from_json(args: &Value) -> Result<Self, String> {
|
|
let mut errors: Vec<ValidationError> = 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::<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)
|
|
}
|
|
};
|
|
|
|
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 = 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<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())
|
|
}
|
|
|
|
/// Extract acceptance criteria as plain strings for downstream use.
|
|
pub fn acceptance_criteria_strings(&self) -> Vec<String> {
|
|
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<Description>,
|
|
/// At least one non-junk acceptance criterion required.
|
|
#[garde(custom(validate_acceptance_criteria_nonempty))]
|
|
pub acceptance_criteria: Vec<AcceptanceCriterion>,
|
|
/// Optional list of story IDs this refactor depends on.
|
|
#[garde(skip)]
|
|
pub depends_on: Option<Vec<DependsOnId>>,
|
|
}
|
|
|
|
impl CreateRefactorRequest {
|
|
/// Parse and validate a `create_refactor` JSON argument map.
|
|
pub fn from_json(args: &Value) -> Result<Self, String> {
|
|
let mut errors: Vec<ValidationError> = 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::<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)
|
|
}
|
|
};
|
|
|
|
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 = 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<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())
|
|
}
|
|
|
|
/// Extract acceptance criteria as plain strings for downstream use.
|
|
pub fn acceptance_criteria_strings(&self) -> Vec<String> {
|
|
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<Description>,
|
|
/// At least one non-junk acceptance criterion required.
|
|
#[garde(custom(validate_acceptance_criteria_nonempty))]
|
|
pub acceptance_criteria: Vec<AcceptanceCriterion>,
|
|
/// Optional list of story IDs this spike depends on.
|
|
#[garde(skip)]
|
|
pub depends_on: Option<Vec<DependsOnId>>,
|
|
}
|
|
|
|
impl CreateSpikeRequest {
|
|
/// Parse and validate a `create_spike` JSON argument map.
|
|
pub fn from_json(args: &Value) -> Result<Self, String> {
|
|
let mut errors: Vec<ValidationError> = 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::<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)
|
|
}
|
|
};
|
|
|
|
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 = 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<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())
|
|
}
|
|
|
|
/// Extract acceptance criteria as plain strings for downstream use.
|
|
pub fn acceptance_criteria_strings(&self) -> Vec<String> {
|
|
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<StoryName>,
|
|
/// Validated user-story text, if provided.
|
|
pub user_story: Option<Description>,
|
|
/// Validated background description, if provided.
|
|
pub description: Option<Description>,
|
|
}
|
|
|
|
impl UpdateStoryRequest {
|
|
/// Parse and validate the text fields from an `update_story` JSON argument map.
|
|
pub fn from_json(args: &Value) -> Result<Self, String> {
|
|
let mut errors: Vec<ValidationError> = 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<Self, String> {
|
|
let mut errors: Vec<ValidationError> = 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<Self, String> {
|
|
let mut errors: Vec<ValidationError> = 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<Self, String> {
|
|
let mut errors: Vec<ValidationError> = 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<String>,
|
|
}
|
|
|
|
impl MoveStoryToMergeRequest {
|
|
/// Parse and validate a `move_story_to_merge` JSON argument map.
|
|
pub fn from_json(args: &serde_json::Value) -> Result<Self, String> {
|
|
let mut errors: Vec<ValidationError> = 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<Self, String> {
|
|
let mut errors: Vec<ValidationError> = 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<Self, String> {
|
|
let mut errors: Vec<ValidationError> = 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 </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);
|
|
}
|
|
|
|
// --- 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 <thinking>token</thinking>",
|
|
"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 </description>",
|
|
"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": ["<tool_use>bad</tool_use>"]
|
|
});
|
|
let err = CreateSpikeRequest::from_json(&args).unwrap_err();
|
|
assert!(err.contains("AntiGrammarToken"));
|
|
}
|
|
|
|
#[test]
|
|
fn create_spike_request_errors_are_json() {
|
|
let args = json!({
|
|
"name": "<thinking>bad</thinking>",
|
|
"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 <thinking>name</thinking>"});
|
|
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 </description> 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": "<thinking>bad</thinking>",
|
|
"description": "<tool_use>bad</tool_use>"
|
|
});
|
|
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": "<thinking>inject</thinking>"});
|
|
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": "<tool_use>bad</tool_use>"});
|
|
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": "<thinking>42</thinking>"});
|
|
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());
|
|
}
|
|
}
|