Skip to main content

brainwires_core/
plan_parser.rs

1//! Plan Parser - Extract tasks from plan content
2//!
3//! Parses structured plan content to extract numbered steps
4//! that can be converted into tasks.
5
6use crate::task::{Task, TaskPriority};
7use regex::Regex;
8use std::sync::LazyLock;
9
10/// A parsed step from plan content
11#[derive(Debug, Clone)]
12pub struct ParsedStep {
13    /// Step number (1-based)
14    pub number: usize,
15    /// Step description
16    pub description: String,
17    /// Indentation level (0 = root, 1 = substep, etc.)
18    pub indent_level: usize,
19    /// Whether this step is marked as high priority
20    pub is_priority: bool,
21}
22
23/// Parse plan content into structured steps
24pub fn parse_plan_steps(content: &str) -> Vec<ParsedStep> {
25    let mut steps = Vec::new();
26
27    // Patterns for numbered steps:
28    // - "1. Step description"
29    // - "1) Step description"
30    // - "Step 1: Description"
31    // - "- Step description" (bullets)
32    // - "  1. Substep" (indented)
33
34    static NUMBERED_RE: LazyLock<Regex> = LazyLock::new(|| {
35        Regex::new(r"^(\s*)(\d+)[.)]\s*(.+)$").expect("valid regex")
36    });
37    static STEP_COLON_RE: LazyLock<Regex> = LazyLock::new(|| {
38        Regex::new(r"^(\s*)(?:Step\s+)?(\d+):\s*(.+)$").expect("valid regex")
39    });
40    static BULLET_RE: LazyLock<Regex> = LazyLock::new(|| {
41        Regex::new(r"^(\s*)[-*]\s+(.+)$").expect("valid regex")
42    });
43    let numbered_re = &*NUMBERED_RE;
44    let step_colon_re = &*STEP_COLON_RE;
45    let bullet_re = &*BULLET_RE;
46
47    let mut current_number = 0;
48
49    for line in content.lines() {
50        let trimmed = line.trim();
51        if trimmed.is_empty() {
52            continue;
53        }
54
55        // Try numbered format: "1. Description" or "1) Description"
56        if let Some(caps) = numbered_re.captures(line) {
57            let indent = caps.get(1).map(|m| m.as_str().len()).unwrap_or(0);
58            let _num: usize = caps.get(2).expect("group 2 always present in match").as_str().parse().unwrap_or(0);
59            let desc = caps.get(3).expect("group 3 always present in match").as_str().trim();
60
61            current_number += 1;
62            let indent_level = indent / 2; // Assume 2-space indentation
63
64            steps.push(ParsedStep {
65                number: current_number,
66                description: desc.to_string(),
67                indent_level,
68                is_priority: desc.to_lowercase().contains("important")
69                    || desc.to_lowercase().contains("critical")
70                    || desc.contains("!"),
71            });
72            continue;
73        }
74
75        // Try "Step N:" format
76        if let Some(caps) = step_colon_re.captures(line) {
77            let indent = caps.get(1).map(|m| m.as_str().len()).unwrap_or(0);
78            let _num: usize = caps.get(2).expect("group 2 always present in match").as_str().parse().unwrap_or(0);
79            let desc = caps.get(3).expect("group 3 always present in match").as_str().trim();
80
81            current_number += 1;
82            let indent_level = indent / 2;
83
84            steps.push(ParsedStep {
85                number: current_number,
86                description: desc.to_string(),
87                indent_level,
88                is_priority: desc.to_lowercase().contains("important")
89                    || desc.to_lowercase().contains("critical"),
90            });
91            continue;
92        }
93
94        // Try bullet format (only in certain sections)
95        if let Some(caps) = bullet_re.captures(line) {
96            let indent = caps.get(1).map(|m| m.as_str().len()).unwrap_or(0);
97            let desc = caps.get(2).expect("group 2 always present in match").as_str().trim();
98
99            // Skip bullets that look like notes/comments
100            if desc.starts_with("Note:") || desc.starts_with("Warning:") {
101                continue;
102            }
103
104            // Only include bullets that look like action items
105            if desc.len() > 10 && (
106                desc.to_lowercase().contains("create") ||
107                desc.to_lowercase().contains("add") ||
108                desc.to_lowercase().contains("implement") ||
109                desc.to_lowercase().contains("update") ||
110                desc.to_lowercase().contains("modify") ||
111                desc.to_lowercase().contains("configure") ||
112                desc.to_lowercase().contains("set up") ||
113                desc.to_lowercase().contains("install") ||
114                desc.to_lowercase().contains("test") ||
115                desc.to_lowercase().contains("verify") ||
116                desc.to_lowercase().contains("check") ||
117                desc.to_lowercase().contains("review") ||
118                desc.to_lowercase().contains("fix") ||
119                desc.to_lowercase().contains("remove") ||
120                desc.to_lowercase().contains("delete")
121            ) {
122                current_number += 1;
123                let indent_level = indent / 2;
124
125                steps.push(ParsedStep {
126                    number: current_number,
127                    description: desc.to_string(),
128                    indent_level,
129                    is_priority: false,
130                });
131            }
132        }
133    }
134
135    steps
136}
137
138/// Convert parsed steps into Task objects
139pub fn steps_to_tasks(steps: &[ParsedStep], plan_id: &str) -> Vec<Task> {
140    let mut tasks = Vec::new();
141    let mut parent_stack: Vec<String> = Vec::new();
142
143    for step in steps {
144        let task_id = format!("{}-step-{}", &plan_id[..8.min(plan_id.len())], step.number);
145
146        let priority = if step.is_priority {
147            TaskPriority::High
148        } else {
149            TaskPriority::Normal
150        };
151
152        let mut task = Task::new_for_plan(
153            task_id.clone(),
154            step.description.clone(),
155            plan_id.to_string(),
156        );
157        task.priority = priority;
158
159        // Handle hierarchy based on indent level
160        if step.indent_level == 0 {
161            // Root level task
162            parent_stack.clear();
163            parent_stack.push(task_id.clone());
164        } else if step.indent_level <= parent_stack.len() {
165            // Same or higher level - pop back
166            parent_stack.truncate(step.indent_level);
167            if let Some(parent_id) = parent_stack.last() {
168                task.parent_id = Some(parent_id.clone());
169            }
170            parent_stack.push(task_id.clone());
171        } else {
172            // Deeper level - current parent is last in stack
173            if let Some(parent_id) = parent_stack.last() {
174                task.parent_id = Some(parent_id.clone());
175            }
176            parent_stack.push(task_id.clone());
177        }
178
179        // Add sequential dependencies (each step depends on previous)
180        if !tasks.is_empty() && step.indent_level == 0 {
181            // Only add dependency for root-level tasks
182            let prev_task: &Task = &tasks[tasks.len() - 1];
183            if prev_task.parent_id.is_none() {
184                task.depends_on.push(prev_task.id.clone());
185            }
186        }
187
188        tasks.push(task);
189    }
190
191    // Update parent tasks to include children
192    let task_ids: Vec<_> = tasks.iter().map(|t| t.id.clone()).collect();
193    for i in 0..tasks.len() {
194        if let Some(ref parent_id) = tasks[i].parent_id.clone() {
195            // Find parent and add this task as child
196            for task in tasks.iter_mut() {
197                if task.id == *parent_id {
198                    task.children.push(task_ids[i].clone());
199                    break;
200                }
201            }
202        }
203    }
204
205    tasks
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_parse_numbered_steps() {
214        let content = r#"
2151. Create the user model
2162. Add authentication endpoints
2173. Implement JWT token handling
218"#;
219        let steps = parse_plan_steps(content);
220        assert_eq!(steps.len(), 3);
221        assert_eq!(steps[0].description, "Create the user model");
222        assert_eq!(steps[1].description, "Add authentication endpoints");
223    }
224
225    #[test]
226    fn test_parse_step_colon_format() {
227        let content = r#"
228Step 1: Initialize the project
229Step 2: Configure dependencies
230"#;
231        let steps = parse_plan_steps(content);
232        assert_eq!(steps.len(), 2);
233        assert_eq!(steps[0].description, "Initialize the project");
234    }
235
236    #[test]
237    fn test_parse_indented_steps() {
238        let content = r#"
2391. Setup phase
240  1. Install dependencies
241  2. Configure environment
2422. Implementation phase
243"#;
244        let steps = parse_plan_steps(content);
245        assert_eq!(steps.len(), 4);
246        // Note: Due to renumbering, indent_level matters more than the original numbers
247    }
248
249    #[test]
250    fn test_steps_to_tasks() {
251        let content = "1. First step\n2. Second step";
252        let steps = parse_plan_steps(content);
253        let tasks = steps_to_tasks(&steps, "plan-12345678");
254
255        assert_eq!(tasks.len(), 2);
256        assert!(tasks[0].plan_id.is_some());
257        assert_eq!(tasks[0].plan_id.as_ref().unwrap(), "plan-12345678");
258    }
259
260    #[test]
261    fn test_priority_detection() {
262        let content = "1. Important: Fix critical bug!\n2. Normal task";
263        let steps = parse_plan_steps(content);
264
265        assert!(steps[0].is_priority);
266        assert!(!steps[1].is_priority);
267    }
268}