use crate::http::context::AppContext; use serde_json::{Value, json}; use std::fs; use std::path::{Path, PathBuf}; /// Parse all AC items from a story file, returning (text, is_checked) pairs. fn parse_ac_items(contents: &str) -> Vec<(String, bool)> { let mut in_ac_section = false; let mut items = Vec::new(); for line in contents.lines() { let trimmed = line.trim(); if trimmed == "## Acceptance Criteria" { in_ac_section = true; continue; } // Stop at the next heading if in_ac_section && trimmed.starts_with("## ") { break; } if in_ac_section { 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)); } } } items } /// Find the most recent log file for any agent under `.storkit/logs/{story_id}/`. fn find_most_recent_log(project_root: &Path, story_id: &str) -> Option { let dir = project_root .join(".storkit") .join("logs") .join(story_id); if !dir.is_dir() { return None; } let mut best: Option<(PathBuf, std::time::SystemTime)> = None; let entries = fs::read_dir(&dir).ok()?; for entry in entries.flatten() { let path = entry.path(); let name = match path.file_name().and_then(|n| n.to_str()) { Some(n) => n.to_string(), None => continue, }; if !name.ends_with(".log") { continue; } let modified = match entry.metadata().and_then(|m| m.modified()) { Ok(t) => t, Err(_) => continue, }; if best.as_ref().is_none_or(|(_, t)| modified > *t) { best = Some((path, modified)); } } best.map(|(p, _)| p) } /// Return the last N raw lines from a file. fn last_n_lines(path: &Path, n: usize) -> Result, String> { let content = fs::read_to_string(path).map_err(|e| format!("Failed to read log file: {e}"))?; let lines: Vec = content .lines() .rev() .take(n) .map(|l| l.to_string()) .collect::>() .into_iter() .rev() .collect(); Ok(lines) } /// Run `git diff --stat {base}...HEAD` in the worktree. async fn git_diff_stat(worktree: &Path, base: &str) -> Option { let dir = worktree.to_path_buf(); let base_arg = format!("{base}...HEAD"); tokio::task::spawn_blocking(move || { let output = std::process::Command::new("git") .args(["diff", "--stat", &base_arg]) .current_dir(&dir) .output() .ok()?; if output.status.success() { Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) } else { None } }) .await .ok() .flatten() } /// Return the last N commit messages on the current branch relative to base. async fn git_log_commits(worktree: &Path, base: &str, count: usize) -> Option> { let dir = worktree.to_path_buf(); let range = format!("{base}..HEAD"); let count_str = count.to_string(); tokio::task::spawn_blocking(move || { let output = std::process::Command::new("git") .args(["log", &range, "--oneline", &format!("-{count_str}")]) .current_dir(&dir) .output() .ok()?; if output.status.success() { let lines: Vec = String::from_utf8(output.stdout) .ok()? .lines() .filter(|l| !l.is_empty()) .map(|l| l.to_string()) .collect(); Some(lines) } else { None } }) .await .ok() .flatten() } /// Return the active branch name for the given directory. async fn git_branch(dir: &Path) -> Option { let dir = dir.to_path_buf(); tokio::task::spawn_blocking(move || { let output = std::process::Command::new("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(&dir) .output() .ok()?; if output.status.success() { Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) } else { None } }) .await .ok() .flatten() } pub(super) async fn tool_whatsup(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 root = ctx.state.get_project_root()?; let current_dir = root.join(".storkit").join("work").join("2_current"); let filepath = current_dir.join(format!("{story_id}.md")); if !filepath.exists() { return Err(format!( "Story '{story_id}' not found in work/2_current/. Check the story_id and ensure it is in the current stage." )); } let contents = fs::read_to_string(&filepath).map_err(|e| format!("Failed to read story file: {e}"))?; // --- Front matter --- let mut front_matter = serde_json::Map::new(); if let Ok(meta) = crate::io::story_metadata::parse_front_matter(&contents) { if let Some(name) = &meta.name { front_matter.insert("name".to_string(), json!(name)); } if let Some(agent) = &meta.agent { front_matter.insert("agent".to_string(), json!(agent)); } if let Some(true) = meta.blocked { front_matter.insert("blocked".to_string(), json!(true)); } if let Some(qa) = &meta.qa { front_matter.insert("qa".to_string(), json!(qa.as_str())); } if let Some(rc) = meta.retry_count && rc > 0 { front_matter.insert("retry_count".to_string(), json!(rc)); } if let Some(mf) = &meta.merge_failure { front_matter.insert("merge_failure".to_string(), json!(mf)); } if let Some(rh) = meta.review_hold && rh { front_matter.insert("review_hold".to_string(), json!(rh)); } } // --- AC checklist --- let ac_items: Vec = parse_ac_items(&contents) .into_iter() .map(|(text, checked)| json!({ "text": text, "checked": checked })) .collect(); // --- Worktree --- let worktree_path = root.join(".storkit").join("worktrees").join(story_id); let (_, worktree_info) = if worktree_path.is_dir() { let branch = git_branch(&worktree_path).await; ( branch.clone(), Some(json!({ "path": worktree_path.to_string_lossy(), "branch": branch, })), ) } else { (None, None) }; // --- Git diff stat --- let diff_stat = if worktree_path.is_dir() { git_diff_stat(&worktree_path, "master").await } else { None }; // --- Last 5 commits --- let commits = if worktree_path.is_dir() { git_log_commits(&worktree_path, "master", 5).await } else { None }; // --- Most recent agent log (last 20 lines) --- let agent_log = match find_most_recent_log(&root, story_id) { Some(log_path) => { let filename = log_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("") .to_string(); match last_n_lines(&log_path, 20) { Ok(lines) => Some(json!({ "file": filename, "lines": lines, })), Err(_) => None, } } None => None, }; let result = json!({ "story_id": story_id, "front_matter": front_matter, "acceptance_criteria": ac_items, "worktree": worktree_info, "git_diff_stat": diff_stat, "commits": commits, "agent_log": agent_log, }); serde_json::to_string_pretty(&result).map_err(|e| format!("Serialization error: {e}")) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn parse_ac_items_returns_checked_and_unchecked() { let content = "---\nname: test\n---\n\n## Acceptance Criteria\n\n- [ ] item one\n- [x] item two\n- [X] item three\n\n## Out of Scope\n\n- [ ] not an ac\n"; let items = parse_ac_items(content); assert_eq!(items.len(), 3); assert_eq!(items[0], ("item one".to_string(), false)); assert_eq!(items[1], ("item two".to_string(), true)); assert_eq!(items[2], ("item three".to_string(), true)); } #[test] fn parse_ac_items_empty_when_no_section() { let content = "---\nname: test\n---\n\nNo AC section here.\n"; let items = parse_ac_items(content); assert!(items.is_empty()); } #[test] fn find_most_recent_log_returns_none_for_missing_dir() { let tmp = tempdir().unwrap(); let result = find_most_recent_log(tmp.path(), "nonexistent_story"); assert!(result.is_none()); } #[test] fn find_most_recent_log_returns_newest_file() { let tmp = tempdir().unwrap(); let log_dir = tmp .path() .join(".storkit") .join("logs") .join("42_story_foo"); fs::create_dir_all(&log_dir).unwrap(); let old_path = log_dir.join("coder-1-sess-old.log"); fs::write(&old_path, "old content").unwrap(); // Ensure different mtime std::thread::sleep(std::time::Duration::from_millis(50)); let new_path = log_dir.join("coder-1-sess-new.log"); fs::write(&new_path, "new content").unwrap(); let result = find_most_recent_log(tmp.path(), "42_story_foo").unwrap(); assert!( result.to_string_lossy().contains("sess-new"), "Expected newest file, got: {}", result.display() ); } #[tokio::test] async fn tool_whatsup_returns_error_for_missing_story() { let tmp = tempdir().unwrap(); let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf()); let result = tool_whatsup(&json!({"story_id": "999_story_nonexistent"}), &ctx).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("not found in work/2_current/")); } #[tokio::test] async fn tool_whatsup_returns_story_data() { let tmp = tempdir().unwrap(); let current_dir = tmp .path() .join(".storkit") .join("work") .join("2_current"); fs::create_dir_all(¤t_dir).unwrap(); let story_content = "---\nname: My Test Story\nagent: coder-1\n---\n\n## Acceptance Criteria\n\n- [ ] First criterion\n- [x] Second criterion\n\n## Out of Scope\n\n- nothing\n"; fs::write(current_dir.join("42_story_test.md"), story_content).unwrap(); let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf()); let result = tool_whatsup(&json!({"story_id": "42_story_test"}), &ctx) .await .unwrap(); let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); assert_eq!(parsed["story_id"], "42_story_test"); assert_eq!(parsed["front_matter"]["name"], "My Test Story"); assert_eq!(parsed["front_matter"]["agent"], "coder-1"); let ac = parsed["acceptance_criteria"].as_array().unwrap(); assert_eq!(ac.len(), 2); assert_eq!(ac[0]["text"], "First criterion"); assert_eq!(ac[0]["checked"], false); assert_eq!(ac[1]["text"], "Second criterion"); assert_eq!(ac[1]["checked"], true); } }