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:
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(¤t_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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user