Skip to main content

plan_tooling/
parse.rs

1use serde::Serialize;
2use std::path::Path;
3
4pub mod to_json;
5
6#[derive(Debug, Clone, Serialize)]
7pub struct Plan {
8    pub title: String,
9    pub file: String,
10    pub sprints: Vec<Sprint>,
11}
12
13#[derive(Debug, Clone, Serialize)]
14pub struct Sprint {
15    pub number: i32,
16    pub name: String,
17    pub start_line: u32,
18    pub tasks: Vec<Task>,
19}
20
21#[derive(Debug, Clone, Serialize)]
22pub struct Task {
23    pub id: String,
24    pub name: String,
25    pub sprint: i32,
26    pub start_line: u32,
27    pub location: Vec<String>,
28    pub description: Option<String>,
29    pub dependencies: Option<Vec<String>>,
30    pub complexity: Option<i32>,
31    pub acceptance_criteria: Vec<String>,
32    pub validation: Vec<String>,
33}
34
35pub fn parse_plan_with_display(
36    path: &Path,
37    display_path: &str,
38) -> anyhow::Result<(Plan, Vec<String>)> {
39    let raw = std::fs::read(path)?;
40    let raw_text = String::from_utf8_lossy(&raw);
41    let raw_lines: Vec<String> = raw_text.lines().map(|l| l.to_string()).collect();
42
43    let mut plan_title = String::new();
44    for line in &raw_lines {
45        if let Some(rest) = line.strip_prefix("# ") {
46            plan_title = rest.trim().to_string();
47            break;
48        }
49    }
50
51    let mut errors: Vec<String> = Vec::new();
52
53    let mut sprints: Vec<Sprint> = Vec::new();
54    let mut current_sprint: Option<Sprint> = None;
55    let mut current_task: Option<Task> = None;
56
57    fn finish_task(
58        current_task: &mut Option<Task>,
59        current_sprint: &mut Option<Sprint>,
60        errors: &mut Vec<String>,
61        display_path: &str,
62    ) {
63        let Some(task) = current_task.take() else {
64            return;
65        };
66        let Some(sprint) = current_sprint.as_mut() else {
67            errors.push(format!(
68                "{display_path}:{}: task outside of any sprint: {}",
69                task.start_line, task.id
70            ));
71            return;
72        };
73        sprint.tasks.push(task);
74    }
75
76    fn finish_sprint(current_sprint: &mut Option<Sprint>, sprints: &mut Vec<Sprint>) {
77        if let Some(s) = current_sprint.take() {
78            sprints.push(s);
79        }
80    }
81
82    let mut i: usize = 0;
83    while i < raw_lines.len() {
84        let line = raw_lines[i].as_str();
85
86        if let Some((number, name)) = parse_sprint_heading(line) {
87            finish_task(
88                &mut current_task,
89                &mut current_sprint,
90                &mut errors,
91                display_path,
92            );
93            finish_sprint(&mut current_sprint, &mut sprints);
94            current_sprint = Some(Sprint {
95                number,
96                name,
97                start_line: (i + 1) as u32,
98                tasks: Vec::new(),
99            });
100            i += 1;
101            continue;
102        }
103
104        if let Some((sprint_num, seq_num, name)) = parse_task_heading(line) {
105            finish_task(
106                &mut current_task,
107                &mut current_sprint,
108                &mut errors,
109                display_path,
110            );
111            current_task = Some(Task {
112                id: normalize_task_id(sprint_num, seq_num),
113                name,
114                sprint: sprint_num,
115                start_line: (i + 1) as u32,
116                location: Vec::new(),
117                description: None,
118                dependencies: None,
119                complexity: None,
120                acceptance_criteria: Vec::new(),
121                validation: Vec::new(),
122            });
123            i += 1;
124            continue;
125        }
126
127        if current_task.is_none() {
128            i += 1;
129            continue;
130        }
131
132        let Some((base_indent, field, rest)) = parse_field_line(line) else {
133            i += 1;
134            continue;
135        };
136
137        match field.as_str() {
138            "Description" => {
139                let v = rest.unwrap_or_default();
140                if let Some(task) = current_task.as_mut() {
141                    task.description = Some(v);
142                }
143                i += 1;
144            }
145            "Complexity" => {
146                let v = rest.unwrap_or_default();
147                if !v.trim().is_empty() {
148                    match v.trim().parse::<i32>() {
149                        Ok(n) => {
150                            if let Some(task) = current_task.as_mut() {
151                                task.complexity = Some(n);
152                            }
153                        }
154                        Err(_) => {
155                            errors.push(format!(
156                                "{display_path}:{}: invalid Complexity (expected int): {}",
157                                i + 1,
158                                crate::repr::py_repr(v.trim())
159                            ));
160                        }
161                    }
162                }
163                i += 1;
164            }
165            "Location" | "Dependencies" | "Acceptance criteria" | "Validation" => {
166                let (items, next_idx) = if let Some(r) = rest.clone() {
167                    if !r.trim().is_empty() {
168                        (vec![strip_inline_code(&r)], i + 1)
169                    } else {
170                        parse_list_block(&raw_lines, i + 1, base_indent)
171                    }
172                } else {
173                    parse_list_block(&raw_lines, i + 1, base_indent)
174                };
175
176                if let Some(task) = current_task.as_mut() {
177                    let cleaned: Vec<String> =
178                        items.into_iter().filter(|x| !x.trim().is_empty()).collect();
179                    match field.as_str() {
180                        "Location" => task.location.extend(cleaned),
181                        "Dependencies" => task.dependencies = Some(cleaned),
182                        "Acceptance criteria" => task.acceptance_criteria.extend(cleaned),
183                        "Validation" => task.validation.extend(cleaned),
184                        _ => {}
185                    }
186                }
187
188                i = next_idx;
189            }
190            _ => {
191                i += 1;
192            }
193        }
194    }
195
196    finish_task(
197        &mut current_task,
198        &mut current_sprint,
199        &mut errors,
200        display_path,
201    );
202    finish_sprint(&mut current_sprint, &mut sprints);
203
204    for sprint in &mut sprints {
205        for task in &mut sprint.tasks {
206            let Some(deps) = task.dependencies.clone() else {
207                continue;
208            };
209
210            let mut normalized: Vec<String> = Vec::new();
211            let mut saw_value = false;
212            for d in deps {
213                let trimmed = d.trim();
214                if trimmed.is_empty() {
215                    continue;
216                }
217                saw_value = true;
218                if trimmed.eq_ignore_ascii_case("none") {
219                    continue;
220                }
221                for part in trimmed.split(',') {
222                    let p = part.trim();
223                    if !p.is_empty() {
224                        normalized.push(p.to_string());
225                    }
226                }
227            }
228            if !saw_value {
229                task.dependencies = None;
230            } else {
231                task.dependencies = Some(normalized);
232            }
233        }
234    }
235
236    Ok((
237        Plan {
238            title: plan_title,
239            file: display_path.to_string(),
240            sprints,
241        },
242        errors,
243    ))
244}
245
246fn normalize_task_id(sprint: i32, seq: i32) -> String {
247    format!("Task {sprint}.{seq}")
248}
249
250fn parse_sprint_heading(line: &str) -> Option<(i32, String)> {
251    let rest = line.strip_prefix("## Sprint ")?;
252    let (num_part, name_part) = rest.split_once(':')?;
253    if num_part.is_empty() || !num_part.chars().all(|c| c.is_ascii_digit()) {
254        return None;
255    }
256    let number = num_part.parse::<i32>().ok()?;
257    let name = name_part.trim().to_string();
258    if name.is_empty() {
259        return None;
260    }
261    Some((number, name))
262}
263
264fn parse_task_heading(line: &str) -> Option<(i32, i32, String)> {
265    let rest = line.strip_prefix("### Task ")?;
266    let (id_part, name_part) = rest.split_once(':')?;
267    let (sprint_part, seq_part) = id_part.split_once('.')?;
268    if sprint_part.is_empty() || !sprint_part.chars().all(|c| c.is_ascii_digit()) {
269        return None;
270    }
271    if seq_part.is_empty() || !seq_part.chars().all(|c| c.is_ascii_digit()) {
272        return None;
273    }
274    let sprint_num = sprint_part.parse::<i32>().ok()?;
275    let seq_num = seq_part.parse::<i32>().ok()?;
276    let name = name_part.trim().to_string();
277    if name.is_empty() {
278        return None;
279    }
280    Some((sprint_num, seq_num, name))
281}
282
283fn parse_field_line(line: &str) -> Option<(usize, String, Option<String>)> {
284    let base_indent = line.chars().take_while(|c| *c == ' ').count();
285    let trimmed = line.trim_start_matches(' ');
286    let after_dash = trimmed.strip_prefix('-')?;
287    let after_space = after_dash.trim_start();
288    let after_star = after_space.strip_prefix("**")?;
289    let (field, rest) = after_star.split_once("**:")?;
290    let field = field.to_string();
291    match field.as_str() {
292        "Location"
293        | "Description"
294        | "Dependencies"
295        | "Complexity"
296        | "Acceptance criteria"
297        | "Validation" => Some((base_indent, field, Some(rest.trim().to_string()))),
298        _ => None,
299    }
300}
301
302fn strip_inline_code(text: &str) -> String {
303    let t = text.trim();
304    if t.len() >= 2 && t.starts_with('`') && t.ends_with('`') {
305        return t[1..t.len() - 1].trim().to_string();
306    }
307    t.to_string()
308}
309
310fn parse_list_block(
311    lines: &[String],
312    start_idx: usize,
313    base_indent: usize,
314) -> (Vec<String>, usize) {
315    let mut items: Vec<String> = Vec::new();
316    let mut i = start_idx;
317    while i < lines.len() {
318        let raw = lines[i].as_str();
319        if raw.trim().is_empty() {
320            i += 1;
321            continue;
322        }
323
324        let indent = raw.chars().take_while(|c| *c == ' ').count();
325        let trimmed = raw.trim_start_matches(' ');
326        if !trimmed.starts_with('-') {
327            break;
328        }
329        let after_dash = &trimmed[1..];
330        if after_dash.is_empty() || !after_dash.chars().next().unwrap_or('x').is_whitespace() {
331            break;
332        }
333        if indent <= base_indent {
334            break;
335        }
336        let text = after_dash.trim_start().trim_end();
337        items.push(strip_inline_code(text));
338        i += 1;
339    }
340
341    (items, i)
342}