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