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
+7 -11
View File
@@ -15,6 +15,7 @@ use crate::http::workflow::{
use crate::io::story_metadata::parse_unchecked_todos;
use crate::service::story::parse_test_cases;
use crate::slog_warn;
use crate::validation::{AddCriterionRequest, EditCriterionRequest};
#[allow(unused_imports)]
use crate::workflow::{
TestCaseResult, TestStatus, WorkflowState, evaluate_acceptance_with_coverage,
@@ -268,13 +269,10 @@ pub(crate) fn tool_edit_criterion(args: &Value, ctx: &AppContext) -> Result<Stri
.get("criterion_index")
.and_then(|v| v.as_u64())
.ok_or("Missing required argument: criterion_index")? as usize;
let new_text = args
.get("new_text")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: new_text")?;
let req = EditCriterionRequest::from_json(args)?;
let root = ctx.state.get_project_root()?;
edit_criterion_in_file(&root, story_id, criterion_index, new_text)?;
edit_criterion_in_file(&root, story_id, criterion_index, req.new_text.as_ref())?;
Ok(format!(
"Criterion {criterion_index} updated for story '{story_id}'."
@@ -286,16 +284,14 @@ pub(crate) fn tool_add_criterion(args: &Value, ctx: &AppContext) -> Result<Strin
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let criterion = args
.get("criterion")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: criterion")?;
let req = AddCriterionRequest::from_json(args)?;
let root = ctx.state.get_project_root()?;
add_criterion_to_file(&root, story_id, criterion)?;
add_criterion_to_file(&root, story_id, req.criterion.as_ref())?;
Ok(format!(
"Added criterion to story '{story_id}': - [ ] {criterion}"
"Added criterion to story '{story_id}': - [ ] {}",
req.criterion.as_ref()
))
}
+17 -15
View File
@@ -3,6 +3,7 @@
use crate::http::context::AppContext;
use crate::http::workflow::update_story_in_file;
use crate::slog_warn;
use crate::validation::UpdateStoryRequest;
use serde_json::Value;
pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
@@ -10,24 +11,21 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let user_story = args.get("user_story").and_then(|v| v.as_str());
let description = args.get("description").and_then(|v| v.as_str());
let req = UpdateStoryRequest::from_json(args)?;
// Explicit top-level args map onto typed CRDT registers directly (story 929:
// no YAML front-matter writes). The `front_matter` object is the legacy
// escape hatch; every known key is recognised and routed below, and any
// unknown key is rejected loudly rather than silently flushed to disk.
if let Some(name) = args.get("name").and_then(|v| v.as_str()) {
if name.trim().is_empty() {
return Err("name must not be empty".to_string());
}
if !crate::crdt_state::set_name(story_id, Some(name)) {
if let Some(ref name) = req.name
&& !crate::crdt_state::set_name(story_id, Some(name.as_ref()))
{
return Err(format!(
"Story '{story_id}' not found in CRDT — name was not updated. \
The story may not exist or may not yet be registered."
));
}
}
if let Some(agent) = args.get("agent").and_then(|v| v.as_str()) {
crate::crdt_state::set_agent(story_id, agent.parse::<crate::config::AgentName>().ok());
}
@@ -46,11 +44,10 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
);
}
"name" => {
let s = value.as_str().filter(|s| !s.trim().is_empty());
if s.is_none() {
return Err("name must not be empty".to_string());
}
if !crate::crdt_state::set_name(story_id, s) {
let raw = value.as_str().unwrap_or("");
let validated = crate::validation::StoryName::parse(raw)
.map_err(|errs| crate::validation::format_errors_as_json(&errs))?;
if !crate::crdt_state::set_name(story_id, Some(validated.as_ref())) {
return Err(format!(
"Story '{story_id}' not found in CRDT — name was not updated."
));
@@ -188,8 +185,13 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
let root = ctx.state.get_project_root()?;
// Only call update_story_in_file when there is body content to update.
if user_story.is_some() || description.is_some() {
update_story_in_file(&root, story_id, user_story, description)?;
if req.user_story.is_some() || req.description.is_some() {
update_story_in_file(
&root,
story_id,
req.user_story.as_ref().map(|d| d.as_str()),
req.description.as_ref().map(|d| d.as_str()),
)?;
}
// Bug 503: warn if any depends_on in the (now updated) story points at an archived story.
+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"));
}
}