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:
+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();
|
||||
|
||||
Reference in New Issue
Block a user