huskies: merge 1030

This commit is contained in:
dave
2026-05-14 13:22:16 +00:00
parent f1c96595de
commit 9501412598
5 changed files with 816 additions and 114 deletions
+4 -1
View File
@@ -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,
};
+779
View File
@@ -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());
}
}