story-kit: merge 318_refactor_split_mcp_rs_into_domain_specific_tool_modules
This commit is contained in:
819
server/src/http/mcp/diagnostics.rs
Normal file
819
server/src/http/mcp/diagnostics.rs
Normal file
@@ -0,0 +1,819 @@
|
||||
use crate::agents::{AgentStatus, move_story_to_stage};
|
||||
use crate::http::context::AppContext;
|
||||
use crate::log_buffer;
|
||||
use crate::slog;
|
||||
use crate::slog_warn;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
|
||||
pub(super) fn tool_get_server_logs(args: &Value) -> Result<String, String> {
|
||||
let lines_count = args
|
||||
.get("lines")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n.min(1000) as usize)
|
||||
.unwrap_or(100);
|
||||
let filter = args.get("filter").and_then(|v| v.as_str());
|
||||
let severity = args
|
||||
.get("severity")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(log_buffer::LogLevel::from_str_ci);
|
||||
|
||||
let recent = log_buffer::global().get_recent(lines_count, filter, severity.as_ref());
|
||||
let joined = recent.join("\n");
|
||||
// Clamp to lines_count actual lines in case any entry contains embedded newlines.
|
||||
let all_lines: Vec<&str> = joined.lines().collect();
|
||||
let start = all_lines.len().saturating_sub(lines_count);
|
||||
Ok(all_lines[start..].join("\n"))
|
||||
}
|
||||
|
||||
/// Rebuild the server binary and re-exec.
|
||||
///
|
||||
/// 1. Gracefully stops all running agents (kills PTY children).
|
||||
/// 2. Runs `cargo build [-p story-kit]` from the workspace root, matching
|
||||
/// the current build profile (debug or release).
|
||||
/// 3. If the build fails, returns the build error (server stays up).
|
||||
/// 4. If the build succeeds, re-execs the process with the new binary via
|
||||
/// `std::os::unix::process::CommandExt::exec()`.
|
||||
pub(super) async fn tool_rebuild_and_restart(ctx: &AppContext) -> Result<String, String> {
|
||||
slog!("[rebuild] Rebuild and restart requested via MCP tool");
|
||||
|
||||
// 1. Gracefully stop all running agents.
|
||||
let running_agents = ctx.agents.list_agents().unwrap_or_default();
|
||||
let running_count = running_agents
|
||||
.iter()
|
||||
.filter(|a| a.status == AgentStatus::Running)
|
||||
.count();
|
||||
if running_count > 0 {
|
||||
slog!("[rebuild] Stopping {running_count} running agent(s) before rebuild");
|
||||
}
|
||||
ctx.agents.kill_all_children();
|
||||
|
||||
// 2. Find the workspace root (parent of the server binary's source).
|
||||
// CARGO_MANIFEST_DIR at compile time points to the `server/` crate;
|
||||
// the workspace root is its parent.
|
||||
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace_root = manifest_dir
|
||||
.parent()
|
||||
.ok_or_else(|| "Cannot determine workspace root from CARGO_MANIFEST_DIR".to_string())?;
|
||||
|
||||
slog!(
|
||||
"[rebuild] Building server from workspace root: {}",
|
||||
workspace_root.display()
|
||||
);
|
||||
|
||||
// 3. Build the server binary, matching the current build profile so the
|
||||
// re-exec via current_exe() picks up the new binary.
|
||||
let build_args: Vec<&str> = if cfg!(debug_assertions) {
|
||||
vec!["build", "-p", "story-kit"]
|
||||
} else {
|
||||
vec!["build", "--release", "-p", "story-kit"]
|
||||
};
|
||||
slog!("[rebuild] cargo {}", build_args.join(" "));
|
||||
let output = tokio::task::spawn_blocking({
|
||||
let workspace_root = workspace_root.to_path_buf();
|
||||
move || {
|
||||
std::process::Command::new("cargo")
|
||||
.args(&build_args)
|
||||
.current_dir(&workspace_root)
|
||||
.output()
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Build task panicked: {e}"))?
|
||||
.map_err(|e| format!("Failed to run cargo build: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
slog!("[rebuild] Build failed:\n{stderr}");
|
||||
return Err(format!("Build failed:\n{stderr}"));
|
||||
}
|
||||
|
||||
slog!("[rebuild] Build succeeded, re-execing with new binary");
|
||||
|
||||
// 4. Re-exec with the new binary.
|
||||
// Collect current argv so we preserve any CLI arguments (e.g. project path).
|
||||
let current_exe = std::env::current_exe()
|
||||
.map_err(|e| format!("Cannot determine current executable: {e}"))?;
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// Remove the port file before re-exec so the new process can write its own.
|
||||
if let Ok(root) = ctx.state.get_project_root() {
|
||||
let port_file = root.join(".story_kit_port");
|
||||
if port_file.exists() {
|
||||
let _ = std::fs::remove_file(&port_file);
|
||||
}
|
||||
}
|
||||
// Also check cwd for port file.
|
||||
let cwd_port_file = std::path::Path::new(".story_kit_port");
|
||||
if cwd_port_file.exists() {
|
||||
let _ = std::fs::remove_file(cwd_port_file);
|
||||
}
|
||||
|
||||
// Use exec() to replace the current process.
|
||||
// This never returns on success.
|
||||
use std::os::unix::process::CommandExt;
|
||||
let err = std::process::Command::new(¤t_exe)
|
||||
.args(&args[1..])
|
||||
.exec();
|
||||
|
||||
// If we get here, exec() failed.
|
||||
Err(format!("Failed to exec new binary: {err}"))
|
||||
}
|
||||
|
||||
/// Generate a Claude Code permission rule string for the given tool name and input.
|
||||
///
|
||||
/// - `Edit` / `Write` / `Read` / `Grep` / `Glob` etc. → just the tool name
|
||||
/// - `Bash` → `Bash(first_word *)` derived from the `command` field in `tool_input`
|
||||
/// - `mcp__*` → the full tool name (e.g. `mcp__story-kit__create_story`)
|
||||
fn generate_permission_rule(tool_name: &str, tool_input: &Value) -> String {
|
||||
if tool_name == "Bash" {
|
||||
// Extract command from tool_input.command and use first word as prefix
|
||||
let command_str = tool_input
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let first_word = command_str.split_whitespace().next().unwrap_or("unknown");
|
||||
format!("Bash({first_word} *)")
|
||||
} else {
|
||||
// For Edit, Write, Read, Glob, Grep, MCP tools, etc. — use the tool name directly
|
||||
tool_name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a permission rule to `.claude/settings.json` in the project root.
|
||||
/// Does nothing if the rule already exists. Creates the file if missing.
|
||||
pub(super) fn add_permission_rule(project_root: &std::path::Path, rule: &str) -> Result<(), String> {
|
||||
let claude_dir = project_root.join(".claude");
|
||||
fs::create_dir_all(&claude_dir)
|
||||
.map_err(|e| format!("Failed to create .claude/ directory: {e}"))?;
|
||||
|
||||
let settings_path = claude_dir.join("settings.json");
|
||||
let mut settings: Value = if settings_path.exists() {
|
||||
let content = fs::read_to_string(&settings_path)
|
||||
.map_err(|e| format!("Failed to read settings.json: {e}"))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse settings.json: {e}"))?
|
||||
} else {
|
||||
json!({ "permissions": { "allow": [] } })
|
||||
};
|
||||
|
||||
let allow_arr = settings
|
||||
.pointer_mut("/permissions/allow")
|
||||
.and_then(|v| v.as_array_mut());
|
||||
|
||||
let allow = match allow_arr {
|
||||
Some(arr) => arr,
|
||||
None => {
|
||||
// Ensure the structure exists
|
||||
settings
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.entry("permissions")
|
||||
.or_insert(json!({ "allow": [] }));
|
||||
settings
|
||||
.pointer_mut("/permissions/allow")
|
||||
.unwrap()
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
}
|
||||
};
|
||||
|
||||
// Check for duplicates — exact string match
|
||||
let rule_value = Value::String(rule.to_string());
|
||||
if allow.contains(&rule_value) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Also check for wildcard coverage: if "mcp__story-kit__*" exists, don't add
|
||||
// a more specific "mcp__story-kit__create_story".
|
||||
let dominated = allow.iter().any(|existing| {
|
||||
if let Some(pat) = existing.as_str()
|
||||
&& let Some(prefix) = pat.strip_suffix('*')
|
||||
{
|
||||
return rule.starts_with(prefix);
|
||||
}
|
||||
false
|
||||
});
|
||||
if dominated {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
allow.push(rule_value);
|
||||
|
||||
let pretty =
|
||||
serde_json::to_string_pretty(&settings).map_err(|e| format!("Failed to serialize: {e}"))?;
|
||||
fs::write(&settings_path, pretty)
|
||||
.map_err(|e| format!("Failed to write settings.json: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// MCP tool called by Claude Code via `--permission-prompt-tool`.
|
||||
///
|
||||
/// Forwards the permission request through the shared channel to the active
|
||||
/// WebSocket session, which presents a dialog to the user. Blocks until the
|
||||
/// user approves or denies (with a 5-minute timeout).
|
||||
pub(super) async fn tool_prompt_permission(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let tool_name = args
|
||||
.get("tool_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let tool_input = args
|
||||
.get("input")
|
||||
.cloned()
|
||||
.unwrap_or(json!({}));
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
ctx.perm_tx
|
||||
.send(crate::http::context::PermissionForward {
|
||||
request_id: request_id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
tool_input: tool_input.clone(),
|
||||
response_tx,
|
||||
})
|
||||
.map_err(|_| "No active WebSocket session to receive permission request".to_string())?;
|
||||
|
||||
use crate::http::context::PermissionDecision;
|
||||
|
||||
let decision = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(300),
|
||||
response_rx,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
let msg = format!("Permission request for '{tool_name}' timed out after 5 minutes");
|
||||
slog_warn!("[permission] {msg}");
|
||||
msg
|
||||
})?
|
||||
.map_err(|_| "Permission response channel closed unexpectedly".to_string())?;
|
||||
|
||||
if decision == PermissionDecision::AlwaysAllow {
|
||||
// Persist the rule so Claude Code won't prompt again for this tool.
|
||||
if let Some(root) = ctx.state.project_root.lock().unwrap().clone() {
|
||||
let rule = generate_permission_rule(&tool_name, &tool_input);
|
||||
if let Err(e) = add_permission_rule(&root, &rule) {
|
||||
slog_warn!("[permission] Failed to write always-allow rule: {e}");
|
||||
} else {
|
||||
slog!("[permission] Added always-allow rule: {rule}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if decision == PermissionDecision::Approve || decision == PermissionDecision::AlwaysAllow {
|
||||
// Claude Code SDK expects:
|
||||
// Allow: { behavior: "allow", updatedInput: <record> }
|
||||
// Deny: { behavior: "deny", message: string }
|
||||
Ok(json!({"behavior": "allow", "updatedInput": tool_input}).to_string())
|
||||
} else {
|
||||
slog_warn!("[permission] User denied permission for '{tool_name}'");
|
||||
Ok(json!({
|
||||
"behavior": "deny",
|
||||
"message": format!("User denied permission for '{tool_name}'")
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tool_get_token_usage(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let root = ctx.state.get_project_root()?;
|
||||
let filter_story = args.get("story_id").and_then(|v| v.as_str());
|
||||
|
||||
let all_records = crate::agents::token_usage::read_all(&root)?;
|
||||
let records: Vec<_> = all_records
|
||||
.into_iter()
|
||||
.filter(|r| filter_story.is_none_or(|s| r.story_id == s))
|
||||
.collect();
|
||||
|
||||
let total_cost: f64 = records.iter().map(|r| r.usage.total_cost_usd).sum();
|
||||
let total_input: u64 = records.iter().map(|r| r.usage.input_tokens).sum();
|
||||
let total_output: u64 = records.iter().map(|r| r.usage.output_tokens).sum();
|
||||
let total_cache_create: u64 = records
|
||||
.iter()
|
||||
.map(|r| r.usage.cache_creation_input_tokens)
|
||||
.sum();
|
||||
let total_cache_read: u64 = records
|
||||
.iter()
|
||||
.map(|r| r.usage.cache_read_input_tokens)
|
||||
.sum();
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"records": records.iter().map(|r| json!({
|
||||
"story_id": r.story_id,
|
||||
"agent_name": r.agent_name,
|
||||
"timestamp": r.timestamp,
|
||||
"input_tokens": r.usage.input_tokens,
|
||||
"output_tokens": r.usage.output_tokens,
|
||||
"cache_creation_input_tokens": r.usage.cache_creation_input_tokens,
|
||||
"cache_read_input_tokens": r.usage.cache_read_input_tokens,
|
||||
"total_cost_usd": r.usage.total_cost_usd,
|
||||
})).collect::<Vec<_>>(),
|
||||
"totals": {
|
||||
"records": records.len(),
|
||||
"input_tokens": total_input,
|
||||
"output_tokens": total_output,
|
||||
"cache_creation_input_tokens": total_cache_create,
|
||||
"cache_read_input_tokens": total_cache_read,
|
||||
"total_cost_usd": total_cost,
|
||||
}
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
pub(super) fn tool_move_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
let target_stage = args
|
||||
.get("target_stage")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: target_stage")?;
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
|
||||
let (from_stage, to_stage) = move_story_to_stage(&project_root, story_id, target_stage)?;
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"story_id": story_id,
|
||||
"from_stage": from_stage,
|
||||
"to_stage": to_stage,
|
||||
"message": format!("Work item '{story_id}' moved from '{from_stage}' to '{to_stage}'.")
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::context::AppContext;
|
||||
|
||||
fn test_ctx(dir: &std::path::Path) -> AppContext {
|
||||
AppContext::new_test(dir.to_path_buf())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_server_logs_no_args_returns_string() {
|
||||
let result = tool_get_server_logs(&json!({})).unwrap();
|
||||
// Returns recent log lines (possibly empty in tests) — just verify no panic
|
||||
let _ = result;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_server_logs_with_filter_returns_matching_lines() {
|
||||
let result =
|
||||
tool_get_server_logs(&json!({"filter": "xyz_unlikely_match_999"})).unwrap();
|
||||
assert_eq!(result, "", "filter with no matches should return empty string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_server_logs_with_line_limit() {
|
||||
let result = tool_get_server_logs(&json!({"lines": 5})).unwrap();
|
||||
assert!(result.lines().count() <= 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_server_logs_max_cap_is_1000() {
|
||||
// Lines > 1000 are capped — just verify it returns without error
|
||||
let result = tool_get_server_logs(&json!({"lines": 9999})).unwrap();
|
||||
let _ = result;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_token_usage_empty_returns_zero_totals() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_get_token_usage(&json!({}), &ctx).unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["records"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(parsed["totals"]["records"], 0);
|
||||
assert_eq!(parsed["totals"]["total_cost_usd"], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_token_usage_returns_written_records() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let ctx = test_ctx(root);
|
||||
|
||||
let usage = crate::agents::TokenUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 200,
|
||||
cache_creation_input_tokens: 5000,
|
||||
cache_read_input_tokens: 10000,
|
||||
total_cost_usd: 1.57,
|
||||
};
|
||||
let record =
|
||||
crate::agents::token_usage::build_record("42_story_foo", "coder-1", None, usage);
|
||||
crate::agents::token_usage::append_record(root, &record).unwrap();
|
||||
|
||||
let result = tool_get_token_usage(&json!({}), &ctx).unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["records"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(parsed["records"][0]["story_id"], "42_story_foo");
|
||||
assert_eq!(parsed["records"][0]["agent_name"], "coder-1");
|
||||
assert_eq!(parsed["records"][0]["input_tokens"], 100);
|
||||
assert_eq!(parsed["totals"]["records"], 1);
|
||||
assert!((parsed["totals"]["total_cost_usd"].as_f64().unwrap() - 1.57).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_token_usage_filters_by_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let ctx = test_ctx(root);
|
||||
|
||||
let usage = crate::agents::TokenUsage {
|
||||
input_tokens: 50,
|
||||
output_tokens: 60,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
total_cost_usd: 0.5,
|
||||
};
|
||||
let r1 = crate::agents::token_usage::build_record("10_story_a", "coder-1", None, usage.clone());
|
||||
let r2 = crate::agents::token_usage::build_record("20_story_b", "coder-2", None, usage);
|
||||
crate::agents::token_usage::append_record(root, &r1).unwrap();
|
||||
crate::agents::token_usage::append_record(root, &r2).unwrap();
|
||||
|
||||
let result =
|
||||
tool_get_token_usage(&json!({"story_id": "10_story_a"}), &ctx).unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["records"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(parsed["records"][0]["story_id"], "10_story_a");
|
||||
assert_eq!(parsed["totals"]["records"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_prompt_permission_approved_returns_updated_input() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
// Spawn a task that immediately sends approval through the channel.
|
||||
let perm_rx = ctx.perm_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut rx = perm_rx.lock().await;
|
||||
if let Some(forward) = rx.recv().await {
|
||||
let _ = forward.response_tx.send(crate::http::context::PermissionDecision::Approve);
|
||||
}
|
||||
});
|
||||
|
||||
let result = tool_prompt_permission(
|
||||
&json!({"tool_name": "Bash", "input": {"command": "echo hello"}}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect("should succeed on approval");
|
||||
|
||||
let parsed: Value = serde_json::from_str(&result).expect("result should be valid JSON");
|
||||
assert_eq!(
|
||||
parsed["behavior"], "allow",
|
||||
"approved must return behavior:allow"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["updatedInput"]["command"], "echo hello",
|
||||
"approved must return updatedInput with original tool input for Claude Code SDK compatibility"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_prompt_permission_denied_returns_deny_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
// Spawn a task that immediately sends denial through the channel.
|
||||
let perm_rx = ctx.perm_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut rx = perm_rx.lock().await;
|
||||
if let Some(forward) = rx.recv().await {
|
||||
let _ = forward.response_tx.send(crate::http::context::PermissionDecision::Deny);
|
||||
}
|
||||
});
|
||||
|
||||
let result = tool_prompt_permission(
|
||||
&json!({"tool_name": "Write", "input": {}}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect("denial must return Ok, not Err");
|
||||
|
||||
let parsed: Value = serde_json::from_str(&result).expect("result should be valid JSON");
|
||||
assert_eq!(parsed["behavior"], "deny", "denied must return behavior:deny");
|
||||
assert!(parsed["message"].is_string(), "deny must include a message");
|
||||
}
|
||||
|
||||
// ── Permission rule generation tests ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn generate_rule_for_edit_tool() {
|
||||
let rule = generate_permission_rule("Edit", &json!({}));
|
||||
assert_eq!(rule, "Edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_rule_for_write_tool() {
|
||||
let rule = generate_permission_rule("Write", &json!({}));
|
||||
assert_eq!(rule, "Write");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_rule_for_bash_git() {
|
||||
let rule =
|
||||
generate_permission_rule("Bash", &json!({"command": "git status"}));
|
||||
assert_eq!(rule, "Bash(git *)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_rule_for_bash_cargo() {
|
||||
let rule =
|
||||
generate_permission_rule("Bash", &json!({"command": "cargo test --all"}));
|
||||
assert_eq!(rule, "Bash(cargo *)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_rule_for_bash_empty_command() {
|
||||
let rule = generate_permission_rule("Bash", &json!({}));
|
||||
assert_eq!(rule, "Bash(unknown *)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_rule_for_mcp_tool() {
|
||||
let rule = generate_permission_rule(
|
||||
"mcp__story-kit__create_story",
|
||||
&json!({"name": "foo"}),
|
||||
);
|
||||
assert_eq!(rule, "mcp__story-kit__create_story");
|
||||
}
|
||||
|
||||
// ── Settings.json writing tests ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn add_rule_creates_settings_file_when_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
add_permission_rule(tmp.path(), "Edit").unwrap();
|
||||
|
||||
let content = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
|
||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
||||
assert!(allow.contains(&json!("Edit")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rule_does_not_duplicate_existing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
add_permission_rule(tmp.path(), "Edit").unwrap();
|
||||
add_permission_rule(tmp.path(), "Edit").unwrap();
|
||||
|
||||
let content = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
|
||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
||||
let count = allow.iter().filter(|v| v == &&json!("Edit")).count();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rule_skips_when_wildcard_already_covers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let claude_dir = tmp.path().join(".claude");
|
||||
fs::create_dir_all(&claude_dir).unwrap();
|
||||
fs::write(
|
||||
claude_dir.join("settings.json"),
|
||||
r#"{"permissions":{"allow":["mcp__story-kit__*"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
add_permission_rule(tmp.path(), "mcp__story-kit__create_story").unwrap();
|
||||
|
||||
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
|
||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
||||
assert_eq!(allow.len(), 1);
|
||||
assert_eq!(allow[0], "mcp__story-kit__*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rule_appends_to_existing_rules() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let claude_dir = tmp.path().join(".claude");
|
||||
fs::create_dir_all(&claude_dir).unwrap();
|
||||
fs::write(
|
||||
claude_dir.join("settings.json"),
|
||||
r#"{"permissions":{"allow":["Edit"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
add_permission_rule(tmp.path(), "Write").unwrap();
|
||||
|
||||
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
|
||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
||||
assert_eq!(allow.len(), 2);
|
||||
assert!(allow.contains(&json!("Edit")));
|
||||
assert!(allow.contains(&json!("Write")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rule_preserves_other_settings_fields() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let claude_dir = tmp.path().join(".claude");
|
||||
fs::create_dir_all(&claude_dir).unwrap();
|
||||
fs::write(
|
||||
claude_dir.join("settings.json"),
|
||||
r#"{"permissions":{"allow":["Edit"]},"enabledMcpjsonServers":["story-kit"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
add_permission_rule(tmp.path(), "Write").unwrap();
|
||||
|
||||
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
|
||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
||||
let servers = settings["enabledMcpjsonServers"].as_array().unwrap();
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0], "story-kit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_and_restart_in_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"] == "rebuild_and_restart");
|
||||
assert!(
|
||||
tool.is_some(),
|
||||
"rebuild_and_restart missing from tools list"
|
||||
);
|
||||
let t = tool.unwrap();
|
||||
assert!(t["description"].as_str().unwrap().contains("Rebuild"));
|
||||
assert!(t["inputSchema"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebuild_and_restart_kills_agents_before_build() {
|
||||
// Verify that calling rebuild_and_restart on an empty pool doesn't
|
||||
// panic and proceeds to the build step. We can't test exec() in a
|
||||
// unit test, but we can verify the build attempt happens.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
// The build will succeed (we're running in the real workspace) and
|
||||
// then exec() will be called — which would replace our test process.
|
||||
// So we only test that the function *runs* without panicking up to
|
||||
// the agent-kill step. We do this by checking the pool is empty.
|
||||
assert_eq!(ctx.agents.list_agents().unwrap().len(), 0);
|
||||
ctx.agents.kill_all_children(); // should not panic on empty pool
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_uses_matching_build_profile() {
|
||||
// The build must use the same profile (debug/release) as the running
|
||||
// binary, otherwise cargo build outputs to a different target dir and
|
||||
// current_exe() still points at the old binary.
|
||||
let build_args: Vec<&str> = if cfg!(debug_assertions) {
|
||||
vec!["build", "-p", "story-kit"]
|
||||
} else {
|
||||
vec!["build", "--release", "-p", "story-kit"]
|
||||
};
|
||||
|
||||
// Tests always run in debug mode, so --release must NOT be present.
|
||||
assert!(
|
||||
!build_args.contains(&"--release"),
|
||||
"In debug builds, rebuild must not pass --release (would put \
|
||||
the binary in target/release/ while current_exe() points to \
|
||||
target/debug/)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── move_story tool tests ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn move_story_in_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");
|
||||
assert!(tool.is_some(), "move_story missing from tools list");
|
||||
let t = tool.unwrap();
|
||||
assert!(t["description"].is_string());
|
||||
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"));
|
||||
assert!(req_names.contains(&"target_stage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_move_story_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_move_story(&json!({"target_stage": "current"}), &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_move_story_missing_target_stage() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_move_story(&json!({"story_id": "1_story_test"}), &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("target_stage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_move_story_invalid_target_stage() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
// Seed project root in state so get_project_root works
|
||||
let backlog = root.join(".story_kit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(backlog.join("1_story_test.md"), "---\nname: Test\n---\n").unwrap();
|
||||
let ctx = test_ctx(root);
|
||||
let result = tool_move_story(
|
||||
&json!({"story_id": "1_story_test", "target_stage": "invalid"}),
|
||||
&ctx,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid target_stage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_move_story_moves_from_backlog_to_current() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let backlog = root.join(".story_kit/work/1_backlog");
|
||||
let current = root.join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(backlog.join("5_story_test.md"), "---\nname: Test\n---\n").unwrap();
|
||||
|
||||
let ctx = test_ctx(root);
|
||||
let result = tool_move_story(
|
||||
&json!({"story_id": "5_story_test", "target_stage": "current"}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!backlog.join("5_story_test.md").exists());
|
||||
assert!(current.join("5_story_test.md").exists());
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["story_id"], "5_story_test");
|
||||
assert_eq!(parsed["from_stage"], "backlog");
|
||||
assert_eq!(parsed["to_stage"], "current");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_move_story_moves_from_current_to_backlog() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let current = root.join(".story_kit/work/2_current");
|
||||
let backlog = root.join(".story_kit/work/1_backlog");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(current.join("6_story_back.md"), "---\nname: Back\n---\n").unwrap();
|
||||
|
||||
let ctx = test_ctx(root);
|
||||
let result = tool_move_story(
|
||||
&json!({"story_id": "6_story_back", "target_stage": "backlog"}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!current.join("6_story_back.md").exists());
|
||||
assert!(backlog.join("6_story_back.md").exists());
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["from_stage"], "current");
|
||||
assert_eq!(parsed["to_stage"], "backlog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_move_story_idempotent_when_already_in_target() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let current = root.join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(current.join("7_story_idem.md"), "---\nname: Idem\n---\n").unwrap();
|
||||
|
||||
let ctx = test_ctx(root);
|
||||
let result = tool_move_story(
|
||||
&json!({"story_id": "7_story_idem", "target_stage": "current"}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(current.join("7_story_idem.md").exists());
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["from_stage"], "current");
|
||||
assert_eq!(parsed["to_stage"], "current");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_move_story_error_when_not_found() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_move_story(
|
||||
&json!({"story_id": "99_story_ghost", "target_stage": "current"}),
|
||||
&ctx,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found in any pipeline stage"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user