//! Task output processing and task derivation utilities. //! //! This module provides utilities for: //! - Parsing task lists from markdown documents //! - Analyzing completed task outputs //! - Suggesting follow-up actions based on task results use regex::Regex; use serde::{Deserialize, Serialize}; use uuid::Uuid; /// A parsed task from a markdown document #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ParsedTask { /// Task name/title pub name: String, /// Task description or plan pub description: Option, /// Group/phase this task belongs to pub group: Option, /// Order within the group (0-indexed) pub order: usize, /// Whether this task was marked as completed in source pub completed: bool, /// Dependencies (names of other tasks) pub dependencies: Vec, } /// Result of parsing tasks from a document #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaskParseResult { /// Successfully parsed tasks pub tasks: Vec, /// Groups/phases found pub groups: Vec, /// Total tasks found pub total: usize, /// Any parsing warnings pub warnings: Vec, } /// Impact on contract phase #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PhaseImpact { /// Current phase pub phase: String, /// Whether phase targets are now met pub targets_met: bool, /// Tasks remaining in phase pub tasks_remaining: usize, /// Suggestion for phase transition pub transition_suggestion: Option, } /// Suggested action based on task output #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum SuggestedAction { /// Create a follow-up task CreateTask { name: String, plan: String, chain_from: Option, }, /// Create a new file from template CreateFile { template_id: String, name: String, seed_content: Option, }, /// Update an existing file UpdateFile { file_id: Uuid, file_name: String, additions: String, }, /// Advance to next phase AdvancePhase { to_phase: String, }, /// Run the next chained task RunNextTask { task_id: Uuid, task_name: String, }, /// Mark the contract as completed MarkContractComplete { contract_id: Uuid, }, } /// Analysis of a completed task's output #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaskOutputAnalysis { /// Summary of what was accomplished pub summary: String, /// Files that were created/modified (from diff) pub files_affected: Vec, /// Suggested next actions pub next_steps: Vec, /// Impact on contract phase pub phase_impact: Option, } /// Parse tasks from a markdown task breakdown document /// /// Supports formats like: /// - `[ ] Task name` /// - `[x] Completed task` /// - `1. Task name` /// - `- Task name` /// /// Groups are detected from `## Phase/Section` headings. pub fn parse_tasks_from_breakdown(content: &str) -> TaskParseResult { let mut tasks = Vec::new(); let mut groups = Vec::new(); let mut warnings = Vec::new(); let mut current_group: Option = None; let mut task_order = 0; // Patterns for task items let checkbox_pattern = Regex::new(r"^\s*[-*]\s*\[([ xX])\]\s*(.+)$").unwrap(); let numbered_checkbox = Regex::new(r"^\s*\d+\.\s*\[([ xX])\]\s*(.+)$").unwrap(); let numbered_pattern = Regex::new(r"^\s*\d+\.\s+(.+)$").unwrap(); let bullet_pattern = Regex::new(r"^\s*[-*]\s+(.+)$").unwrap(); let heading_pattern = Regex::new(r"^##\s+(?:Phase\s*\d*:?\s*)?(.+)$").unwrap(); // Patterns for dependencies (inline) let depends_pattern = Regex::new(r"(?i)\(?\s*(?:depends on|after|requires):?\s*([^)]+)\)?").unwrap(); for line in content.lines() { let trimmed = line.trim(); // Skip empty lines if trimmed.is_empty() { continue; } // Check for section headings if let Some(caps) = heading_pattern.captures(trimmed) { let group_name = caps[1].trim().to_string(); if !groups.contains(&group_name) { groups.push(group_name.clone()); } current_group = Some(group_name); task_order = 0; continue; } // Try to parse as a task let mut task_name: Option = None; let mut completed = false; // Try checkbox patterns first (more specific) if let Some(caps) = checkbox_pattern.captures(trimmed) { completed = &caps[1] != " "; task_name = Some(caps[2].trim().to_string()); } else if let Some(caps) = numbered_checkbox.captures(trimmed) { completed = &caps[1] != " "; task_name = Some(caps[2].trim().to_string()); } else if let Some(caps) = numbered_pattern.captures(trimmed) { task_name = Some(caps[1].trim().to_string()); } else if let Some(caps) = bullet_pattern.captures(trimmed) { // Only treat as task if it looks like a task (has actionable verbs) let text = caps[1].trim(); if looks_like_task(text) { task_name = Some(text.to_string()); } } if let Some(name) = task_name { // Skip items that are clearly not tasks if name.to_lowercase().starts_with("note:") || name.to_lowercase().starts_with("todo:") && name.len() < 10 || name.starts_with('#') { continue; } // Extract dependencies if present let dependencies = if let Some(dep_caps) = depends_pattern.captures(&name) { dep_caps[1] .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() } else { Vec::new() }; // Clean task name (remove dependency info) let clean_name = depends_pattern.replace(&name, "").trim().to_string(); // Extract description if there's a colon let (final_name, description) = if let Some(idx) = clean_name.find(':') { let (n, d) = clean_name.split_at(idx); (n.trim().to_string(), Some(d[1..].trim().to_string())) } else { (clean_name, None) }; tasks.push(ParsedTask { name: final_name, description, group: current_group.clone(), order: task_order, completed, dependencies, }); task_order += 1; } } let total = tasks.len(); // Add warnings if tasks.is_empty() { warnings.push("No tasks found in document. Ensure tasks are formatted as checkbox items (- [ ] Task) or numbered lists (1. Task).".to_string()); } TaskParseResult { tasks, groups, total, warnings, } } /// Check if text looks like a task (has action verbs at word boundaries) fn looks_like_task(text: &str) -> bool { let lower = text.to_lowercase(); let action_verbs = [ "add", "create", "implement", "build", "write", "fix", "update", "refactor", "test", "configure", "set up", "setup", "deploy", "integrate", "migrate", "design", "review", "document", "remove", "delete", "modify", "change", "improve", "optimize", "enable", "disable", "install", "initialize", "define", "extend", "extract", ]; // Check if text starts with an action verb (followed by space or end) for verb in &action_verbs { if lower.starts_with(verb) { // Check for word boundary after verb let after = &lower[verb.len()..]; if after.is_empty() || after.starts_with(' ') || after.starts_with('_') { return true; } } // Check if verb appears after space with word boundary let pattern = format!(" {} ", verb); let pattern_end = format!(" {}", verb); if lower.contains(&pattern) { return true; } // Check if verb is at the end of string after a space if lower.ends_with(&pattern_end) && lower.len() > pattern_end.len() { return true; } } false } /// Analyze a completed task's output to suggest next actions pub fn analyze_task_output( _task_id: Uuid, task_name: &str, task_result: Option<&str>, task_diff: Option<&str>, contract_phase: &str, total_tasks: usize, completed_tasks: usize, next_task: Option<(Uuid, String)>, dev_notes_file: Option<(Uuid, String)>, ) -> TaskOutputAnalysis { let mut next_steps = Vec::new(); let mut files_affected = Vec::new(); // Parse files from diff if available if let Some(diff) = task_diff { files_affected = extract_files_from_diff(diff); } // Generate summary let summary = if let Some(result) = task_result { if result.len() > 200 { format!("{}...", &result[..200]) } else { result.to_string() } } else { format!("Task '{}' completed", task_name) }; // If there's a next chained task, suggest running it if let Some((next_id, next_name)) = next_task { next_steps.push(SuggestedAction::RunNextTask { task_id: next_id, task_name: next_name, }); } // Suggest updating Dev Notes if in execute phase and file exists if contract_phase == "execute" { if let Some((file_id, file_name)) = dev_notes_file { let additions = format!( "\n## Task: {}\n\n{}\n\n### Files Modified\n{}\n", task_name, summary, files_affected.iter() .map(|f| format!("- {}", f)) .collect::>() .join("\n") ); next_steps.push(SuggestedAction::UpdateFile { file_id, file_name, additions, }); } else { // Suggest creating Dev Notes next_steps.push(SuggestedAction::CreateFile { template_id: "dev-notes".to_string(), name: "Development Notes".to_string(), seed_content: Some(format!( "# Development Notes\n\n## Task: {}\n\n{}\n", task_name, summary )), }); } } // Calculate phase impact let new_completed = completed_tasks + 1; let targets_met = new_completed >= total_tasks && total_tasks > 0; let tasks_remaining = total_tasks.saturating_sub(new_completed); let transition_suggestion = if targets_met && contract_phase == "execute" { Some("All tasks complete. Ready to advance to Review phase.".to_string()) } else { None }; // If targets are met, suggest phase transition if targets_met && contract_phase == "execute" { next_steps.push(SuggestedAction::AdvancePhase { to_phase: "review".to_string(), }); } let phase_impact = Some(PhaseImpact { phase: contract_phase.to_string(), targets_met, tasks_remaining, transition_suggestion, }); TaskOutputAnalysis { summary, files_affected, next_steps, phase_impact, } } /// Extract file paths from a git diff fn extract_files_from_diff(diff: &str) -> Vec { let mut files = Vec::new(); let file_pattern = Regex::new(r"^(?:diff --git a/|[+]{3} b/|[-]{3} a/)(.+)$").unwrap(); for line in diff.lines() { if let Some(caps) = file_pattern.captures(line) { let path = caps[1].trim().to_string(); // Skip /dev/null and duplicates if path != "/dev/null" && !files.contains(&path) { // Clean up path (remove a/ or b/ prefix from git diff) let clean_path = path.trim_start_matches("a/").trim_start_matches("b/").to_string(); if !files.contains(&clean_path) { files.push(clean_path); } } } } files } /// Format parsed tasks for display pub fn format_parsed_tasks(result: &TaskParseResult) -> String { let mut output = String::new(); if result.tasks.is_empty() { output.push_str("No tasks found in the document.\n"); for warning in &result.warnings { output.push_str(&format!("Warning: {}\n", warning)); } return output; } output.push_str(&format!("Found {} task(s)", result.total)); if !result.groups.is_empty() { output.push_str(&format!(" in {} group(s)", result.groups.len())); } output.push_str(":\n\n"); let mut current_group: Option<&str> = None; for (i, task) in result.tasks.iter().enumerate() { // Print group header if changed if task.group.as_deref() != current_group { current_group = task.group.as_deref(); if let Some(group) = current_group { output.push_str(&format!("**{}**\n", group)); } } let status = if task.completed { "[x]" } else { "[ ]" }; output.push_str(&format!("{}. {} {}", i + 1, status, task.name)); if !task.dependencies.is_empty() { output.push_str(&format!(" (depends on: {})", task.dependencies.join(", "))); } output.push('\n'); } output } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_checkbox_tasks() { let content = r#" ## Phase 1: Setup - [ ] Set up project structure - [x] Configure dev environment ## Phase 2: Features 1. [ ] Implement authentication 2. [ ] Add user dashboard "#; let result = parse_tasks_from_breakdown(content); assert_eq!(result.total, 4); assert_eq!(result.groups.len(), 2); assert!(!result.tasks[0].completed); assert!(result.tasks[1].completed); } #[test] fn test_parse_with_dependencies() { let content = r#" - [ ] Task A - [ ] Task B (depends on: Task A) "#; let result = parse_tasks_from_breakdown(content); assert_eq!(result.tasks[1].dependencies, vec!["Task A"]); } #[test] fn test_extract_files_from_diff() { let diff = r#" diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +fn new_function() {} "#; let files = extract_files_from_diff(diff); assert!(files.contains(&"src/main.rs".to_string())); } #[test] fn test_looks_like_task() { assert!(looks_like_task("Add authentication")); assert!(looks_like_task("Create user model")); assert!(looks_like_task("implement feature X")); assert!(!looks_like_task("This is a note")); assert!(!looks_like_task("Summary of changes")); } }