huskies: merge 1031
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user