huskies: merge 1031

This commit is contained in:
dave
2026-05-14 14:31:13 +00:00
parent 3d741acefb
commit bc99821274
4 changed files with 323 additions and 32 deletions
+2 -2
View File
@@ -20,6 +20,6 @@ mod sanitize;
pub use error::{ValidationError, format_errors_as_json};
pub use newtypes::{AcceptanceCriterion, DependsOnId, Description, StoryName};
pub use requests::{
CreateBugRequest, CreateEpicRequest, CreateRefactorRequest, CreateSpikeRequest,
CreateStoryRequest,
AddCriterionRequest, CreateBugRequest, CreateEpicRequest, CreateRefactorRequest,
CreateSpikeRequest, CreateStoryRequest, EditCriterionRequest, UpdateStoryRequest,
};
+293
View File
@@ -848,6 +848,161 @@ impl CreateSpikeRequest {
}
}
// ---------------------------------------------------------------------------
// 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(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1252,4 +1407,142 @@ mod tests {
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"));
}
}