//! 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<String>,
/// Group/phase this task belongs to
pub group: Option<String>,
/// 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<String>,
}
/// Result of parsing tasks from a document
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskParseResult {
/// Successfully parsed tasks
pub tasks: Vec<ParsedTask>,
/// Groups/phases found
pub groups: Vec<String>,
/// Total tasks found
pub total: usize,
/// Any parsing warnings
pub warnings: Vec<String>,
}
/// 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<String>,
}
/// 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<Uuid>,
},
/// Create a new file from template
CreateFile {
template_id: String,
name: String,
seed_content: Option<String>,
},
/// 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<String>,
/// Suggested next actions
pub next_steps: Vec<SuggestedAction>,
/// Impact on contract phase
pub phase_impact: Option<PhaseImpact>,
}
/// 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<String> = 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<String> = 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::<Vec<_>>()
.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<String> {
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"));
}
}