fix: add --all to cargo fmt in script/test and autoformat codebase

cargo fmt without --all fails with "Failed to find targets" in
workspace repos. This was blocking every story's gates. Also ran
cargo fmt --all to fix all existing formatting issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dave
2026-04-13 14:07:08 +00:00
parent ed2526ce41
commit 845b85e7a7
128 changed files with 3566 additions and 2395 deletions
+16 -21
View File
@@ -61,9 +61,10 @@ pub async fn agent_stream(
.header("Content-Type", "text/event-stream")
.header("Cache-Control", "no-cache")
.header("Connection", "keep-alive")
.body(Body::from_bytes_stream(
futures::StreamExt::map(stream, |r| r.map(bytes::Bytes::from)),
))
.body(Body::from_bytes_stream(futures::StreamExt::map(
stream,
|r| r.map(bytes::Bytes::from),
)))
}
#[cfg(test)]
@@ -77,10 +78,7 @@ mod tests {
fn test_app(ctx: Arc<AppContext>) -> impl poem::Endpoint {
Route::new()
.at(
"/agents/:story_id/:agent_name/stream",
get(agent_stream),
)
.at("/agents/:story_id/:agent_name/stream", get(agent_stream))
.data(ctx)
}
@@ -123,10 +121,7 @@ mod tests {
});
let cli = poem::test::TestClient::new(test_app(ctx));
let resp = cli
.get("/agents/1_story/coder-1/stream")
.send()
.await;
let resp = cli.get("/agents/1_story/coder-1/stream").send().await;
let body = resp.0.into_body().into_string().await.unwrap();
@@ -178,15 +173,18 @@ mod tests {
});
let cli = poem::test::TestClient::new(test_app(ctx));
let resp = cli
.get("/agents/2_story/coder-1/stream")
.send()
.await;
let resp = cli.get("/agents/2_story/coder-1/stream").send().await;
let body = resp.0.into_body().into_string().await.unwrap();
assert!(body.contains("step 1 output"), "Output must be forwarded: {body}");
assert!(body.contains("\"type\":\"done\""), "Done event must be forwarded: {body}");
assert!(
body.contains("step 1 output"),
"Output must be forwarded: {body}"
);
assert!(
body.contains("\"type\":\"done\""),
"Done event must be forwarded: {body}"
);
}
#[tokio::test]
@@ -195,10 +193,7 @@ mod tests {
let ctx = Arc::new(AppContext::new_test(tmp.path().to_path_buf()));
let cli = poem::test::TestClient::new(test_app(ctx));
let resp = cli
.get("/agents/nonexistent/coder-1/stream")
.send()
.await;
let resp = cli.get("/agents/nonexistent/coder-1/stream").send().await;
assert_eq!(
resp.0.status(),
+5 -4
View File
@@ -198,7 +198,8 @@ mod tests {
fn get_api_key_returns_key_when_set() {
let dir = TempDir::new().unwrap();
let ctx = test_ctx(dir.path());
ctx.store.set(KEY_ANTHROPIC_API_KEY, json!("sk-ant-test123"));
ctx.store
.set(KEY_ANTHROPIC_API_KEY, json!("sk-ant-test123"));
let result = get_anthropic_api_key(&ctx);
assert_eq!(result.unwrap(), "sk-ant-test123");
}
@@ -217,7 +218,8 @@ mod tests {
async fn key_exists_returns_true_when_set() {
let dir = TempDir::new().unwrap();
let ctx = AppContext::new_test(dir.path().to_path_buf());
ctx.store.set(KEY_ANTHROPIC_API_KEY, json!("sk-ant-test123"));
ctx.store
.set(KEY_ANTHROPIC_API_KEY, json!("sk-ant-test123"));
let api = AnthropicApi::new(Arc::new(ctx));
let result = api.get_anthropic_api_key_exists().await.unwrap();
assert!(result.0);
@@ -265,8 +267,7 @@ mod tests {
let dir = TempDir::new().unwrap();
let ctx = AppContext::new_test(dir.path().to_path_buf());
// A header value containing a newline is invalid
ctx.store
.set(KEY_ANTHROPIC_API_KEY, json!("bad\nvalue"));
ctx.store.set(KEY_ANTHROPIC_API_KEY, json!("bad\nvalue"));
let api = AnthropicApi::new(Arc::new(ctx));
let result = api.list_anthropic_models_from("http://127.0.0.1:1").await;
assert!(result.is_err());
+39 -14
View File
@@ -9,8 +9,8 @@
//! their dedicated async handlers. The `reset` command is handled by the frontend
//! (it clears local session state and message history) and is not routed here.
use crate::http::context::{AppContext, OpenApiResult};
use crate::chat::commands::CommandDispatch;
use crate::http::context::{AppContext, OpenApiResult};
use poem::http::StatusCode;
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
use serde::{Deserialize, Serialize};
@@ -55,9 +55,11 @@ impl BotCommandApi {
&self,
body: Json<BotCommandRequest>,
) -> OpenApiResult<Json<BotCommandResponse>> {
let project_root = self.ctx.state.get_project_root().map_err(|e| {
poem::Error::from_string(e, StatusCode::BAD_REQUEST)
})?;
let project_root = self
.ctx
.state
.get_project_root()
.map_err(|e| poem::Error::from_string(e, StatusCode::BAD_REQUEST))?;
let cmd = body.command.trim().to_ascii_lowercase();
let args = body.args.trim();
@@ -135,12 +137,21 @@ async fn dispatch_assign(
let number_str = parts.next().unwrap_or("").trim();
let model_str = parts.next().unwrap_or("").trim();
if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) || model_str.is_empty() {
if number_str.is_empty()
|| !number_str.chars().all(|c| c.is_ascii_digit())
|| model_str.is_empty()
{
return "Usage: `/assign <number> <model>` (e.g. `/assign 42 opus`)".to_string();
}
crate::chat::transport::matrix::assign::handle_assign("web-ui", number_str, model_str, project_root, agents)
.await
crate::chat::transport::matrix::assign::handle_assign(
"web-ui",
number_str,
model_str,
project_root,
agents,
)
.await
}
async fn dispatch_start(
@@ -164,8 +175,14 @@ async fn dispatch_start(
Some(hint_str)
};
crate::chat::transport::matrix::start::handle_start("web-ui", number_str, agent_hint, project_root, agents)
.await
crate::chat::transport::matrix::start::handle_start(
"web-ui",
number_str,
agent_hint,
project_root,
agents,
)
.await
}
async fn dispatch_delete(
@@ -177,7 +194,13 @@ async fn dispatch_delete(
if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) {
return "Usage: `/delete <number>` (e.g. `/delete 42`)".to_string();
}
crate::chat::transport::matrix::delete::handle_delete("web-ui", number_str, project_root, agents).await
crate::chat::transport::matrix::delete::handle_delete(
"web-ui",
number_str,
project_root,
agents,
)
.await
}
async fn dispatch_rebuild(
@@ -197,11 +220,13 @@ async fn dispatch_timer(args: &str, project_root: &std::path::Path) -> String {
"@__web_ui__:localhost",
) {
Some(cmd) => cmd,
None => return "Usage: `/timer list`, `/timer <number> <HH:MM>`, or `/timer cancel <number>`".to_string(),
None => {
return "Usage: `/timer list`, `/timer <number> <HH:MM>`, or `/timer cancel <number>`"
.to_string();
}
};
let store = crate::chat::timer::TimerStore::load(
project_root.join(".huskies").join("timers.json"),
);
let store =
crate::chat::timer::TimerStore::load(project_root.join(".huskies").join("timers.json"));
crate::chat::timer::handle_timer_command(timer_cmd, &store, project_root).await
}
+5 -3
View File
@@ -94,8 +94,7 @@ pub struct AppContext {
///
/// Wrapped in `Arc` so `AppContext` can implement `Clone`.
/// `None` when no Matrix bot is configured.
pub matrix_shutdown_tx:
Option<Arc<tokio::sync::watch::Sender<Option<ShutdownReason>>>>,
pub matrix_shutdown_tx: Option<Arc<tokio::sync::watch::Sender<Option<ShutdownReason>>>>,
/// Shared rate-limit retry timer store.
///
/// Used by MCP tools (`move_story`, `stop_agent`) to cancel pending timers
@@ -168,7 +167,10 @@ mod tests {
fn permission_decision_equality() {
assert_eq!(PermissionDecision::Deny, PermissionDecision::Deny);
assert_eq!(PermissionDecision::Approve, PermissionDecision::Approve);
assert_eq!(PermissionDecision::AlwaysAllow, PermissionDecision::AlwaysAllow);
assert_eq!(
PermissionDecision::AlwaysAllow,
PermissionDecision::AlwaysAllow
);
assert_ne!(PermissionDecision::Deny, PermissionDecision::Approve);
assert_ne!(PermissionDecision::Approve, PermissionDecision::AlwaysAllow);
}
+15 -4
View File
@@ -168,8 +168,16 @@ mod tests {
let entries = &result.0;
assert!(entries.len() >= 2);
assert!(entries.iter().any(|e| e.name == "subdir" && e.kind == "dir"));
assert!(entries.iter().any(|e| e.name == "file.txt" && e.kind == "file"));
assert!(
entries
.iter()
.any(|e| e.name == "subdir" && e.kind == "dir")
);
assert!(
entries
.iter()
.any(|e| e.name == "file.txt" && e.kind == "file")
);
}
#[tokio::test]
@@ -390,7 +398,11 @@ mod tests {
let entries = &result.0;
assert!(entries.iter().any(|e| e.name == "adir" && e.kind == "dir"));
assert!(entries.iter().any(|e| e.name == "bfile.txt" && e.kind == "file"));
assert!(
entries
.iter()
.any(|e| e.name == "bfile.txt" && e.kind == "file")
);
}
#[tokio::test]
@@ -403,5 +415,4 @@ mod tests {
let result = api.list_directory(payload).await;
assert!(result.is_err());
}
}
+115 -109
View File
@@ -5,7 +5,7 @@ use crate::http::context::AppContext;
use crate::http::settings::get_editor_command_from_store;
use crate::slog_warn;
use crate::worktree;
use serde_json::{json, Value};
use serde_json::{Value, json};
pub(super) async fn tool_start_agent(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
@@ -72,28 +72,32 @@ pub(super) async fn tool_stop_agent(args: &Value, ctx: &AppContext) -> Result<St
.stop_agent(&project_root, story_id, agent_name)
.await?;
Ok(format!("Agent '{agent_name}' for story '{story_id}' stopped."))
Ok(format!(
"Agent '{agent_name}' for story '{story_id}' stopped."
))
}
pub(super) fn tool_list_agents(ctx: &AppContext) -> Result<String, String> {
let project_root = ctx.agents.get_project_root(&ctx.state).ok();
let agents = ctx.agents.list_agents()?;
serde_json::to_string_pretty(&json!(agents
.iter()
.filter(|a| {
project_root
.as_deref()
.map(|root| !crate::http::agents::story_is_archived(root, &a.story_id))
.unwrap_or(true)
})
.map(|a| json!({
"story_id": a.story_id,
"agent_name": a.agent_name,
"status": a.status.to_string(),
"session_id": a.session_id,
"worktree_path": a.worktree_path,
}))
.collect::<Vec<_>>()))
serde_json::to_string_pretty(&json!(
agents
.iter()
.filter(|a| {
project_root
.as_deref()
.map(|root| !crate::http::agents::story_is_archived(root, &a.story_id))
.unwrap_or(true)
})
.map(|a| json!({
"story_id": a.story_id,
"agent_name": a.agent_name,
"status": a.status.to_string(),
"session_id": a.session_id,
"worktree_path": a.worktree_path,
}))
.collect::<Vec<_>>()
))
.map_err(|e| format!("Serialization error: {e}"))
}
@@ -124,16 +128,12 @@ pub(super) async fn tool_get_agent_output(
let project_root = ctx.agents.get_project_root(&ctx.state)?;
// Collect all matching log files, oldest first.
let log_files =
agent_log::list_story_log_files(&project_root, story_id, agent_name_filter);
let log_files = agent_log::list_story_log_files(&project_root, story_id, agent_name_filter);
let mut all_lines: Vec<String> = Vec::new();
for path in &log_files {
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?");
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
all_lines.push(format!("=== {} ===", file_name.trim_end_matches(".log")));
match agent_log::read_log_as_readable_lines(path) {
Ok(lines) => all_lines.extend(lines),
@@ -156,8 +156,7 @@ pub(super) async fn tool_get_agent_output(
let now = chrono::Utc::now().to_rfc3339();
for event in &live_events {
if let Ok(event_value) = serde_json::to_value(event)
&& let Some(line) =
agent_log::format_log_entry_as_text(&now, &event_value)
&& let Some(line) = agent_log::format_log_entry_as_text(&now, &event_value)
{
all_lines.push(line);
}
@@ -201,8 +200,7 @@ pub(super) fn tool_get_agent_config(ctx: &AppContext) -> Result<String, String>
// Collect available (idle) agent names across all stages so the caller can
// see at a glance which agents are free to start (story 190).
let mut available_names: std::collections::HashSet<String> =
std::collections::HashSet::new();
let mut available_names: std::collections::HashSet<String> = std::collections::HashSet::new();
for stage in &[
PipelineStage::Coder,
PipelineStage::Qa,
@@ -214,19 +212,21 @@ pub(super) fn tool_get_agent_config(ctx: &AppContext) -> Result<String, String>
}
}
serde_json::to_string_pretty(&json!(config
.agent
.iter()
.map(|a| json!({
"name": a.name,
"role": a.role,
"model": a.model,
"allowed_tools": a.allowed_tools,
"max_turns": a.max_turns,
"max_budget_usd": a.max_budget_usd,
"available": available_names.contains(&a.name),
}))
.collect::<Vec<_>>()))
serde_json::to_string_pretty(&json!(
config
.agent
.iter()
.map(|a| json!({
"name": a.name,
"role": a.role,
"model": a.model,
"allowed_tools": a.allowed_tools,
"max_turns": a.max_turns,
"max_budget_usd": a.max_budget_usd,
"available": available_names.contains(&a.name),
}))
.collect::<Vec<_>>()
))
.map_err(|e| format!("Serialization error: {e}"))
}
@@ -254,11 +254,13 @@ pub(super) async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> Resul
_ => None,
};
let completion = info.completion.as_ref().map(|r| json!({
"summary": r.summary,
"gates_passed": r.gates_passed,
"gate_output": r.gate_output,
}));
let completion = info.completion.as_ref().map(|r| {
json!({
"summary": r.summary,
"gates_passed": r.gates_passed,
"gate_output": r.gate_output,
})
});
serde_json::to_string_pretty(&json!({
"story_id": info.story_id,
@@ -295,13 +297,15 @@ pub(super) fn tool_list_worktrees(ctx: &AppContext) -> Result<String, String> {
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let entries = worktree::list_worktrees(&project_root)?;
serde_json::to_string_pretty(&json!(entries
.iter()
.map(|e| json!({
"story_id": e.story_id,
"path": e.path.to_string_lossy(),
}))
.collect::<Vec<_>>()))
serde_json::to_string_pretty(&json!(
entries
.iter()
.map(|e| json!({
"story_id": e.story_id,
"path": e.path.to_string_lossy(),
}))
.collect::<Vec<_>>()
))
.map_err(|e| format!("Serialization error: {e}"))
}
@@ -332,7 +336,10 @@ pub(super) fn tool_get_editor_command(args: &Value, ctx: &AppContext) -> Result<
/// Run `git log <base>..HEAD --oneline` in the worktree and return the commit
/// summaries, or `None` if git is unavailable or there are no new commits.
pub(super) async fn get_worktree_commits(worktree_path: &str, base_branch: &str) -> Option<Vec<String>> {
pub(super) async fn get_worktree_commits(
worktree_path: &str,
base_branch: &str,
) -> Option<Vec<String>> {
let wt = worktree_path.to_string();
let base = base_branch.to_string();
tokio::task::spawn_blocking(move || {
@@ -382,7 +389,11 @@ mod tests {
let result = tool_get_agent_config(&ctx).unwrap();
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
// Default config contains one agent entry with default values
assert_eq!(parsed.len(), 1, "default config should have one fallback agent");
assert_eq!(
parsed.len(),
1,
"default config should have one fallback agent"
);
assert!(parsed[0].get("name").is_some());
assert!(parsed[0].get("role").is_some());
}
@@ -401,12 +412,10 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
// No agent registered, no log file → returns "no log files found" message
let result = tool_get_agent_output(
&json!({"story_id": "99_nope", "agent_name": "bot"}),
&ctx,
)
.await
.unwrap();
let result =
tool_get_agent_output(&json!({"story_id": "99_nope", "agent_name": "bot"}), &ctx)
.await
.unwrap();
assert!(
result.contains("No log files found"),
"expected 'No log files found' message: {result}"
@@ -418,12 +427,9 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
// No agent_name provided — should succeed (no error)
let result = tool_get_agent_output(
&json!({"story_id": "99_nope"}),
&ctx,
)
.await
.unwrap();
let result = tool_get_agent_output(&json!({"story_id": "99_nope"}), &ctx)
.await
.unwrap();
assert!(result.contains("No log files found"));
}
@@ -440,13 +446,8 @@ mod tests {
.set("project_root", json!(tmp.path().to_string_lossy().as_ref()));
// Write a log file
let mut writer = AgentLogWriter::new(
tmp.path(),
"42_story_foo",
"coder-1",
"sess-test",
)
.unwrap();
let mut writer =
AgentLogWriter::new(tmp.path(), "42_story_foo", "coder-1", "sess-test").unwrap();
writer
.write_event(&AgentEvent::Output {
story_id: "42_story_foo".to_string(),
@@ -488,13 +489,8 @@ mod tests {
ctx.store
.set("project_root", json!(tmp.path().to_string_lossy().as_ref()));
let mut writer = AgentLogWriter::new(
tmp.path(),
"42_story_bar",
"coder-1",
"sess-tail",
)
.unwrap();
let mut writer =
AgentLogWriter::new(tmp.path(), "42_story_bar", "coder-1", "sess-tail").unwrap();
for i in 0..10 {
writer
.write_event(&AgentEvent::Output {
@@ -514,8 +510,14 @@ mod tests {
.unwrap();
// Should contain "line 7", "line 8", "line 9" but NOT "line 0"
assert!(result.contains("line 9"), "should contain last line: {result}");
assert!(!result.contains("line 0"), "should not contain early lines: {result}");
assert!(
result.contains("line 9"),
"should contain last line: {result}"
);
assert!(
!result.contains("line 0"),
"should not contain early lines: {result}"
);
}
#[tokio::test]
@@ -529,13 +531,8 @@ mod tests {
ctx.store
.set("project_root", json!(tmp.path().to_string_lossy().as_ref()));
let mut writer = AgentLogWriter::new(
tmp.path(),
"42_story_baz",
"coder-1",
"sess-filter",
)
.unwrap();
let mut writer =
AgentLogWriter::new(tmp.path(), "42_story_baz", "coder-1", "sess-filter").unwrap();
writer
.write_event(&AgentEvent::Output {
story_id: "42_story_baz".to_string(),
@@ -559,8 +556,14 @@ mod tests {
.await
.unwrap();
assert!(result.contains("needle"), "filter should keep matching lines: {result}");
assert!(!result.contains("haystack"), "filter should remove non-matching lines: {result}");
assert!(
result.contains("needle"),
"filter should keep matching lines: {result}"
);
assert!(
!result.contains("haystack"),
"filter should remove non-matching lines: {result}"
);
}
#[tokio::test]
@@ -697,10 +700,7 @@ stage = "coder"
fn tool_get_editor_command_no_editor_configured() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_get_editor_command(
&json!({"worktree_path": "/some/path"}),
&ctx,
);
let result = tool_get_editor_command(&json!({"worktree_path": "/some/path"}), &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("No editor configured"));
}
@@ -725,17 +725,14 @@ stage = "coder"
let ctx = test_ctx(tmp.path());
ctx.store.set("editor_command", json!("code"));
let result = tool_get_editor_command(
&json!({"worktree_path": "/path/to/worktree"}),
&ctx,
)
.unwrap();
let result =
tool_get_editor_command(&json!({"worktree_path": "/path/to/worktree"}), &ctx).unwrap();
assert_eq!(result, "code /path/to/worktree");
}
#[test]
fn get_editor_command_in_tools_list() {
use super::super::{handle_tools_list};
use super::super::handle_tools_list;
let resp = handle_tools_list(Some(json!(1)));
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
let tool = tools.iter().find(|t| t["name"] == "get_editor_command");
@@ -769,9 +766,11 @@ stage = "coder"
async fn wait_for_agent_tool_nonexistent_agent_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result =
tool_wait_for_agent(&json!({"story_id": "99_nope", "agent_name": "bot", "timeout_ms": 50}), &ctx)
.await;
let result = tool_wait_for_agent(
&json!({"story_id": "99_nope", "agent_name": "bot", "timeout_ms": 50}),
&ctx,
)
.await;
// No agent registered — should error
assert!(result.is_err());
}
@@ -802,13 +801,19 @@ stage = "coder"
#[test]
fn wait_for_agent_tool_in_list() {
use super::super::{handle_tools_list};
use super::super::handle_tools_list;
let resp = handle_tools_list(Some(json!(1)));
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
let wait_tool = tools.iter().find(|t| t["name"] == "wait_for_agent");
assert!(wait_tool.is_some(), "wait_for_agent missing from tools list");
assert!(
wait_tool.is_some(),
"wait_for_agent missing from tools list"
);
let t = wait_tool.unwrap();
assert!(t["description"].as_str().unwrap().contains("block") || t["description"].as_str().unwrap().contains("Block"));
assert!(
t["description"].as_str().unwrap().contains("block")
|| t["description"].as_str().unwrap().contains("Block")
);
let required = t["inputSchema"]["required"].as_array().unwrap();
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
assert!(req_names.contains(&"story_id"));
@@ -821,7 +826,8 @@ stage = "coder"
let tmp = tempfile::tempdir().unwrap();
let cov_dir = tmp.path().join(".huskies/coverage");
fs::create_dir_all(&cov_dir).unwrap();
let json_content = r#"{"data":[{"totals":{"lines":{"count":100,"covered":78,"percent":78.0}}}]}"#;
let json_content =
r#"{"data":[{"totals":{"lines":{"count":100,"covered":78,"percent":78.0}}}]}"#;
fs::write(cov_dir.join("server.json"), json_content).unwrap();
let pct = read_coverage_percent_from_json(tmp.path());
+14 -7
View File
@@ -153,7 +153,8 @@ pub(super) async fn tool_prompt_permission(
// Try to forward to the interactive session (WebSocket/Matrix).
// If no session is active (headless agent), auto-deny the permission.
if ctx.perm_tx
if ctx
.perm_tx
.send(crate::http::context::PermissionForward {
request_id: request_id.clone(),
tool_name: tool_name.clone(),
@@ -321,8 +322,8 @@ pub(super) fn tool_dump_crdt(args: &Value) -> Result<String, String> {
/// MCP tool: return the server version and build hash.
pub(super) fn tool_get_version() -> Result<String, String> {
let build_hash = std::fs::read_to_string(".huskies/build_hash")
.unwrap_or_else(|_| "unknown".to_string());
let build_hash =
std::fs::read_to_string(".huskies/build_hash").unwrap_or_else(|_| "unknown".to_string());
serde_json::to_string_pretty(&json!({
"version": env!("CARGO_PKG_VERSION"),
"build_hash": build_hash.trim(),
@@ -338,7 +339,10 @@ pub(super) fn tool_loc_file(args: &Value, ctx: &AppContext) -> Result<String, St
.ok_or_else(|| "Missing required argument: file_path".to_string())?;
let project_root = ctx.state.get_project_root()?;
Ok(crate::chat::commands::loc::loc_single_file(&project_root, file_path))
Ok(crate::chat::commands::loc::loc_single_file(
&project_root,
file_path,
))
}
#[cfg(test)]
@@ -851,8 +855,7 @@ mod tests {
#[test]
fn tool_dump_crdt_with_story_id_filter_returns_valid_json() {
let result =
tool_dump_crdt(&json!({"story_id": "9999_story_nonexistent"})).unwrap();
let result = tool_dump_crdt(&json!({"story_id": "9999_story_nonexistent"})).unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert!(parsed["items"].as_array().unwrap().is_empty());
}
@@ -866,7 +869,11 @@ mod tests {
assert!(tool.is_some(), "dump_crdt missing from tools list");
let t = tool.unwrap();
assert!(
t["description"].as_str().unwrap().to_lowercase().contains("debug"),
t["description"]
.as_str()
.unwrap()
.to_lowercase()
.contains("debug"),
"description must mention this is a debug tool"
);
assert!(t["inputSchema"].is_object());
+26 -45
View File
@@ -1,6 +1,6 @@
//! MCP git tools — status, diff, add, commit, and log operations on agent worktrees.
use crate::http::context::AppContext;
use serde_json::{json, Value};
use serde_json::{Value, json};
use std::path::PathBuf;
/// Validates that `worktree_path` exists and is inside the project's
@@ -12,9 +12,7 @@ fn validate_worktree_path(worktree_path: &str, ctx: &AppContext) -> Result<PathB
return Err("worktree_path must be an absolute path".to_string());
}
if !wd.exists() {
return Err(format!(
"worktree_path does not exist: {worktree_path}"
));
return Err(format!("worktree_path does not exist: {worktree_path}"));
}
let project_root = ctx.agents.get_project_root(&ctx.state)?;
@@ -230,11 +228,7 @@ pub(super) async fn tool_git_commit(args: &Value, ctx: &AppContext) -> Result<St
let dir = validate_worktree_path(worktree_path, ctx)?;
let git_args: Vec<String> = vec![
"commit".to_string(),
"--message".to_string(),
message,
];
let git_args: Vec<String> = vec!["commit".to_string(), "--message".to_string(), message];
let output = run_git_owned(git_args, dir).await?;
@@ -412,12 +406,9 @@ mod tests {
.output()
.unwrap();
let result = tool_git_status(
&json!({"worktree_path": story_wt.to_str().unwrap()}),
&ctx,
)
.await
.unwrap();
let result = tool_git_status(&json!({"worktree_path": story_wt.to_str().unwrap()}), &ctx)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["clean"], true);
@@ -446,18 +437,17 @@ mod tests {
// Add untracked file
std::fs::write(story_wt.join("new_file.txt"), "content").unwrap();
let result = tool_git_status(
&json!({"worktree_path": story_wt.to_str().unwrap()}),
&ctx,
)
.await
.unwrap();
let result = tool_git_status(&json!({"worktree_path": story_wt.to_str().unwrap()}), &ctx)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["clean"], false);
let untracked = parsed["untracked"].as_array().unwrap();
assert!(
untracked.iter().any(|v| v.as_str().unwrap().contains("new_file.txt")),
untracked
.iter()
.any(|v| v.as_str().unwrap().contains("new_file.txt")),
"expected new_file.txt in untracked: {parsed}"
);
}
@@ -493,12 +483,9 @@ mod tests {
// Modify file (unstaged)
std::fs::write(story_wt.join("file.txt"), "line1\nline2\n").unwrap();
let result = tool_git_diff(
&json!({"worktree_path": story_wt.to_str().unwrap()}),
&ctx,
)
.await
.unwrap();
let result = tool_git_diff(&json!({"worktree_path": story_wt.to_str().unwrap()}), &ctx)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(
@@ -560,11 +547,8 @@ mod tests {
#[tokio::test]
async fn git_add_missing_paths() {
let (_tmp, story_wt, ctx) = setup_worktree();
let result = tool_git_add(
&json!({"worktree_path": story_wt.to_str().unwrap()}),
&ctx,
)
.await;
let result =
tool_git_add(&json!({"worktree_path": story_wt.to_str().unwrap()}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("paths"));
}
@@ -609,7 +593,10 @@ mod tests {
.output()
.unwrap();
let output = String::from_utf8_lossy(&status.stdout);
assert!(output.contains("A file.txt"), "file should be staged: {output}");
assert!(
output.contains("A file.txt"),
"file should be staged: {output}"
);
}
// ── git_commit ────────────────────────────────────────────────────
@@ -626,11 +613,8 @@ mod tests {
#[tokio::test]
async fn git_commit_missing_message() {
let (_tmp, story_wt, ctx) = setup_worktree();
let result = tool_git_commit(
&json!({"worktree_path": story_wt.to_str().unwrap()}),
&ctx,
)
.await;
let result =
tool_git_commit(&json!({"worktree_path": story_wt.to_str().unwrap()}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("message"));
}
@@ -713,12 +697,9 @@ mod tests {
.output()
.unwrap();
let result = tool_git_log(
&json!({"worktree_path": story_wt.to_str().unwrap()}),
&ctx,
)
.await
.unwrap();
let result = tool_git_log(&json!({"worktree_path": story_wt.to_str().unwrap()}), &ctx)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["exit_code"], 0);
+33 -28
View File
@@ -4,7 +4,7 @@ use crate::http::context::AppContext;
use crate::io::story_metadata::write_merge_failure;
use crate::slog;
use crate::slog_warn;
use serde_json::{json, Value};
use serde_json::{Value, json};
pub(super) fn tool_merge_agent_work(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
@@ -38,14 +38,12 @@ fn tool_get_merge_status_inner(
job: &crate::agents::merge::MergeJob,
) -> Result<String, String> {
match &job.status {
crate::agents::merge::MergeJobStatus::Running => {
serde_json::to_string_pretty(&json!({
"story_id": story_id,
"status": "running",
"message": "Merge pipeline is still running."
}))
.map_err(|e| format!("Serialization error: {e}"))
}
crate::agents::merge::MergeJobStatus::Running => serde_json::to_string_pretty(&json!({
"story_id": story_id,
"status": "running",
"message": "Merge pipeline is still running."
}))
.map_err(|e| format!("Serialization error: {e}")),
crate::agents::merge::MergeJobStatus::Completed(report) => {
serde_json::to_string_pretty(&json!({
"story_id": story_id,
@@ -58,14 +56,12 @@ fn tool_get_merge_status_inner(
}))
.map_err(|e| format!("Serialization error: {e}"))
}
crate::agents::merge::MergeJobStatus::Failed(err) => {
serde_json::to_string_pretty(&json!({
"story_id": story_id,
"status": "failed",
"error": err,
}))
.map_err(|e| format!("Serialization error: {e}"))
}
crate::agents::merge::MergeJobStatus::Failed(err) => serde_json::to_string_pretty(&json!({
"story_id": story_id,
"status": "failed",
"error": err,
}))
.map_err(|e| format!("Serialization error: {e}")),
}
}
@@ -75,8 +71,9 @@ pub(super) fn tool_get_merge_status(args: &Value, ctx: &AppContext) -> Result<St
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let job = ctx.agents.get_merge_status(story_id)
.ok_or_else(|| format!("No merge job found for story '{story_id}'. Call merge_agent_work first."))?;
let job = ctx.agents.get_merge_status(story_id).ok_or_else(|| {
format!("No merge job found for story '{story_id}'. Call merge_agent_work first.")
})?;
match &job.status {
crate::agents::merge::MergeJobStatus::Running => {
@@ -127,7 +124,10 @@ pub(super) fn tool_get_merge_status(args: &Value, ctx: &AppContext) -> Result<St
}
}
pub(super) async fn tool_move_story_to_merge(args: &Value, ctx: &AppContext) -> Result<String, String> {
pub(super) async fn tool_move_story_to_merge(
args: &Value,
ctx: &AppContext,
) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
@@ -176,10 +176,12 @@ pub(super) fn tool_report_merge_failure(args: &Value, ctx: &AppContext) -> Resul
// Broadcast the failure so the Matrix notification listener can post an
// error message to configured rooms without coupling this tool to the bot.
let _ = ctx.watcher_tx.send(crate::io::watcher::WatcherEvent::MergeFailure {
story_id: story_id.to_string(),
reason: reason.to_string(),
});
let _ = ctx
.watcher_tx
.send(crate::io::watcher::WatcherEvent::MergeFailure {
story_id: story_id.to_string(),
reason: reason.to_string(),
});
// Persist the failure reason to the story file's front matter so it
// survives server restarts and is visible in the web UI.
@@ -238,7 +240,7 @@ mod tests {
#[test]
fn merge_agent_work_in_tools_list() {
use super::super::{handle_tools_list};
use super::super::handle_tools_list;
let resp = handle_tools_list(Some(json!(1)));
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
let tool = tools.iter().find(|t| t["name"] == "merge_agent_work");
@@ -254,11 +256,14 @@ mod tests {
#[test]
fn move_story_to_merge_in_tools_list() {
use super::super::{handle_tools_list};
use super::super::handle_tools_list;
let resp = handle_tools_list(Some(json!(1)));
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
let tool = tools.iter().find(|t| t["name"] == "move_story_to_merge");
assert!(tool.is_some(), "move_story_to_merge missing from tools list");
assert!(
tool.is_some(),
"move_story_to_merge missing from tools list"
);
let t = tool.unwrap();
assert!(t["description"].is_string());
let required = t["inputSchema"]["required"].as_array().unwrap();
@@ -338,7 +343,7 @@ mod tests {
#[test]
fn report_merge_failure_in_tools_list() {
use super::super::{handle_tools_list};
use super::super::handle_tools_list;
let resp = handle_tools_list(Some(json!(1)));
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
let tool = tools.iter().find(|t| t["name"] == "report_merge_failure");
+48 -28
View File
@@ -1,12 +1,12 @@
//! MCP server — Model Context Protocol endpoint dispatching tool calls to handlers.
use crate::slog_warn;
use crate::http::context::AppContext;
use crate::slog_warn;
use poem::handler;
use poem::http::StatusCode;
use poem::web::Data;
use poem::{Body, Request, Response};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use serde_json::{Value, json};
use std::sync::Arc;
pub mod agent_tools;
@@ -1212,15 +1212,8 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
// ── Tool dispatch ─────────────────────────────────────────────────
async fn handle_tools_call(
id: Option<Value>,
params: &Value,
ctx: &AppContext,
) -> JsonRpcResponse {
let tool_name = params
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("");
async fn handle_tools_call(id: Option<Value>, params: &Value, ctx: &AppContext) -> JsonRpcResponse {
let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or(json!({}));
let result = match tool_name {
@@ -1460,7 +1453,12 @@ mod tests {
));
let result = resp.result.unwrap();
assert_eq!(result["isError"], true);
assert!(result["content"][0]["text"].as_str().unwrap().contains("Unknown tool"));
assert!(
result["content"][0]["text"]
.as_str()
.unwrap()
.contains("Unknown tool")
);
}
#[test]
@@ -1572,7 +1570,10 @@ mod tests {
)
.await;
assert!(
body["error"]["message"].as_str().unwrap_or("").contains("version"),
body["error"]["message"]
.as_str()
.unwrap_or("")
.contains("version"),
"expected version error: {body}"
);
}
@@ -1599,9 +1600,7 @@ mod tests {
let resp = cli
.post("/mcp")
.header("content-type", "application/json")
.body(
r#"{"jsonrpc":"2.0","id":null,"method":"notifications/initialized","params":{}}"#,
)
.body(r#"{"jsonrpc":"2.0","id":null,"method":"notifications/initialized","params":{}}"#)
.send()
.await;
assert_eq!(resp.0.status(), poem::http::StatusCode::ACCEPTED);
@@ -1631,7 +1630,10 @@ mod tests {
)
.await;
assert!(
body["error"]["message"].as_str().unwrap_or("").contains("Unknown method"),
body["error"]["message"]
.as_str()
.unwrap_or("")
.contains("Unknown method"),
"expected unknown method error: {body}"
);
}
@@ -1719,14 +1721,21 @@ mod tests {
let body = resp.0.into_body().into_string().await.unwrap();
// Body is SSE-wrapped: "data: {…}\n\n" — strip the prefix and verify it's
// a valid JSON-RPC result (not an error about missing agent_name).
let json_part = body.trim_start_matches("data: ").trim_end_matches("\n\n").trim();
let json_part = body
.trim_start_matches("data: ")
.trim_end_matches("\n\n")
.trim();
let parsed: serde_json::Value = serde_json::from_str(json_part)
.unwrap_or_else(|_| panic!("expected JSON-RPC in SSE body, got: {body}"));
assert!(parsed.get("result").is_some(),
"expected JSON-RPC result (disk-based handler ran): {parsed}");
assert!(
parsed.get("result").is_some(),
"expected JSON-RPC result (disk-based handler ran): {parsed}"
);
// Must NOT be an error about missing agent_name (agent_name is now optional)
assert!(parsed.get("error").is_none(),
"unexpected error when agent_name omitted: {parsed}");
assert!(
parsed.get("error").is_none(),
"unexpected error when agent_name omitted: {parsed}"
);
}
#[tokio::test]
@@ -1749,8 +1758,14 @@ mod tests {
let body = resp.0.into_body().into_string().await.unwrap();
assert!(body.contains("data:"), "expected SSE data prefix: {body}");
// Must NOT return isError — should be a success result with "No log files found"
assert!(!body.contains("isError"), "expected no isError for missing agent: {body}");
assert!(body.contains("No log files found"), "expected not-found message: {body}");
assert!(
!body.contains("isError"),
"expected no isError for missing agent: {body}"
);
assert!(
body.contains("No log files found"),
"expected not-found message: {body}"
);
}
#[tokio::test]
@@ -1760,8 +1775,7 @@ mod tests {
// Agent has exited (not in pool) but wrote logs to disk.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mut writer =
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-sse").unwrap();
let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-sse").unwrap();
writer
.write_event(&AgentEvent::Output {
story_id: "42_story_foo".to_string(),
@@ -1781,7 +1795,13 @@ mod tests {
.send()
.await;
let body = resp.0.into_body().into_string().await.unwrap();
assert!(body.contains("disk output"), "expected disk log content in SSE response: {body}");
assert!(!body.contains("isError"), "expected no error for exited agent with logs: {body}");
assert!(
body.contains("disk output"),
"expected disk log content in SSE response: {body}"
);
assert!(
!body.contains("isError"),
"expected no error for exited agent with logs: {body}"
);
}
}
+16 -13
View File
@@ -1,5 +1,7 @@
//! MCP QA tools — request, approve, and reject QA reviews for stories.
use crate::agents::{move_story_to_done, move_story_to_merge, move_story_to_qa, reject_story_from_qa};
use crate::agents::{
move_story_to_done, move_story_to_merge, move_story_to_qa, reject_story_from_qa,
};
use crate::http::context::AppContext;
use crate::slog;
use crate::slog_warn;
@@ -63,11 +65,10 @@ pub(super) async fn tool_approve_qa(args: &Value, ctx: &AppContext) -> Result<St
let root = project_root.clone();
let br = branch.clone();
let sid = story_id.to_string();
let merge_ok = tokio::task::spawn_blocking(move || {
merge_spike_branch_to_master(&root, &br, &sid)
})
.await
.map_err(|e| format!("Merge task panicked: {e}"))??;
let merge_ok =
tokio::task::spawn_blocking(move || merge_spike_branch_to_master(&root, &br, &sid))
.await
.map_err(|e| format!("Merge task panicked: {e}"))??;
move_story_to_done(&project_root, story_id)?;
@@ -77,12 +78,8 @@ pub(super) async fn tool_approve_qa(args: &Value, ctx: &AppContext) -> Result<St
let wt_path = crate::worktree::worktree_path(&project_root, story_id);
if wt_path.exists() {
let config = crate::config::ProjectConfig::load(&project_root).unwrap_or_default();
let _ = crate::worktree::remove_worktree_by_story_id(
&project_root,
story_id,
&config,
)
.await;
let _ = crate::worktree::remove_worktree_by_story_id(&project_root, story_id, &config)
.await;
}
pool.auto_assign_available_work(&project_root).await;
@@ -222,7 +219,13 @@ pub(super) async fn tool_reject_qa(args: &Value, ctx: &AppContext) -> Result<Str
);
if let Err(e) = ctx
.agents
.start_agent(&project_root, story_id, Some(agent_name), Some(&context), None)
.start_agent(
&project_root,
story_id,
Some(agent_name),
Some(&context),
None,
)
.await
{
slog_warn!("[qa] Failed to restart coder for '{story_id}' after rejection: {e}");
+49 -48
View File
@@ -3,7 +3,7 @@ use crate::http::context::AppContext;
use bytes::Bytes;
use futures::StreamExt;
use poem::{Body, Response};
use serde_json::{json, Value};
use serde_json::{Value, json};
use std::path::PathBuf;
const DEFAULT_TIMEOUT_SECS: u64 = 120;
@@ -25,13 +25,7 @@ static BLOCKED_PATTERNS: &[&str] = &[
/// Binaries that are unconditionally blocked.
static BLOCKED_BINARIES: &[&str] = &[
"sudo",
"su",
"shutdown",
"reboot",
"halt",
"poweroff",
"mkfs",
"sudo", "su", "shutdown", "reboot", "halt", "poweroff", "mkfs",
];
/// Returns an error message if the command matches a blocked pattern or binary.
@@ -153,15 +147,13 @@ pub(super) async fn tool_run_command(args: &Value, ctx: &AppContext) -> Result<S
}
Ok(Err(e)) => Err(format!("Task join error: {e}")),
Ok(Ok(Err(e))) => Err(format!("Failed to execute command: {e}")),
Ok(Ok(Ok(output))) => {
serde_json::to_string_pretty(&json!({
"stdout": String::from_utf8_lossy(&output.stdout),
"stderr": String::from_utf8_lossy(&output.stderr),
"exit_code": output.status.code().unwrap_or(-1),
"timed_out": false,
}))
.map_err(|e| format!("Serialization error: {e}"))
}
Ok(Ok(Ok(output))) => serde_json::to_string_pretty(&json!({
"stdout": String::from_utf8_lossy(&output.stdout),
"stderr": String::from_utf8_lossy(&output.stderr),
"exit_code": output.status.code().unwrap_or(-1),
"timed_out": false,
}))
.map_err(|e| format!("Serialization error: {e}")),
}
}
@@ -172,7 +164,7 @@ pub(super) fn handle_run_command_sse(
params: &Value,
ctx: &AppContext,
) -> Response {
use super::{to_sse_response, JsonRpcResponse};
use super::{JsonRpcResponse, to_sse_response};
let args = params.get("arguments").cloned().unwrap_or(json!({}));
@@ -183,7 +175,7 @@ pub(super) fn handle_run_command_sse(
id,
-32602,
"Missing required argument: command".into(),
))
));
}
};
@@ -194,7 +186,7 @@ pub(super) fn handle_run_command_sse(
id,
-32602,
"Missing required argument: working_dir".into(),
))
));
}
};
@@ -326,9 +318,7 @@ pub(super) fn handle_run_command_sse(
.status(poem::http::StatusCode::OK)
.header("Content-Type", "text/event-stream")
.header("Cache-Control", "no-cache")
.body(Body::from_bytes_stream(stream.map(|r| {
r.map(Bytes::from)
})))
.body(Body::from_bytes_stream(stream.map(|r| r.map(Bytes::from))))
}
/// Truncate output to at most `max_lines` lines, keeping the tail.
@@ -364,7 +354,11 @@ fn parse_test_counts(output: &str) -> (u64, u64) {
fn extract_count(line: &str, label: &str) -> Option<u64> {
let pos = line.find(label)?;
let before = line[..pos].trim_end();
let num_str: String = before.chars().rev().take_while(|c| c.is_ascii_digit()).collect();
let num_str: String = before
.chars()
.rev()
.take_while(|c| c.is_ascii_digit())
.collect();
if num_str.is_empty() {
return None;
}
@@ -391,10 +385,7 @@ pub(super) async fn tool_run_tests(args: &Value, ctx: &AppContext) -> Result<Str
let script_path = working_dir.join("script").join("test");
if !script_path.exists() {
return Err(format!(
"Test script not found: {}",
script_path.display()
));
return Err(format!("Test script not found: {}", script_path.display()));
}
// Kill any existing test job for this worktree.
@@ -503,10 +494,7 @@ const TEST_POLL_BLOCK_SECS: u64 = 20;
/// Blocks for up to 15 seconds, checking every second. Returns immediately
/// when the test finishes, or after 15s with `{"status": "running"}`.
/// This server-side blocking prevents agents from wasting turns polling.
pub(super) async fn tool_get_test_result(
args: &Value,
ctx: &AppContext,
) -> Result<String, String> {
pub(super) async fn tool_get_test_result(args: &Value, ctx: &AppContext) -> Result<String, String> {
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let working_dir = match args.get("worktree_path").and_then(|v| v.as_str()) {
@@ -703,9 +691,7 @@ pub(super) async fn tool_run_lint(args: &Value, ctx: &AppContext) -> Result<Stri
}
/// Format a `TestJobResult` as the JSON string returned to the agent.
fn format_test_result(
result: &crate::http::context::TestJobResult,
) -> Result<String, String> {
fn format_test_result(result: &crate::http::context::TestJobResult) -> Result<String, String> {
serde_json::to_string_pretty(&json!({
"passed": result.passed,
"exit_code": result.exit_code,
@@ -854,11 +840,8 @@ mod tests {
async fn tool_run_command_blocks_dangerous_command() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_run_command(
&json!({"command": "rm -rf /", "working_dir": "/tmp"}),
&ctx,
)
.await;
let result =
tool_run_command(&json!({"command": "rm -rf /", "working_dir": "/tmp"}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("blocked"));
}
@@ -1017,7 +1000,10 @@ mod tests {
let ctx = test_ctx(tmp.path());
// No script/test in tmp — should return Err
let result = tool_run_tests(&json!({}), &ctx).await;
assert!(result.is_err(), "expected error for missing script: {result:?}");
assert!(
result.is_err(),
"expected error for missing script: {result:?}"
);
assert!(
result.unwrap_err().contains("not found"),
"error should mention 'not found'"
@@ -1073,8 +1059,11 @@ mod tests {
std::fs::create_dir_all(&wt_dir).unwrap();
let ctx = test_ctx(tmp.path());
// tmp.path() itself is outside worktrees → should fail validation
let result =
tool_run_tests(&json!({"worktree_path": tmp.path().to_str().unwrap()}), &ctx).await;
let result = tool_run_tests(
&json!({"worktree_path": tmp.path().to_str().unwrap()}),
&ctx,
)
.await;
assert!(result.is_err());
assert!(
result.unwrap_err().contains("worktrees"),
@@ -1118,8 +1107,11 @@ mod tests {
let wt_dir = tmp.path().join(".huskies").join("worktrees");
std::fs::create_dir_all(&wt_dir).unwrap();
let ctx = test_ctx(tmp.path());
let result =
tool_run_build(&json!({"worktree_path": tmp.path().to_str().unwrap()}), &ctx).await;
let result = tool_run_build(
&json!({"worktree_path": tmp.path().to_str().unwrap()}),
&ctx,
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("worktrees"));
}
@@ -1184,9 +1176,18 @@ mod tests {
let lines: Vec<String> = (1..=200).map(|i| format!("line {i}")).collect();
let text = lines.join("\n");
let result = truncate_output(&text, 50);
assert!(result.contains("line 200"), "should keep last line: {result}");
assert!(result.contains("omitted"), "should note omitted lines: {result}");
assert!(!result.contains("line 1\n"), "should not keep first line: {result}");
assert!(
result.contains("line 200"),
"should keep last line: {result}"
);
assert!(
result.contains("omitted"),
"should note omitted lines: {result}"
);
assert!(
!result.contains("line 1\n"),
"should not keep first line: {result}"
);
}
// ── parse_test_counts ─────────────────────────────────────────────
+8 -10
View File
@@ -20,7 +20,10 @@ fn parse_ac_items(contents: &str) -> Vec<(String, bool)> {
break;
}
if in_ac_section {
if let Some(rest) = trimmed.strip_prefix("- [x] ").or(trimmed.strip_prefix("- [X] ")) {
if let Some(rest) = trimmed
.strip_prefix("- [x] ")
.or(trimmed.strip_prefix("- [X] "))
{
items.push((rest.to_string(), true));
} else if let Some(rest) = trimmed.strip_prefix("- [ ] ") {
items.push((rest.to_string(), false));
@@ -33,10 +36,7 @@ fn parse_ac_items(contents: &str) -> Vec<(String, bool)> {
/// Find the most recent log file for any agent under `.huskies/logs/{story_id}/`.
fn find_most_recent_log(project_root: &Path, story_id: &str) -> Option<PathBuf> {
let dir = project_root
.join(".huskies")
.join("logs")
.join(story_id);
let dir = project_root.join(".huskies").join("logs").join(story_id);
if !dir.is_dir() {
return None;
@@ -68,8 +68,7 @@ fn find_most_recent_log(project_root: &Path, story_id: &str) -> Option<PathBuf>
/// Return the last N raw lines from a file.
fn last_n_lines(path: &Path, n: usize) -> Result<Vec<String>, String> {
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read log file: {e}"))?;
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read log file: {e}"))?;
let lines: Vec<String> = content
.lines()
.rev()
@@ -172,9 +171,8 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
));
}
let contents = crate::db::read_content(story_id).ok_or_else(|| {
format!("Story '{story_id}' has no content in the content store.")
})?;
let contents = crate::db::read_content(story_id)
.ok_or_else(|| format!("Story '{story_id}' has no content in the content store."))?;
// --- Front matter ---
let mut front_matter = serde_json::Map::new();
+85 -51
View File
@@ -8,7 +8,9 @@ use crate::http::workflow::{
create_spike_file, create_story_file, list_bug_files, list_refactor_files, load_pipeline_state,
load_upcoming_stories, update_story_in_file, validate_story_dirs,
};
use crate::io::story_metadata::{check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos};
use crate::io::story_metadata::{
check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos,
};
use crate::slog_warn;
use crate::workflow::{TestCaseResult, TestStatus, evaluate_acceptance_with_coverage};
use serde_json::{Value, json};
@@ -496,7 +498,10 @@ pub(super) fn tool_unblock_story(args: &Value, ctx: &AppContext) -> Result<Strin
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.ok_or_else(|| format!("Invalid story_id format: '{story_id}'. Expected a numeric prefix (e.g. '42_story_foo')."))?;
Ok(crate::chat::commands::unblock::unblock_by_number(&root, story_number))
Ok(crate::chat::commands::unblock::unblock_by_number(
&root,
story_number,
))
}
pub(super) async fn tool_delete_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
@@ -549,8 +554,7 @@ pub(super) async fn tool_delete_story(args: &Value, ctx: &AppContext) -> Result<
// 3. Remove worktree (best-effort).
if let Ok(config) = crate::config::ProjectConfig::load(&project_root) {
match crate::worktree::remove_worktree_by_story_id(&project_root, story_id, &config).await
{
match crate::worktree::remove_worktree_by_story_id(&project_root, story_id, &config).await {
Ok(()) => slog_warn!("[delete_story] Removed worktree for '{story_id}'"),
Err(e) => slog_warn!("[delete_story] Worktree removal for '{story_id}': {e}"),
}
@@ -573,7 +577,10 @@ pub(super) async fn tool_delete_story(args: &Value, ctx: &AppContext) -> Result<
// 5. Delete from database content store and shadow table.
let found_in_db = crate::db::read_content(story_id).is_some()
|| crate::pipeline_state::read_typed(story_id).ok().flatten().is_some();
|| crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
.is_some();
crate::db::delete_item(story_id);
slog_warn!("[delete_story] Deleted '{story_id}' from content store / shadow table");
@@ -599,7 +606,9 @@ pub(super) async fn tool_delete_story(args: &Value, ctx: &AppContext) -> Result<
deleted_from_fs = true;
}
Err(e) => {
slog_warn!("[delete_story] Failed to delete filesystem shadow '{story_id}' from work/{stage}/: {e}");
slog_warn!(
"[delete_story] Failed to delete filesystem shadow '{story_id}' from work/{stage}/: {e}"
);
failed_steps.push(format!("delete_filesystem({stage}): {e}"));
}
}
@@ -820,7 +829,10 @@ mod tests {
.unwrap();
assert!(result.contains("Created story:"));
let story_id = result.trim_start_matches("Created story: ").trim().to_string();
let story_id = result
.trim_start_matches("Created story: ")
.trim()
.to_string();
let content = crate::db::read_content(&story_id).expect("story content should exist");
assert!(
content.contains("## Description"),
@@ -844,11 +856,7 @@ mod tests {
("4_merge", "9940_story_merge", "Merge Story"),
("5_done", "9950_story_done", "Done Story"),
] {
crate::db::write_item_with_content(
id,
stage,
&format!("---\nname: \"{name}\"\n---\n"),
);
crate::db::write_item_with_content(id, stage, &format!("---\nname: \"{name}\"\n---\n"));
}
let ctx = test_ctx(tmp.path());
@@ -869,7 +877,9 @@ mod tests {
// Backlog should contain our item
let backlog = parsed["backlog"].as_array().unwrap();
assert!(
backlog.iter().any(|b| b["story_id"] == "9910_story_upcoming"),
backlog
.iter()
.any(|b| b["story_id"] == "9910_story_upcoming"),
"expected 9910_story_upcoming in backlog: {backlog:?}"
);
}
@@ -896,7 +906,9 @@ mod tests {
let parsed: Value = serde_json::from_str(&result).unwrap();
let active = parsed["active"].as_array().unwrap();
let item = active.iter().find(|i| i["story_id"] == "9921_story_active")
let item = active
.iter()
.find(|i| i["story_id"] == "9921_story_active")
.expect("expected 9921_story_active in active items");
assert_eq!(item["stage"], "current");
assert!(!item["agent"].is_null(), "agent should be present");
@@ -1115,7 +1127,10 @@ mod tests {
)
.unwrap();
assert!(result.contains("_bug_login_crash"), "result should contain bug ID: {result}");
assert!(
result.contains("_bug_login_crash"),
"result should contain bug ID: {result}"
);
// Extract the actual bug ID from the result message (format: "Created bug: <id>").
let bug_id = result.trim_start_matches("Created bug: ").trim();
// Bug content should exist in the CRDT content store.
@@ -1157,11 +1172,15 @@ mod tests {
let result = tool_list_bugs(&ctx).unwrap();
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
assert!(
parsed.iter().any(|b| b["bug_id"] == "9902_bug_crash" && b["name"] == "App Crash"),
parsed
.iter()
.any(|b| b["bug_id"] == "9902_bug_crash" && b["name"] == "App Crash"),
"expected 9902_bug_crash in bugs list: {parsed:?}"
);
assert!(
parsed.iter().any(|b| b["bug_id"] == "9903_bug_typo" && b["name"] == "Typo in Header"),
parsed
.iter()
.any(|b| b["bug_id"] == "9903_bug_typo" && b["name"] == "Typo in Header"),
"expected 9903_bug_typo in bugs list: {parsed:?}"
);
}
@@ -1252,12 +1271,14 @@ mod tests {
)
.unwrap();
assert!(result.contains("_spike_compare_encoders"), "result should contain spike ID: {result}");
assert!(
result.contains("_spike_compare_encoders"),
"result should contain spike ID: {result}"
);
// Extract the actual spike ID from the result message (format: "Created spike: <id>").
let spike_id = result.trim_start_matches("Created spike: ").trim();
// Spike content should exist in the CRDT content store.
let contents = crate::db::read_content(spike_id)
.expect("expected spike content in CRDT");
let contents = crate::db::read_content(spike_id).expect("expected spike content in CRDT");
assert!(contents.starts_with("---\nname: \"Compare Encoders\"\n---"));
assert!(contents.contains("Which encoder is fastest?"));
}
@@ -1268,13 +1289,15 @@ mod tests {
let ctx = test_ctx(tmp.path());
let result = tool_create_spike(&json!({"name": "My Spike"}), &ctx).unwrap();
assert!(result.contains("_spike_my_spike"), "result should contain spike ID: {result}");
assert!(
result.contains("_spike_my_spike"),
"result should contain spike ID: {result}"
);
// Extract the actual spike ID from the result message (format: "Created spike: <id>").
let spike_id = result.trim_start_matches("Created spike: ").trim();
// Spike content should exist in the CRDT content store.
let contents = crate::db::read_content(spike_id)
.expect("expected spike content in CRDT");
let contents = crate::db::read_content(spike_id).expect("expected spike content in CRDT");
assert!(contents.starts_with("---\nname: \"My Spike\"\n---"));
assert!(contents.contains("## Question\n\n- TBD\n"));
}
@@ -1326,7 +1349,9 @@ mod tests {
let ctx = test_ctx(tmp.path());
let result = tool_validate_stories(&ctx).unwrap();
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
let item = parsed.iter().find(|v| v["story_id"] == "9907_test")
let item = parsed
.iter()
.find(|v| v["story_id"] == "9907_test")
.expect("expected 9907_test in validation results");
assert_eq!(item["valid"], true);
}
@@ -1336,16 +1361,14 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"9908_test",
"2_current",
"## No front matter at all\n",
);
crate::db::write_item_with_content("9908_test", "2_current", "## No front matter at all\n");
let ctx = test_ctx(tmp.path());
let result = tool_validate_stories(&ctx).unwrap();
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
let item = parsed.iter().find(|v| v["story_id"] == "9908_test")
let item = parsed
.iter()
.find(|v| v["story_id"] == "9908_test")
.expect("expected 9908_test in validation results");
assert_eq!(item["valid"], false);
}
@@ -1551,11 +1574,7 @@ mod tests {
let current_dir = tmp.path().join(".huskies/work/2_current");
std::fs::create_dir_all(&current_dir).unwrap();
let content = "---\nname: No Branch\n---\n";
std::fs::write(
current_dir.join("51_story_no_branch.md"),
content,
)
.unwrap();
std::fs::write(current_dir.join("51_story_no_branch.md"), content).unwrap();
crate::db::ensure_content_store();
crate::db::write_content("51_story_no_branch", content);
@@ -1594,8 +1613,14 @@ mod tests {
assert!(result.is_ok(), "Expected ok: {result:?}");
let content = crate::db::read_content("504_bool_test").unwrap();
assert!(content.contains("blocked: false"), "bool should be unquoted: {content}");
assert!(!content.contains("blocked: \"false\""), "bool must not be quoted: {content}");
assert!(
content.contains("blocked: false"),
"bool should be unquoted: {content}"
);
assert!(
!content.contains("blocked: \"false\""),
"bool must not be quoted: {content}"
);
}
#[test]
@@ -1615,8 +1640,14 @@ mod tests {
assert!(result.is_ok(), "Expected ok: {result:?}");
let content = crate::db::read_content("504_num_test").unwrap();
assert!(content.contains("retry_count: 3"), "number should be unquoted: {content}");
assert!(!content.contains("retry_count: \"3\""), "number must not be quoted: {content}");
assert!(
content.contains("retry_count: 3"),
"number should be unquoted: {content}"
);
assert!(
!content.contains("retry_count: \"3\""),
"number must not be quoted: {content}"
);
}
#[test]
@@ -1637,8 +1668,14 @@ mod tests {
let content = crate::db::read_content("504_arr_test").unwrap();
// YAML inline sequences use spaces after commas
assert!(content.contains("depends_on: [490, 491]"), "array should be unquoted YAML: {content}");
assert!(!content.contains("depends_on: \""), "array must not be quoted: {content}");
assert!(
content.contains("depends_on: [490, 491]"),
"array should be unquoted YAML: {content}"
);
assert!(
!content.contains("depends_on: \""),
"array must not be quoted: {content}"
);
// The YAML must be parseable as a vec
let meta = crate::io::story_metadata::parse_front_matter(&content)
@@ -1677,8 +1714,10 @@ mod tests {
);
let ctx = test_ctx(tmp.path());
let result =
tool_check_criterion(&json!({"story_id": "9904_test", "criterion_index": 0}), &ctx);
let result = tool_check_criterion(
&json!({"story_id": "9904_test", "criterion_index": 0}),
&ctx,
);
assert!(result.is_ok(), "Expected ok: {result:?}");
assert!(result.unwrap().contains("Criterion 0 checked"));
}
@@ -1719,11 +1758,8 @@ mod tests {
assert_eq!(ctx.timer_store.list().len(), 1);
// Delete the story.
let result = tool_delete_story(
&json!({"story_id": "478_story_rate_limit_repro"}),
&ctx,
)
.await;
let result =
tool_delete_story(&json!({"story_id": "478_story_rate_limit_repro"}), &ctx).await;
assert!(result.is_ok(), "delete_story failed: {result:?}");
// Timer must be gone — fast-forwarding past the scheduled time should
@@ -1741,9 +1777,7 @@ mod tests {
// Filesystem shadow must also be gone.
assert!(
!backlog
.join("478_story_rate_limit_repro.md")
.exists(),
!backlog.join("478_story_rate_limit_repro.md").exists(),
"filesystem shadow was not removed"
);
}
+20 -26
View File
@@ -24,7 +24,10 @@ use std::path::Path;
/// Returns `None` for `Scaffold` since that step has no single output file — it
/// creates the full `.huskies/` directory structure and is handled by
/// `huskies init` before the server starts.
pub(crate) fn step_output_path(project_root: &Path, step: WizardStep) -> Option<std::path::PathBuf> {
pub(crate) fn step_output_path(
project_root: &Path,
step: WizardStep,
) -> Option<std::path::PathBuf> {
match step {
WizardStep::Context => Some(
project_root
@@ -58,7 +61,11 @@ pub(crate) fn is_script_step(step: WizardStep) -> bool {
/// Existing files (including `CLAUDE.md`) are never overwritten — the wizard
/// appends or skips per the acceptance criteria. For script steps the file is
/// also made executable after writing.
pub(crate) fn write_if_missing(path: &Path, content: &str, executable: bool) -> Result<bool, String> {
pub(crate) fn write_if_missing(
path: &Path,
content: &str,
executable: bool,
) -> Result<bool, String> {
if path.exists() {
return Ok(false); // already present — skip silently
}
@@ -66,8 +73,7 @@ pub(crate) fn write_if_missing(path: &Path, content: &str, executable: bool) ->
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {e}", parent.display()))?;
}
fs::write(path, content)
.map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
if executable {
#[cfg(unix)]
@@ -186,7 +192,8 @@ pub(crate) fn generation_hint(step: WizardStep, project_root: &Path) -> String {
- High-level goal of the project\n\
- Core features\n\
- Domain concepts and entities\n\
- Glossary of abbreviations and technical terms".to_string()
- Glossary of abbreviations and technical terms"
.to_string()
} else {
"Read the project source tree and generate a `.huskies/specs/00_CONTEXT.md` describing:\n\
- High-level goal of the project\n\
@@ -262,7 +269,9 @@ pub(crate) fn generation_hint(step: WizardStep, project_root: &Path) -> String {
"Generate a `script/test_coverage` shell script (#!/usr/bin/env bash, set -euo pipefail) that generates a test coverage report (e.g. `cargo llvm-cov nextest` or `npm run coverage`).".to_string()
}
}
WizardStep::Scaffold => "Scaffold step is handled automatically by `huskies init`.".to_string(),
WizardStep::Scaffold => {
"Scaffold step is handled automatically by `huskies init`.".to_string()
}
}
}
@@ -427,11 +436,8 @@ mod tests {
fn wizard_generate_with_content_stages_content() {
let dir = TempDir::new().unwrap();
let ctx = setup(&dir);
let result = tool_wizard_generate(
&serde_json::json!({"content": "# My Project"}),
&ctx,
)
.unwrap();
let result =
tool_wizard_generate(&serde_json::json!({"content": "# My Project"}), &ctx).unwrap();
assert!(result.contains("staged"));
let state = WizardState::load(dir.path()).unwrap();
assert_eq!(state.steps[1].status, StepStatus::AwaitingConfirmation);
@@ -443,11 +449,7 @@ mod tests {
let dir = TempDir::new().unwrap();
let ctx = setup(&dir);
// Stage content for Context step.
tool_wizard_generate(
&serde_json::json!({"content": "# Context content"}),
&ctx,
)
.unwrap();
tool_wizard_generate(&serde_json::json!({"content": "# Context content"}), &ctx).unwrap();
let result = tool_wizard_confirm(&ctx).unwrap();
assert!(result.contains("confirmed"));
// File should now exist.
@@ -478,11 +480,7 @@ mod tests {
std::fs::write(&context_path, "original content").unwrap();
// Stage and confirm — existing file should NOT be overwritten.
tool_wizard_generate(
&serde_json::json!({"content": "new content"}),
&ctx,
)
.unwrap();
tool_wizard_generate(&serde_json::json!({"content": "new content"}), &ctx).unwrap();
let result = tool_wizard_confirm(&ctx).unwrap();
assert!(result.contains("already exists"));
assert_eq!(
@@ -507,11 +505,7 @@ mod tests {
let dir = TempDir::new().unwrap();
let ctx = setup(&dir);
// Stage content first.
tool_wizard_generate(
&serde_json::json!({"content": "some content"}),
&ctx,
)
.unwrap();
tool_wizard_generate(&serde_json::json!({"content": "some content"}), &ctx).unwrap();
let result = tool_wizard_retry(&ctx).unwrap();
assert!(result.contains("reset"));
let state = WizardState::load(dir.path()).unwrap();
+3 -6
View File
@@ -2,8 +2,6 @@
pub mod agents;
pub mod agents_sse;
pub mod anthropic;
#[cfg(test)]
pub(crate) mod test_helpers;
pub mod assets;
pub mod bot_command;
pub mod chat;
@@ -14,6 +12,8 @@ pub mod mcp;
pub mod model;
pub mod oauth;
pub mod settings;
#[cfg(test)]
pub(crate) mod test_helpers;
pub mod workflow;
pub mod project;
@@ -95,10 +95,7 @@ pub fn build_routes(
"/callback",
get(oauth::oauth_callback).data(oauth_state.clone()),
)
.at(
"/oauth/status",
get(oauth::oauth_status),
)
.at("/oauth/status", get(oauth::oauth_status))
.at("/debug/crdt", get(debug_crdt_handler))
.at("/assets/*path", get(assets::embedded_asset))
.at("/", get(assets::embedded_index))
+16 -5
View File
@@ -67,14 +67,21 @@ fn compute_code_challenge(verifier: &str) -> String {
/// Base64url-encode without padding (RFC 7636).
fn base64url_encode(data: &[u8]) -> String {
// Standard base64 then convert to base64url
const CHARS: &[u8] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut result = String::new();
let mut i = 0;
while i < data.len() {
let b0 = data[i] as u32;
let b1 = if i + 1 < data.len() { data[i + 1] as u32 } else { 0 };
let b2 = if i + 2 < data.len() { data[i + 2] as u32 } else { 0 };
let b1 = if i + 1 < data.len() {
data[i + 1] as u32
} else {
0
};
let b2 = if i + 2 < data.len() {
data[i + 2] as u32
} else {
0
};
let triple = (b0 << 16) | (b1 << 8) | b2;
result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
@@ -238,7 +245,11 @@ pub async fn oauth_callback(
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
slog!("[oauth] Token exchange response (HTTP {}): {}", status, body);
slog!(
"[oauth] Token exchange response (HTTP {}): {}",
status,
body
);
if !status.is_success() {
return html_response(
+7 -10
View File
@@ -79,7 +79,10 @@ impl SettingsApi {
payload: Json<EditorCommandPayload>,
) -> OpenApiResult<Json<EditorCommandResponse>> {
let editor_command = payload.0.editor_command;
let trimmed = editor_command.as_deref().map(str::trim).filter(|s| !s.is_empty());
let trimmed = editor_command
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
match trimmed {
Some(cmd) => {
self.ctx.store.set(EDITOR_COMMAND_KEY, json!(cmd));
@@ -256,9 +259,7 @@ mod tests {
async fn get_editor_http_handler_returns_null_when_not_set() {
let dir = TempDir::new().unwrap();
let ctx = test_ctx(dir.path());
let api = SettingsApi {
ctx: Arc::new(ctx),
};
let api = SettingsApi { ctx: Arc::new(ctx) };
let result = api.get_editor().await.unwrap().0;
assert!(result.editor_command.is_none());
}
@@ -267,9 +268,7 @@ mod tests {
async fn set_editor_http_handler_stores_value() {
let dir = TempDir::new().unwrap();
let ctx = test_ctx(dir.path());
let api = SettingsApi {
ctx: Arc::new(ctx),
};
let api = SettingsApi { ctx: Arc::new(ctx) };
let result = api
.set_editor(Json(EditorCommandPayload {
editor_command: Some("zed".to_string()),
@@ -284,9 +283,7 @@ mod tests {
async fn set_editor_http_handler_clears_value_when_null() {
let dir = TempDir::new().unwrap();
let ctx = test_ctx(dir.path());
let api = SettingsApi {
ctx: Arc::new(ctx),
};
let api = SettingsApi { ctx: Arc::new(ctx) };
// First set a value
api.set_editor(Json(EditorCommandPayload {
editor_command: Some("code".to_string()),
+9 -9
View File
@@ -259,10 +259,7 @@ mod tests {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client
.post("/wizard/step/context/generating")
.send()
.await;
let resp = client.post("/wizard/step/context/generating").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(body["steps"][1]["status"], "generating");
@@ -273,10 +270,7 @@ mod tests {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client
.post("/wizard/step/nonexistent/confirm")
.send()
.await;
let resp = client.post("/wizard/step/nonexistent/confirm").send().await;
resp.assert_status(StatusCode::NOT_FOUND);
}
@@ -286,7 +280,13 @@ mod tests {
WizardState::init_if_missing(dir.path());
// Steps 2-6 (scaffold is already confirmed)
let steps = ["context", "stack", "test_script", "release_script", "test_coverage"];
let steps = [
"context",
"stack",
"test_script",
"release_script",
"test_coverage",
];
for step in steps {
let resp = client
.post(format!("/wizard/step/{step}/confirm"))
+105 -35
View File
@@ -165,12 +165,16 @@ fn is_bug_item(stem: &str) -> bool {
/// Extract bug name from content (heading or front matter).
fn extract_bug_name_from_content(content: &str) -> Option<String> {
// Try front matter first.
if let Ok(meta) = parse_front_matter(content) && let Some(name) = meta.name {
if let Ok(meta) = parse_front_matter(content)
&& let Some(name) = meta.name
{
return Some(name);
}
// Fallback: heading.
for line in content.lines() {
if let Some(rest) = line.strip_prefix("# Bug ") && let Some(colon_pos) = rest.find(": ") {
if let Some(rest) = line.strip_prefix("# Bug ")
&& let Some(colon_pos) = rest.find(": ")
{
return Some(rest[colon_pos + 2..].to_string());
}
}
@@ -184,16 +188,19 @@ pub fn list_bug_files(_root: &Path) -> Result<Vec<(String, String)>, String> {
let mut bugs = Vec::new();
for item in crate::pipeline_state::read_all_typed() {
if !matches!(item.stage, crate::pipeline_state::Stage::Backlog) || !is_bug_item(&item.story_id.0) {
if !matches!(item.stage, crate::pipeline_state::Stage::Backlog)
|| !is_bug_item(&item.story_id.0)
{
continue;
}
let sid = item.story_id.0;
let name = if item.name.is_empty() { None } else { Some(item.name) }
.or_else(|| {
crate::db::read_content(&sid)
.and_then(|c| extract_bug_name_from_content(&c))
})
.unwrap_or_else(|| sid.clone());
let name = if item.name.is_empty() {
None
} else {
Some(item.name)
}
.or_else(|| crate::db::read_content(&sid).and_then(|c| extract_bug_name_from_content(&c)))
.unwrap_or_else(|| sid.clone());
bugs.push((sid, name));
}
@@ -214,17 +221,23 @@ pub fn list_refactor_files(_root: &Path) -> Result<Vec<(String, String)>, String
let mut refactors = Vec::new();
for item in crate::pipeline_state::read_all_typed() {
if !matches!(item.stage, crate::pipeline_state::Stage::Backlog) || !is_refactor_item(&item.story_id.0) {
if !matches!(item.stage, crate::pipeline_state::Stage::Backlog)
|| !is_refactor_item(&item.story_id.0)
{
continue;
}
let sid = item.story_id.0;
let name = if item.name.is_empty() { None } else { Some(item.name) }
.or_else(|| {
crate::db::read_content(&sid)
.and_then(|c| parse_front_matter(&c).ok())
.and_then(|m| m.name)
})
.unwrap_or_else(|| sid.clone());
let name = if item.name.is_empty() {
None
} else {
Some(item.name)
}
.or_else(|| {
crate::db::read_content(&sid)
.and_then(|c| parse_front_matter(&c).ok())
.and_then(|m| m.name)
})
.unwrap_or_else(|| sid.clone());
refactors.push((sid, name));
}
@@ -278,7 +291,11 @@ mod tests {
fs::write(backlog.join("3_bug_another.md"), "").unwrap();
// Also write to content store so next_item_number sees them.
crate::db::write_item_with_content("1_bug_crash", "1_backlog", "---\nname: Crash\n---\n");
crate::db::write_item_with_content("3_bug_another", "1_backlog", "---\nname: Another\n---\n");
crate::db::write_item_with_content(
"3_bug_another",
"1_backlog",
"---\nname: Another\n---\n",
);
assert!(super::super::next_item_number(tmp.path()).unwrap() >= 4);
}
@@ -323,7 +340,11 @@ mod tests {
);
let result = list_bug_files(tmp.path()).unwrap();
assert!(result.iter().any(|(id, name)| id == "7001_bug_open" && name == "Open Bug"));
assert!(
result
.iter()
.any(|(id, name)| id == "7001_bug_open" && name == "Open Bug")
);
assert!(!result.iter().any(|(id, _)| id == "7002_bug_closed"));
}
@@ -349,9 +370,18 @@ mod tests {
let result = list_bug_files(tmp.path()).unwrap();
// Find positions of our three bugs in the sorted result.
let pos_first = result.iter().position(|(id, _)| id == "7011_bug_first").unwrap();
let pos_second = result.iter().position(|(id, _)| id == "7012_bug_second").unwrap();
let pos_third = result.iter().position(|(id, _)| id == "7013_bug_third").unwrap();
let pos_first = result
.iter()
.position(|(id, _)| id == "7011_bug_first")
.unwrap();
let pos_second = result
.iter()
.position(|(id, _)| id == "7012_bug_second")
.unwrap();
let pos_third = result
.iter()
.position(|(id, _)| id == "7013_bug_third")
.unwrap();
assert!(pos_first < pos_second);
assert!(pos_second < pos_third);
}
@@ -379,12 +409,17 @@ mod tests {
)
.unwrap();
assert!(bug_id.ends_with("_bug_login_crash"), "expected ID to end with _bug_login_crash, got: {bug_id}");
assert!(
bug_id.ends_with("_bug_login_crash"),
"expected ID to end with _bug_login_crash, got: {bug_id}"
);
// Check content exists (either in DB or filesystem).
let contents = crate::db::read_content(&bug_id)
.or_else(|| {
let filepath = tmp.path().join(format!(".huskies/work/1_backlog/{bug_id}.md"));
let filepath = tmp
.path()
.join(format!(".huskies/work/1_backlog/{bug_id}.md"));
fs::read_to_string(filepath).ok()
})
.expect("bug content should exist");
@@ -393,7 +428,10 @@ mod tests {
contents.starts_with("---\nname: \"Login Crash\"\n---"),
"bug file must start with YAML front matter"
);
assert!(contents.contains("Login Crash"), "content should mention bug name");
assert!(
contents.contains("Login Crash"),
"content should mention bug name"
);
assert!(contents.contains("## Description"));
assert!(contents.contains("The login page crashes on submit."));
assert!(contents.contains("## How to Reproduce"));
@@ -409,7 +447,15 @@ mod tests {
#[test]
fn create_bug_file_rejects_empty_name() {
let tmp = tempfile::tempdir().unwrap();
let result = create_bug_file(tmp.path(), "!!!", "desc", "steps", "actual", "expected", None);
let result = create_bug_file(
tmp.path(),
"!!!",
"desc",
"steps",
"actual",
"expected",
None,
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("alphanumeric"));
}
@@ -453,11 +499,16 @@ mod tests {
let spike_id =
create_spike_file(tmp.path(), "Filesystem Watcher Architecture", None).unwrap();
assert!(spike_id.ends_with("_spike_filesystem_watcher_architecture"), "expected ID to end with _spike_filesystem_watcher_architecture, got: {spike_id}");
assert!(
spike_id.ends_with("_spike_filesystem_watcher_architecture"),
"expected ID to end with _spike_filesystem_watcher_architecture, got: {spike_id}"
);
let contents = crate::db::read_content(&spike_id)
.or_else(|| {
let filepath = tmp.path().join(format!(".huskies/work/1_backlog/{spike_id}.md"));
let filepath = tmp
.path()
.join(format!(".huskies/work/1_backlog/{spike_id}.md"));
fs::read_to_string(filepath).ok()
})
.expect("spike content should exist");
@@ -466,7 +517,10 @@ mod tests {
contents.starts_with("---\nname: \"Filesystem Watcher Architecture\"\n---"),
"spike file must start with YAML front matter"
);
assert!(contents.contains("Filesystem Watcher Architecture"), "content should mention spike name");
assert!(
contents.contains("Filesystem Watcher Architecture"),
"content should mention spike name"
);
assert!(contents.contains("## Question"));
assert!(contents.contains("## Hypothesis"));
assert!(contents.contains("## Timebox"));
@@ -480,11 +534,14 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let description = "What is the best approach for watching filesystem events?";
let spike_id = create_spike_file(tmp.path(), "FS Watcher Spike", Some(description)).unwrap();
let spike_id =
create_spike_file(tmp.path(), "FS Watcher Spike", Some(description)).unwrap();
let contents = crate::db::read_content(&spike_id)
.or_else(|| {
let filepath = tmp.path().join(format!(".huskies/work/1_backlog/{spike_id}.md"));
let filepath = tmp
.path()
.join(format!(".huskies/work/1_backlog/{spike_id}.md"));
fs::read_to_string(filepath).ok()
})
.expect("spike content should exist");
@@ -498,7 +555,9 @@ mod tests {
let contents = crate::db::read_content(&spike_id)
.or_else(|| {
let filepath = tmp.path().join(format!(".huskies/work/1_backlog/{spike_id}.md"));
let filepath = tmp
.path()
.join(format!(".huskies/work/1_backlog/{spike_id}.md"));
fs::read_to_string(filepath).ok()
})
.expect("spike content should exist");
@@ -544,8 +603,19 @@ mod tests {
);
let spike_id = create_spike_file(tmp.path(), "My Spike", None).unwrap();
assert!(spike_id.ends_with("_spike_my_spike"), "expected ID to end with _spike_my_spike, got: {spike_id}");
let num: u32 = spike_id.chars().take_while(|c| c.is_ascii_digit()).collect::<String>().parse().unwrap();
assert!(num >= 7051, "expected spike number >= 7051, got: {spike_id}");
assert!(
spike_id.ends_with("_spike_my_spike"),
"expected ID to end with _spike_my_spike, got: {spike_id}"
);
let num: u32 = spike_id
.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse()
.unwrap();
assert!(
num >= 7051,
"expected spike number >= 7051, got: {spike_id}"
);
}
}
+106 -43
View File
@@ -161,7 +161,6 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
Ok(state)
}
/// Build a map from story_id → AgentAssignment for all pending/running agents.
fn build_active_agent_map(ctx: &AppContext) -> HashMap<String, AgentAssignment> {
let agents = match ctx.agents.list_agents() {
@@ -196,7 +195,6 @@ fn build_active_agent_map(ctx: &AppContext) -> HashMap<String, AgentAssignment>
map
}
pub fn load_upcoming_stories(_ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
use crate::pipeline_state::Stage;
@@ -244,9 +242,7 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result<Vec<UpcomingStory>, St
Ok(stories)
}
pub fn validate_story_dirs(
_root: &std::path::Path,
) -> Result<Vec<StoryValidationResult>, String> {
pub fn validate_story_dirs(_root: &std::path::Path) -> Result<Vec<StoryValidationResult>, String> {
use crate::pipeline_state::Stage;
let mut results = Vec::new();
@@ -309,7 +305,12 @@ pub(super) fn read_story_content(_project_root: &Path, story_id: &str) -> Result
}
/// Write story content to the DB content store and CRDT.
pub(super) fn write_story_content(_project_root: &Path, story_id: &str, stage: &str, content: &str) {
pub(super) fn write_story_content(
_project_root: &Path,
story_id: &str,
stage: &str,
content: &str,
) {
crate::db::write_item_with_content(story_id, stage, content);
}
@@ -321,13 +322,16 @@ pub(super) fn story_stage(story_id: &str) -> Option<String> {
.map(|item| item.stage.dir_name().to_string())
}
/// Replace the content of a named `## Section` in a story file.
///
/// Finds the first occurrence of `## {section_name}` and replaces everything
/// until the next `##` heading (or end of file) with the provided text.
/// Returns an error if the section is not found.
pub(super) fn replace_section_content(content: &str, section_name: &str, new_text: &str) -> Result<String, String> {
pub(super) fn replace_section_content(
content: &str,
section_name: &str,
new_text: &str,
) -> Result<String, String> {
let lines: Vec<&str> = content.lines().collect();
let heading = format!("## {section_name}");
@@ -517,18 +521,24 @@ mod tests {
("4_merge", "9840_story_merge"),
("5_done", "9850_story_done"),
] {
crate::db::write_item_with_content(
id,
stage,
&format!("---\nname: {id}\n---\n"),
);
crate::db::write_item_with_content(id, stage, &format!("---\nname: {id}\n---\n"));
}
let ctx = crate::http::context::AppContext::new_test(root);
let state = load_pipeline_state(&ctx).unwrap();
assert!(state.backlog.iter().any(|s| s.story_id == "9810_story_upcoming"));
assert!(state.current.iter().any(|s| s.story_id == "9820_story_current"));
assert!(
state
.backlog
.iter()
.any(|s| s.story_id == "9810_story_upcoming")
);
assert!(
state
.current
.iter()
.any(|s| s.story_id == "9820_story_current")
);
assert!(state.qa.iter().any(|s| s.story_id == "9830_story_qa"));
assert!(state.merge.iter().any(|s| s.story_id == "9840_story_merge"));
assert!(state.done.iter().any(|s| s.story_id == "9850_story_done"));
@@ -558,12 +568,23 @@ mod tests {
);
let ctx = crate::http::context::AppContext::new_test(root);
ctx.agents.inject_test_agent("9860_story_test", "coder-1", crate::agents::AgentStatus::Running);
ctx.agents.inject_test_agent(
"9860_story_test",
"coder-1",
crate::agents::AgentStatus::Running,
);
let state = load_pipeline_state(&ctx).unwrap();
let item = state.current.iter().find(|s| s.story_id == "9860_story_test").unwrap();
assert!(item.agent.is_some(), "running agent should appear on work item");
let item = state
.current
.iter()
.find(|s| s.story_id == "9860_story_test")
.unwrap();
assert!(
item.agent.is_some(),
"running agent should appear on work item"
);
let agent = item.agent.as_ref().unwrap();
assert_eq!(agent.agent_name, "coder-1");
assert_eq!(agent.status, "running");
@@ -582,11 +603,19 @@ mod tests {
);
let ctx = crate::http::context::AppContext::new_test(root);
ctx.agents.inject_test_agent("9861_story_done", "coder-1", crate::agents::AgentStatus::Completed);
ctx.agents.inject_test_agent(
"9861_story_done",
"coder-1",
crate::agents::AgentStatus::Completed,
);
let state = load_pipeline_state(&ctx).unwrap();
let item = state.current.iter().find(|s| s.story_id == "9861_story_done").unwrap();
let item = state
.current
.iter()
.find(|s| s.story_id == "9861_story_done")
.unwrap();
assert!(
item.agent.is_none(),
"completed agent should not appear on work item"
@@ -606,12 +635,23 @@ mod tests {
);
let ctx = crate::http::context::AppContext::new_test(root);
ctx.agents.inject_test_agent("9862_story_pending", "coder-1", crate::agents::AgentStatus::Pending);
ctx.agents.inject_test_agent(
"9862_story_pending",
"coder-1",
crate::agents::AgentStatus::Pending,
);
let state = load_pipeline_state(&ctx).unwrap();
let item = state.current.iter().find(|s| s.story_id == "9862_story_pending").unwrap();
assert!(item.agent.is_some(), "pending agent should appear on work item");
let item = state
.current
.iter()
.find(|s| s.story_id == "9862_story_pending")
.unwrap();
assert!(
item.agent.is_some(),
"pending agent should appear on work item"
);
assert_eq!(item.agent.as_ref().unwrap().status, "pending");
}
@@ -633,10 +673,18 @@ mod tests {
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
let state = load_pipeline_state(&ctx).unwrap();
let dependent = state.backlog.iter().find(|s| s.story_id == "9863_story_dependent").unwrap();
let dependent = state
.backlog
.iter()
.find(|s| s.story_id == "9863_story_dependent")
.unwrap();
assert_eq!(dependent.depends_on, Some(vec![10, 11]));
let independent = state.backlog.iter().find(|s| s.story_id == "9864_story_independent").unwrap();
let independent = state
.backlog
.iter()
.find(|s| s.story_id == "9864_story_independent")
.unwrap();
assert_eq!(independent.depends_on, None);
}
@@ -657,9 +705,15 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
let stories = load_upcoming_stories(&ctx).unwrap();
let s1 = stories.iter().find(|s| s.story_id == "9870_story_view_upcoming").unwrap();
let s1 = stories
.iter()
.find(|s| s.story_id == "9870_story_view_upcoming")
.unwrap();
assert_eq!(s1.name.as_deref(), Some("View Upcoming"));
let s2 = stories.iter().find(|s| s.story_id == "9871_story_worktree").unwrap();
let s2 = stories
.iter()
.find(|s| s.story_id == "9871_story_worktree")
.unwrap();
assert_eq!(s2.name.as_deref(), Some("Worktree Orchestration"));
}
@@ -696,24 +750,29 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let results = validate_story_dirs(tmp.path()).unwrap();
let r1 = results.iter().find(|r| r.story_id == "9873_story_todos").unwrap();
let r1 = results
.iter()
.find(|r| r.story_id == "9873_story_todos")
.unwrap();
assert!(r1.valid);
let r2 = results.iter().find(|r| r.story_id == "9874_story_front_matter").unwrap();
let r2 = results
.iter()
.find(|r| r.story_id == "9874_story_front_matter")
.unwrap();
assert!(r2.valid);
}
#[test]
fn validate_story_dirs_missing_front_matter() {
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"9875_story_no_fm",
"2_current",
"# No front matter\n",
);
crate::db::write_item_with_content("9875_story_no_fm", "2_current", "# No front matter\n");
let tmp = tempfile::tempdir().unwrap();
let results = validate_story_dirs(tmp.path()).unwrap();
let r = results.iter().find(|r| r.story_id == "9875_story_no_fm").unwrap();
let r = results
.iter()
.find(|r| r.story_id == "9875_story_no_fm")
.unwrap();
assert!(!r.valid);
assert_eq!(r.error.as_deref(), Some("Missing front matter"));
}
@@ -729,7 +788,10 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let results = validate_story_dirs(tmp.path()).unwrap();
let r = results.iter().find(|r| r.story_id == "9876_story_no_name").unwrap();
let r = results
.iter()
.find(|r| r.story_id == "9876_story_no_name")
.unwrap();
assert!(!r.valid);
let err = r.error.as_deref().unwrap();
assert!(err.contains("Missing 'name' field"));
@@ -789,11 +851,7 @@ mod tests {
#[test]
fn next_item_number_increments_beyond_existing() {
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"9877_story_foo",
"1_backlog",
"---\nname: Foo\n---\n",
);
crate::db::write_item_with_content("9877_story_foo", "1_backlog", "---\nname: Foo\n---\n");
let tmp = tempfile::tempdir().unwrap();
assert!(next_item_number(tmp.path()).unwrap() >= 9878);
}
@@ -824,7 +882,8 @@ mod tests {
#[test]
fn replace_or_append_section_appends_when_absent() {
let contents = "---\nname: T\n---\n# Story\n";
let new = replace_or_append_section(contents, "## Test Results", "## Test Results\n\nfoo\n");
let new =
replace_or_append_section(contents, "## Test Results", "## Test Results\n\nfoo\n");
assert!(new.contains("## Test Results"));
assert!(new.contains("foo"));
assert!(new.contains("# Story"));
@@ -833,7 +892,11 @@ mod tests {
#[test]
fn replace_or_append_section_replaces_existing() {
let contents = "# Story\n\n## Test Results\n\nold content\n\n## Other\n\nother content\n";
let new = replace_or_append_section(contents, "## Test Results", "## Test Results\n\nnew content\n");
let new = replace_or_append_section(
contents,
"## Test Results",
"## Test Results\n\nnew content\n",
);
assert!(new.contains("new content"));
assert!(!new.contains("old content"));
assert!(new.contains("## Other"));
+271 -66
View File
@@ -4,7 +4,10 @@ use serde_json::Value;
use std::collections::HashMap;
use std::path::Path;
use super::{create_section_content, next_item_number, read_story_content, replace_section_content, slugify_name, story_stage, write_story_content};
use super::{
create_section_content, next_item_number, read_story_content, replace_section_content,
slugify_name, story_stage, write_story_content,
};
/// Shared create-story logic used by both the OpenApi and MCP handlers.
///
@@ -158,9 +161,7 @@ pub fn add_criterion_to_file(
let insert_after = last_criterion_line
.or(ac_section_start)
.ok_or_else(|| {
format!("Story '{story_id}' has no '## Acceptance Criteria' section.")
})?;
.ok_or_else(|| format!("Story '{story_id}' has no '## Acceptance Criteria' section."))?;
let mut new_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
new_lines.insert(insert_after + 1, format!("- [ ] {criterion}"));
@@ -195,7 +196,14 @@ fn json_value_to_yaml_scalar(value: &Value) -> String {
}
Value::String(s) => yaml_encode_str(s),
// Null and Object are not meaningful as YAML scalars; store as quoted strings.
other => format!("\"{}\"", other.to_string().replace('"', "\\\"").replace('\n', " ").replace('\r', "")),
other => format!(
"\"{}\"",
other
.to_string()
.replace('"', "\\\"")
.replace('\n', " ")
.replace('\r', "")
),
}
}
@@ -211,7 +219,10 @@ fn yaml_encode_str(s: &str) -> String {
// YAML inline sequences like [490] or [490, 491] — write unquoted so
// serde_yaml can deserialise them as Vec<u32>.
s if s.starts_with('[') && s.ends_with(']') => s.to_string(),
s => format!("\"{}\"", s.replace('"', "\\\"").replace('\n', " ").replace('\r', "")),
s => format!(
"\"{}\"",
s.replace('"', "\\\"").replace('\n', " ").replace('\r', "")
),
}
}
@@ -246,13 +257,17 @@ pub fn update_story_in_file(
if let Some(us) = user_story {
contents = match replace_section_content(&contents, "User Story", us) {
Ok(updated) => updated,
Err(_) => create_section_content(&contents, "User Story", us, Some("Acceptance Criteria")),
Err(_) => {
create_section_content(&contents, "User Story", us, Some("Acceptance Criteria"))
}
};
}
if let Some(desc) = description {
contents = match replace_section_content(&contents, "Description", desc) {
Ok(updated) => updated,
Err(_) => create_section_content(&contents, "Description", desc, Some("Acceptance Criteria")),
Err(_) => {
create_section_content(&contents, "Description", desc, Some("Acceptance Criteria"))
}
};
}
@@ -322,7 +337,11 @@ mod tests {
fs::create_dir_all(&backlog).unwrap();
fs::write(backlog.join("36_story_existing.md"), "").unwrap();
// Also write to content store so next_item_number sees it.
crate::db::write_item_with_content("36_story_existing", "1_backlog", "---\nname: Existing\n---\n");
crate::db::write_item_with_content(
"36_story_existing",
"1_backlog",
"---\nname: Existing\n---\n",
);
let number = super::super::next_item_number(tmp.path()).unwrap();
// The number must be >= 37 (at least higher than the existing "36_story_existing.md"),
@@ -390,9 +409,18 @@ mod tests {
// Read the updated content.
let contents = read_story_content(tmp.path(), "1_test").unwrap();
assert!(contents.contains("- [x] Criterion 0"), "first should be checked");
assert!(contents.contains("- [ ] Criterion 1"), "second should stay unchecked");
assert!(contents.contains("- [ ] Criterion 2"), "third should stay unchecked");
assert!(
contents.contains("- [x] Criterion 0"),
"first should be checked"
);
assert!(
contents.contains("- [ ] Criterion 1"),
"second should stay unchecked"
);
assert!(
contents.contains("- [ ] Criterion 2"),
"third should stay unchecked"
);
}
#[test]
@@ -404,9 +432,18 @@ mod tests {
check_criterion_in_file(tmp.path(), "2_test", 1).unwrap();
let contents = read_story_content(tmp.path(), "2_test").unwrap();
assert!(contents.contains("- [ ] Criterion 0"), "first should stay unchecked");
assert!(contents.contains("- [x] Criterion 1"), "second should be checked");
assert!(contents.contains("- [ ] Criterion 2"), "third should stay unchecked");
assert!(
contents.contains("- [ ] Criterion 0"),
"first should stay unchecked"
);
assert!(
contents.contains("- [x] Criterion 1"),
"second should be checked"
);
assert!(
contents.contains("- [ ] Criterion 2"),
"third should stay unchecked"
);
}
#[test]
@@ -423,7 +460,9 @@ mod tests {
// ── add_criterion_to_file tests ───────────────────────────────────────────
fn story_with_ac_section(criteria: &[&str]) -> String {
let mut s = "---\nname: Test\n---\n\n## User Story\n\nAs a user...\n\n## Acceptance Criteria\n\n".to_string();
let mut s =
"---\nname: Test\n---\n\n## User Story\n\nAs a user...\n\n## Acceptance Criteria\n\n"
.to_string();
for c in criteria {
s.push_str(&format!("- [ ] {c}\n"));
}
@@ -434,7 +473,11 @@ mod tests {
#[test]
fn add_criterion_appends_after_last_criterion() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(tmp.path(), "10_test", &story_with_ac_section(&["First", "Second"]));
setup_story_in_fs(
tmp.path(),
"10_test",
&story_with_ac_section(&["First", "Second"]),
);
add_criterion_to_file(tmp.path(), "10_test", "Third").unwrap();
@@ -450,19 +493,27 @@ mod tests {
#[test]
fn add_criterion_to_empty_section() {
let tmp = tempfile::tempdir().unwrap();
let content = "---\nname: Test\n---\n\n## Acceptance Criteria\n\n## Out of Scope\n\n- N/A\n";
let content =
"---\nname: Test\n---\n\n## Acceptance Criteria\n\n## Out of Scope\n\n- N/A\n";
setup_story_in_fs(tmp.path(), "11_test", content);
add_criterion_to_file(tmp.path(), "11_test", "New AC").unwrap();
let contents = read_story_content(tmp.path(), "11_test").unwrap();
assert!(contents.contains("- [ ] New AC\n"), "criterion should be present");
assert!(
contents.contains("- [ ] New AC\n"),
"criterion should be present"
);
}
#[test]
fn add_criterion_missing_section_returns_error() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(tmp.path(), "12_test", "---\nname: Test\n---\n\nNo AC section here.\n");
setup_story_in_fs(
tmp.path(),
"12_test",
"---\nname: Test\n---\n\nNo AC section here.\n",
);
let result = add_criterion_to_file(tmp.path(), "12_test", "X");
assert!(result.is_err());
@@ -477,12 +528,25 @@ mod tests {
let content = "---\nname: T\n---\n\n## User Story\n\nOld text\n\n## Acceptance Criteria\n\n- [ ] AC\n";
setup_story_in_fs(tmp.path(), "20_test", content);
update_story_in_file(tmp.path(), "20_test", Some("New user story text"), None, None).unwrap();
update_story_in_file(
tmp.path(),
"20_test",
Some("New user story text"),
None,
None,
)
.unwrap();
let result = read_story_content(tmp.path(), "20_test").unwrap();
assert!(result.contains("New user story text"), "new text should be present");
assert!(
result.contains("New user story text"),
"new text should be present"
);
assert!(!result.contains("Old text"), "old text should be replaced");
assert!(result.contains("## Acceptance Criteria"), "other sections preserved");
assert!(
result.contains("## Acceptance Criteria"),
"other sections preserved"
);
}
#[test]
@@ -494,8 +558,14 @@ mod tests {
update_story_in_file(tmp.path(), "21_test", None, Some("New description"), None).unwrap();
let result = read_story_content(tmp.path(), "21_test").unwrap();
assert!(result.contains("New description"), "new description present");
assert!(!result.contains("Old description"), "old description replaced");
assert!(
result.contains("New description"),
"new description present"
);
assert!(
!result.contains("Old description"),
"old description replaced"
);
}
#[test]
@@ -515,16 +585,26 @@ mod tests {
let content = "---\nname: T\n---\n\n## Acceptance Criteria\n\n- [ ] AC\n";
setup_story_in_fs(tmp.path(), "23_test", content);
let result = update_story_in_file(tmp.path(), "23_test", Some("New user story"), None, None);
assert!(result.is_ok(), "should succeed when section is missing: {result:?}");
let result =
update_story_in_file(tmp.path(), "23_test", Some("New user story"), None, None);
assert!(
result.is_ok(),
"should succeed when section is missing: {result:?}"
);
let updated = read_story_content(tmp.path(), "23_test").unwrap();
assert!(updated.contains("## User Story"), "section should be created");
assert!(
updated.contains("## User Story"),
"section should be created"
);
assert!(updated.contains("New user story"), "text should be present");
// Section should appear before Acceptance Criteria.
let pos_us = updated.find("## User Story").unwrap();
let pos_ac = updated.find("## Acceptance Criteria").unwrap();
assert!(pos_us < pos_ac, "User Story should be before Acceptance Criteria");
assert!(
pos_us < pos_ac,
"User Story should be before Acceptance Criteria"
);
}
#[test]
@@ -534,16 +614,34 @@ mod tests {
let content = "---\nname: T\n---\n\n## User Story\n\nAs a user...\n\n## Acceptance Criteria\n\n- [ ] AC\n";
setup_story_in_fs(tmp.path(), "32_test", content);
let result = update_story_in_file(tmp.path(), "32_test", None, Some("New description text"), None);
assert!(result.is_ok(), "should succeed when section is missing: {result:?}");
let result = update_story_in_file(
tmp.path(),
"32_test",
None,
Some("New description text"),
None,
);
assert!(
result.is_ok(),
"should succeed when section is missing: {result:?}"
);
let updated = read_story_content(tmp.path(), "32_test").unwrap();
assert!(updated.contains("## Description"), "section should be created");
assert!(updated.contains("New description text"), "text should be present");
assert!(
updated.contains("## Description"),
"section should be created"
);
assert!(
updated.contains("New description text"),
"text should be present"
);
// Section should appear before Acceptance Criteria.
let pos_desc = updated.find("## Description").unwrap();
let pos_ac = updated.find("## Acceptance Criteria").unwrap();
assert!(pos_desc < pos_ac, "Description should be before Acceptance Criteria");
assert!(
pos_desc < pos_ac,
"Description should be before Acceptance Criteria"
);
}
#[test]
@@ -553,32 +651,58 @@ mod tests {
let content = "---\nname: T\n---\n\nSome content here.\n";
setup_story_in_fs(tmp.path(), "33_test", content);
let result = update_story_in_file(tmp.path(), "33_test", None, Some("Appended description"), None);
assert!(result.is_ok(), "should succeed even with no Acceptance Criteria: {result:?}");
let result = update_story_in_file(
tmp.path(),
"33_test",
None,
Some("Appended description"),
None,
);
assert!(
result.is_ok(),
"should succeed even with no Acceptance Criteria: {result:?}"
);
let updated = read_story_content(tmp.path(), "33_test").unwrap();
assert!(updated.contains("## Description"), "section should be created");
assert!(updated.contains("Appended description"), "text should be present");
assert!(
updated.contains("## Description"),
"section should be created"
);
assert!(
updated.contains("Appended description"),
"text should be present"
);
}
#[test]
fn update_story_sets_agent_front_matter_field() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(tmp.path(), "24_test", "---\nname: T\n---\n\n## User Story\n\nSome story\n");
setup_story_in_fs(
tmp.path(),
"24_test",
"---\nname: T\n---\n\n## User Story\n\nSome story\n",
);
let mut fields = HashMap::new();
fields.insert("agent".to_string(), Value::String("dev".to_string()));
update_story_in_file(tmp.path(), "24_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "24_test").unwrap();
assert!(result.contains("agent: \"dev\""), "agent field should be set");
assert!(
result.contains("agent: \"dev\""),
"agent field should be set"
);
assert!(result.contains("name: T"), "name field preserved");
}
#[test]
fn update_story_sets_arbitrary_front_matter_fields() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(tmp.path(), "25_test", "---\nname: T\n---\n\n## User Story\n\nSome story\n");
setup_story_in_fs(
tmp.path(),
"25_test",
"---\nname: T\n---\n\n## User Story\n\nSome story\n",
);
let mut fields = HashMap::new();
fields.insert("qa".to_string(), Value::String("human".to_string()));
@@ -587,19 +711,29 @@ mod tests {
let result = read_story_content(tmp.path(), "25_test").unwrap();
assert!(result.contains("qa: \"human\""), "qa field should be set");
assert!(result.contains("priority: \"high\""), "priority field should be set");
assert!(
result.contains("priority: \"high\""),
"priority field should be set"
);
assert!(result.contains("name: T"), "name field preserved");
}
#[test]
fn update_story_front_matter_only_no_section_required() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(tmp.path(), "26_test", "---\nname: T\n---\n\nNo sections here.\n");
setup_story_in_fs(
tmp.path(),
"26_test",
"---\nname: T\n---\n\nNo sections here.\n",
);
let mut fields = HashMap::new();
fields.insert("agent".to_string(), Value::String("dev".to_string()));
let result = update_story_in_file(tmp.path(), "26_test", None, None, Some(&fields));
assert!(result.is_ok(), "front-matter-only update should not require body sections");
assert!(
result.is_ok(),
"front-matter-only update should not require body sections"
);
let contents = read_story_content(tmp.path(), "26_test").unwrap();
assert!(contents.contains("agent: \"dev\""));
@@ -616,8 +750,14 @@ mod tests {
update_story_in_file(tmp.path(), "27_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "27_test").unwrap();
assert!(result.contains("blocked: false"), "bool should be unquoted: {result}");
assert!(!result.contains("blocked: \"false\""), "bool must not be quoted: {result}");
assert!(
result.contains("blocked: false"),
"bool should be unquoted: {result}"
);
assert!(
!result.contains("blocked: \"false\""),
"bool must not be quoted: {result}"
);
}
#[test]
@@ -631,14 +771,24 @@ mod tests {
update_story_in_file(tmp.path(), "28_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "28_test").unwrap();
assert!(result.contains("retry_count: 0"), "integer should be unquoted: {result}");
assert!(!result.contains("retry_count: \"0\""), "integer must not be quoted: {result}");
assert!(
result.contains("retry_count: 0"),
"integer should be unquoted: {result}"
);
assert!(
!result.contains("retry_count: \"0\""),
"integer must not be quoted: {result}"
);
}
#[test]
fn update_story_bool_front_matter_parseable_after_write() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(tmp.path(), "29_test", "---\nname: My Story\n---\n\nNo sections.\n");
setup_story_in_fs(
tmp.path(),
"29_test",
"---\nname: My Story\n---\n\nNo sections.\n",
);
let mut fields = HashMap::new();
fields.insert("blocked".to_string(), Value::String("false".to_string()));
@@ -646,7 +796,11 @@ mod tests {
let contents = read_story_content(tmp.path(), "29_test").unwrap();
let meta = parse_front_matter(&contents).expect("front matter should parse");
assert_eq!(meta.name.as_deref(), Some("My Story"), "name preserved after writing bool field");
assert_eq!(
meta.name.as_deref(),
Some("My Story"),
"name preserved after writing bool field"
);
}
// ── Bug 493 regression tests ──────────────────────────────────────────────
@@ -662,8 +816,14 @@ mod tests {
update_story_in_file(tmp.path(), "30_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "30_test").unwrap();
assert!(result.contains("depends_on: [490]"), "should be unquoted array: {result}");
assert!(!result.contains("depends_on: \"[490]\""), "must not be quoted: {result}");
assert!(
result.contains("depends_on: [490]"),
"should be unquoted array: {result}"
);
assert!(
!result.contains("depends_on: \"[490]\""),
"must not be quoted: {result}"
);
let meta = parse_front_matter(&result).expect("front matter should parse");
assert_eq!(meta.depends_on, Some(vec![490]));
@@ -690,8 +850,14 @@ mod tests {
})
.expect("story content should exist");
assert!(contents.contains("depends_on: [489]"), "missing front matter: {contents}");
assert!(!contents.contains("- [ ] depends_on"), "must not appear as checkbox: {contents}");
assert!(
contents.contains("depends_on: [489]"),
"missing front matter: {contents}"
);
assert!(
!contents.contains("- [ ] depends_on"),
"must not appear as checkbox: {contents}"
);
let meta = parse_front_matter(&contents).expect("front matter should parse");
assert_eq!(meta.depends_on, Some(vec![489]));
@@ -709,8 +875,14 @@ mod tests {
update_story_in_file(tmp.path(), "31_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "31_test").unwrap();
assert!(result.contains("blocked: false"), "native bool false should be unquoted: {result}");
assert!(!result.contains("blocked: \"false\""), "must not be quoted: {result}");
assert!(
result.contains("blocked: false"),
"native bool false should be unquoted: {result}"
);
assert!(
!result.contains("blocked: \"false\""),
"must not be quoted: {result}"
);
}
#[test]
@@ -723,22 +895,38 @@ mod tests {
update_story_in_file(tmp.path(), "32_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "32_test").unwrap();
assert!(result.contains("blocked: true"), "native bool true should be unquoted: {result}");
assert!(!result.contains("blocked: \"true\""), "must not be quoted: {result}");
assert!(
result.contains("blocked: true"),
"native bool true should be unquoted: {result}"
);
assert!(
!result.contains("blocked: \"true\""),
"must not be quoted: {result}"
);
}
#[test]
fn update_story_native_integer_written_unquoted() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(tmp.path(), "33b_test", "---\nname: T\n---\n\nNo sections.\n");
setup_story_in_fs(
tmp.path(),
"33b_test",
"---\nname: T\n---\n\nNo sections.\n",
);
let mut fields = HashMap::new();
fields.insert("retry_count".to_string(), serde_json::json!(3));
update_story_in_file(tmp.path(), "33b_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "33b_test").unwrap();
assert!(result.contains("retry_count: 3"), "native integer should be unquoted: {result}");
assert!(!result.contains("retry_count: \"3\""), "must not be quoted: {result}");
assert!(
result.contains("retry_count: 3"),
"native integer should be unquoted: {result}"
);
assert!(
!result.contains("retry_count: \"3\""),
"must not be quoted: {result}"
);
}
#[test]
@@ -751,8 +939,14 @@ mod tests {
update_story_in_file(tmp.path(), "34_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "34_test").unwrap();
assert!(result.contains("depends_on: [490, 491]"), "native array should be YAML sequence: {result}");
assert!(!result.contains("depends_on: \"["), "must not be quoted: {result}");
assert!(
result.contains("depends_on: [490, 491]"),
"native array should be YAML sequence: {result}"
);
assert!(
!result.contains("depends_on: \"["),
"must not be quoted: {result}"
);
let meta = parse_front_matter(&result).expect("front matter should parse");
assert_eq!(meta.depends_on, Some(vec![490, 491]));
@@ -761,7 +955,11 @@ mod tests {
#[test]
fn update_story_native_bool_parseable_after_write() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(tmp.path(), "35_test", "---\nname: My Story\n---\n\nNo sections.\n");
setup_story_in_fs(
tmp.path(),
"35_test",
"---\nname: My Story\n---\n\nNo sections.\n",
);
let mut fields = HashMap::new();
fields.insert("blocked".to_string(), Value::Bool(false));
@@ -769,7 +967,11 @@ mod tests {
let contents = read_story_content(tmp.path(), "35_test").unwrap();
let meta = parse_front_matter(&contents).expect("front matter should parse");
assert_eq!(meta.name.as_deref(), Some("My Story"), "name preserved after writing native bool");
assert_eq!(
meta.name.as_deref(),
Some("My Story"),
"name preserved after writing native bool"
);
}
#[test]
@@ -779,7 +981,10 @@ mod tests {
// String "[490, 491]" still works (backwards compatibility).
let mut fields = HashMap::new();
fields.insert("depends_on".to_string(), Value::String("[490, 491]".to_string()));
fields.insert(
"depends_on".to_string(),
Value::String("[490, 491]".to_string()),
);
update_story_in_file(tmp.path(), "31_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "31_test").unwrap();
+5 -1
View File
@@ -56,7 +56,11 @@ pub fn write_coverage_baseline_to_story_file(
Err(_) => return Ok(()), // No story — skip silently
};
let updated = set_front_matter_field(&contents, "coverage_baseline", &format!("{coverage_pct:.1}%"));
let updated = set_front_matter_field(
&contents,
"coverage_baseline",
&format!("{coverage_pct:.1}%"),
);
let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string());
write_story_content(project_root, story_id, &stage, &updated);