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 { 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 { 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 = 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 { 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: } // 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 { 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::>(), "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 { 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")); } }