huskies: merge 1030
This commit is contained in:
@@ -22,56 +22,19 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
|
||||
pub(crate) fn tool_create_bug(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let name = args
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: name")?;
|
||||
let description = args
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: description")?;
|
||||
let steps_to_reproduce = args
|
||||
.get("steps_to_reproduce")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: steps_to_reproduce")?;
|
||||
let actual_result = args
|
||||
.get("actual_result")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: actual_result")?;
|
||||
let expected_result = args
|
||||
.get("expected_result")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: expected_result")?;
|
||||
let acceptance_criteria: Vec<String> = args
|
||||
.get("acceptance_criteria")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.ok_or("Missing required argument: acceptance_criteria")?;
|
||||
if acceptance_criteria.is_empty() {
|
||||
return Err("acceptance_criteria must contain at least one entry".to_string());
|
||||
}
|
||||
const JUNK_AC: &[&str] = &["", "todo", "tbd", "fixme", "xxx", "???"];
|
||||
let all_junk = acceptance_criteria
|
||||
.iter()
|
||||
.all(|ac| JUNK_AC.contains(&ac.trim().to_lowercase().as_str()));
|
||||
if all_junk {
|
||||
return Err(
|
||||
"acceptance_criteria must contain at least one real entry (not just TODO/TBD/FIXME/XXX/???)."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let depends_on: Option<Vec<u32>> = args
|
||||
.get("depends_on")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
let req = crate::validation::CreateBugRequest::from_json(args)?;
|
||||
let acs = req.acceptance_criteria_strings();
|
||||
let depends_on = req.depends_on_ids();
|
||||
|
||||
let root = ctx.state.get_project_root()?;
|
||||
let bug_id = create_bug_file(
|
||||
&root,
|
||||
name,
|
||||
description,
|
||||
steps_to_reproduce,
|
||||
actual_result,
|
||||
expected_result,
|
||||
&acceptance_criteria,
|
||||
req.name.as_ref(),
|
||||
req.description.as_str(),
|
||||
req.steps_to_reproduce.as_str(),
|
||||
req.actual_result.as_str(),
|
||||
req.expected_result.as_str(),
|
||||
&acs,
|
||||
depends_on.as_deref(),
|
||||
)?;
|
||||
|
||||
@@ -235,8 +198,8 @@ mod tests {
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.contains("empty") || err.contains("whitespace"),
|
||||
"error should mention empty/whitespace, got: {err}"
|
||||
err.contains("EmptyAfterTrim"),
|
||||
"error should mention EmptyAfterTrim, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -416,8 +379,8 @@ mod tests {
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().contains("real entry"),
|
||||
"error should mention real entry"
|
||||
result.unwrap_err().contains("TooFewItems"),
|
||||
"error should contain TooFewItems"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,38 +22,17 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
|
||||
pub(crate) fn tool_create_refactor(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let name = args
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: name")?;
|
||||
let description = args.get("description").and_then(|v| v.as_str());
|
||||
let acceptance_criteria: Vec<String> = args
|
||||
.get("acceptance_criteria")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.ok_or("Missing required argument: acceptance_criteria")?;
|
||||
if acceptance_criteria.is_empty() {
|
||||
return Err("acceptance_criteria must contain at least one entry".to_string());
|
||||
}
|
||||
const JUNK_AC: &[&str] = &["", "todo", "tbd", "fixme", "xxx", "???"];
|
||||
let all_junk = acceptance_criteria
|
||||
.iter()
|
||||
.all(|ac| JUNK_AC.contains(&ac.trim().to_lowercase().as_str()));
|
||||
if all_junk {
|
||||
return Err(
|
||||
"acceptance_criteria must contain at least one real entry (not just TODO/TBD/FIXME/XXX/???)."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let depends_on: Option<Vec<u32>> = args
|
||||
.get("depends_on")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
let req = crate::validation::CreateRefactorRequest::from_json(args)?;
|
||||
let acs = req.acceptance_criteria_strings();
|
||||
let description = req.description.as_ref().map(|d| d.as_str());
|
||||
let depends_on = req.depends_on_ids();
|
||||
|
||||
let root = ctx.state.get_project_root()?;
|
||||
let refactor_id = create_refactor_file(
|
||||
&root,
|
||||
name,
|
||||
req.name.as_ref(),
|
||||
description,
|
||||
&acceptance_criteria,
|
||||
&acs,
|
||||
depends_on.as_deref(),
|
||||
)?;
|
||||
|
||||
@@ -88,8 +67,8 @@ mod tests {
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.contains("empty") || err.contains("whitespace"),
|
||||
"error should mention empty/whitespace, got: {err}"
|
||||
err.contains("EmptyAfterTrim"),
|
||||
"error should mention EmptyAfterTrim, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,8 +121,8 @@ mod tests {
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().contains("real entry"),
|
||||
"error should mention real entry"
|
||||
result.unwrap_err().contains("TooFewItems"),
|
||||
"error should contain TooFewItems"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,39 +22,17 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
|
||||
pub(crate) fn tool_create_spike(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let name = args
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: name")?;
|
||||
let description = args.get("description").and_then(|v| v.as_str());
|
||||
let acceptance_criteria: Vec<String> = args
|
||||
.get("acceptance_criteria")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.ok_or("Missing required argument: acceptance_criteria")?;
|
||||
if acceptance_criteria.is_empty() {
|
||||
return Err("acceptance_criteria must contain at least one entry".to_string());
|
||||
}
|
||||
const JUNK_AC: &[&str] = &["", "todo", "tbd", "fixme", "xxx", "???"];
|
||||
let all_junk = acceptance_criteria
|
||||
.iter()
|
||||
.all(|ac| JUNK_AC.contains(&ac.trim().to_lowercase().as_str()));
|
||||
if all_junk {
|
||||
return Err(
|
||||
"acceptance_criteria must contain at least one real entry (not just TODO/TBD/FIXME/XXX/???)."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let depends_on: Option<Vec<u32>> = args
|
||||
.get("depends_on")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
let req = crate::validation::CreateSpikeRequest::from_json(args)?;
|
||||
let acs = req.acceptance_criteria_strings();
|
||||
let description = req.description.as_ref().map(|d| d.as_str());
|
||||
let depends_on = req.depends_on_ids();
|
||||
|
||||
let root = ctx.state.get_project_root()?;
|
||||
let spike_id = create_spike_file(
|
||||
&root,
|
||||
name,
|
||||
req.name.as_ref(),
|
||||
description,
|
||||
&acceptance_criteria,
|
||||
&acs,
|
||||
depends_on.as_deref(),
|
||||
)?;
|
||||
|
||||
@@ -113,8 +91,8 @@ mod tests {
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.contains("empty") || err.contains("whitespace"),
|
||||
"error should mention empty/whitespace, got: {err}"
|
||||
err.contains("EmptyAfterTrim"),
|
||||
"error should mention EmptyAfterTrim, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,8 +197,8 @@ mod tests {
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().contains("real entry"),
|
||||
"error should mention real entry"
|
||||
result.unwrap_err().contains("TooFewItems"),
|
||||
"error should contain TooFewItems"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,4 +19,7 @@ mod sanitize;
|
||||
|
||||
pub use error::{ValidationError, format_errors_as_json};
|
||||
pub use newtypes::{AcceptanceCriterion, DependsOnId, Description, StoryName};
|
||||
pub use requests::{CreateEpicRequest, CreateStoryRequest};
|
||||
pub use requests::{
|
||||
CreateBugRequest, CreateEpicRequest, CreateRefactorRequest, CreateSpikeRequest,
|
||||
CreateStoryRequest,
|
||||
};
|
||||
|
||||
@@ -330,6 +330,524 @@ impl CreateEpicRequest {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateBugRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fully validated inputs for the `create_bug` MCP tool.
|
||||
#[derive(Debug, Validate)]
|
||||
pub struct CreateBugRequest {
|
||||
/// Validated bug name.
|
||||
#[garde(skip)]
|
||||
pub name: StoryName,
|
||||
/// Required description of the bug.
|
||||
#[garde(skip)]
|
||||
pub description: Description,
|
||||
/// Steps needed to reproduce the bug.
|
||||
#[garde(skip)]
|
||||
pub steps_to_reproduce: Description,
|
||||
/// What actually happens.
|
||||
#[garde(skip)]
|
||||
pub actual_result: Description,
|
||||
/// What should happen.
|
||||
#[garde(skip)]
|
||||
pub expected_result: Description,
|
||||
/// At least one non-junk acceptance criterion required.
|
||||
#[garde(custom(validate_acceptance_criteria_nonempty))]
|
||||
pub acceptance_criteria: Vec<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()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -473,4 +991,265 @@ mod tests {
|
||||
let sc = req.success_criteria_strings();
|
||||
assert_eq!(sc.len(), 2);
|
||||
}
|
||||
|
||||
// --- CreateBugRequest ---
|
||||
|
||||
#[test]
|
||||
fn create_bug_request_valid_minimal() {
|
||||
let args = json!({
|
||||
"name": "Login crash",
|
||||
"description": "App crashes on login",
|
||||
"steps_to_reproduce": "1. Open app 2. Click login",
|
||||
"actual_result": "500 error",
|
||||
"expected_result": "Successful login",
|
||||
"acceptance_criteria": ["Login succeeds"]
|
||||
});
|
||||
let req = CreateBugRequest::from_json(&args).unwrap();
|
||||
assert_eq!(req.name.as_ref(), "Login crash");
|
||||
assert_eq!(req.acceptance_criteria.len(), 1);
|
||||
assert_eq!(req.acceptance_criteria_strings(), vec!["Login succeeds"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_bug_request_missing_name() {
|
||||
let args = json!({
|
||||
"description": "d",
|
||||
"steps_to_reproduce": "s",
|
||||
"actual_result": "a",
|
||||
"expected_result": "e",
|
||||
"acceptance_criteria": ["AC"]
|
||||
});
|
||||
let err = CreateBugRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("FieldMissing"));
|
||||
assert!(err.contains("name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_bug_request_missing_description() {
|
||||
let args = json!({
|
||||
"name": "Bug",
|
||||
"steps_to_reproduce": "s",
|
||||
"actual_result": "a",
|
||||
"expected_result": "e",
|
||||
"acceptance_criteria": ["AC"]
|
||||
});
|
||||
let err = CreateBugRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("FieldMissing"));
|
||||
assert!(err.contains("description"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_bug_request_missing_steps_to_reproduce() {
|
||||
let args = json!({
|
||||
"name": "Bug",
|
||||
"description": "d",
|
||||
"actual_result": "a",
|
||||
"expected_result": "e",
|
||||
"acceptance_criteria": ["AC"]
|
||||
});
|
||||
let err = CreateBugRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("FieldMissing"));
|
||||
assert!(err.contains("steps_to_reproduce"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_bug_request_empty_acs() {
|
||||
let args = json!({
|
||||
"name": "Bug",
|
||||
"description": "d",
|
||||
"steps_to_reproduce": "s",
|
||||
"actual_result": "a",
|
||||
"expected_result": "e",
|
||||
"acceptance_criteria": []
|
||||
});
|
||||
let err = CreateBugRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("TooFewItems"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_bug_request_all_junk_acs() {
|
||||
let args = json!({
|
||||
"name": "Bug",
|
||||
"description": "d",
|
||||
"steps_to_reproduce": "s",
|
||||
"actual_result": "a",
|
||||
"expected_result": "e",
|
||||
"acceptance_criteria": ["TODO", "TBD"]
|
||||
});
|
||||
let err = CreateBugRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("TooFewItems"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_bug_request_grammar_token_in_description() {
|
||||
let args = json!({
|
||||
"name": "Bug",
|
||||
"description": "bad <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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user