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, Default, Serialize)]
7pub struct SprintMetadata {
8    #[serde(skip_serializing_if = "Option::is_none")]
9    pub pr_grouping_intent: Option<String>,
10    #[serde(skip_serializing_if = "Option::is_none")]
11    pub execution_profile: Option<String>,
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub parallel_width: Option<usize>,
14}
15
16#[derive(Debug, Clone, Serialize)]
17pub struct Plan {
18    pub title: String,
19    pub file: String,
20    pub sprints: Vec<Sprint>,
21}
22
23#[derive(Debug, Clone, Serialize)]
24pub struct Sprint {
25    pub number: i32,
26    pub name: String,
27    pub start_line: u32,
28    pub tasks: Vec<Task>,
29    #[serde(skip_serializing)]
30    pub metadata: SprintMetadata,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub struct Task {
35    pub id: String,
36    pub name: String,
37    pub sprint: i32,
38    pub start_line: u32,
39    pub location: Vec<String>,
40    pub description: Option<String>,
41    pub dependencies: Option<Vec<String>>,
42    pub complexity: Option<i32>,
43    pub acceptance_criteria: Vec<String>,
44    pub validation: Vec<String>,
45}
46
47pub fn parse_plan_with_display(
48    path: &Path,
49    display_path: &str,
50) -> anyhow::Result<(Plan, Vec<String>)> {
51    let raw = std::fs::read(path)?;
52    let raw_text = String::from_utf8_lossy(&raw);
53    let raw_lines: Vec<String> = raw_text.lines().map(|l| l.to_string()).collect();
54
55    let mut plan_title = String::new();
56    for line in &raw_lines {
57        if let Some(rest) = line.strip_prefix("# ") {
58            plan_title = rest.trim().to_string();
59            break;
60        }
61    }
62
63    let mut errors: Vec<String> = Vec::new();
64
65    let mut sprints: Vec<Sprint> = Vec::new();
66    let mut current_sprint: Option<Sprint> = None;
67    let mut current_task: Option<Task> = None;
68
69    fn finish_task(
70        current_task: &mut Option<Task>,
71        current_sprint: &mut Option<Sprint>,
72        errors: &mut Vec<String>,
73        display_path: &str,
74    ) {
75        let Some(task) = current_task.take() else {
76            return;
77        };
78        let Some(sprint) = current_sprint.as_mut() else {
79            errors.push(format!(
80                "{display_path}:{}: task outside of any sprint: {}",
81                task.start_line, task.id
82            ));
83            return;
84        };
85        sprint.tasks.push(task);
86    }
87
88    fn finish_sprint(current_sprint: &mut Option<Sprint>, sprints: &mut Vec<Sprint>) {
89        if let Some(s) = current_sprint.take() {
90            sprints.push(s);
91        }
92    }
93
94    let mut i: usize = 0;
95    while i < raw_lines.len() {
96        let line = raw_lines[i].as_str();
97
98        if let Some((number, name)) = parse_sprint_heading(line) {
99            finish_task(
100                &mut current_task,
101                &mut current_sprint,
102                &mut errors,
103                display_path,
104            );
105            finish_sprint(&mut current_sprint, &mut sprints);
106            current_sprint = Some(Sprint {
107                number,
108                name,
109                start_line: (i + 1) as u32,
110                tasks: Vec::new(),
111                metadata: SprintMetadata::default(),
112            });
113            i += 1;
114            continue;
115        }
116
117        if let Some((sprint_num, seq_num, name)) = parse_task_heading(line) {
118            finish_task(
119                &mut current_task,
120                &mut current_sprint,
121                &mut errors,
122                display_path,
123            );
124            current_task = Some(Task {
125                id: normalize_task_id(sprint_num, seq_num),
126                name,
127                sprint: sprint_num,
128                start_line: (i + 1) as u32,
129                location: Vec::new(),
130                description: None,
131                dependencies: None,
132                complexity: None,
133                acceptance_criteria: Vec::new(),
134                validation: Vec::new(),
135            });
136            i += 1;
137            continue;
138        }
139
140        if current_task.is_none() {
141            if let Some((_, field, rest)) = parse_field_line(line)
142                && let Some(sprint) = current_sprint.as_mut()
143            {
144                let value = rest.unwrap_or_default();
145                if field == "PR grouping intent" {
146                    sprint.metadata.pr_grouping_intent = parse_pr_grouping_intent(&value);
147                } else if field == "Execution Profile" {
148                    sprint.metadata.execution_profile = parse_execution_profile(&value);
149                    sprint.metadata.parallel_width = parse_parallel_width(&value);
150                }
151            }
152            i += 1;
153            continue;
154        }
155
156        let Some((base_indent, field, rest)) = parse_field_line(line) else {
157            i += 1;
158            continue;
159        };
160
161        match field.as_str() {
162            "Description" => {
163                let v = rest.unwrap_or_default();
164                if let Some(task) = current_task.as_mut() {
165                    task.description = Some(v);
166                }
167                i += 1;
168            }
169            "Complexity" => {
170                let v = rest.unwrap_or_default();
171                if !v.trim().is_empty() {
172                    match v.trim().parse::<i32>() {
173                        Ok(n) => {
174                            if let Some(task) = current_task.as_mut() {
175                                task.complexity = Some(n);
176                            }
177                        }
178                        Err(_) => {
179                            errors.push(format!(
180                                "{display_path}:{}: invalid Complexity (expected int): {}",
181                                i + 1,
182                                crate::repr::py_repr(v.trim())
183                            ));
184                        }
185                    }
186                }
187                i += 1;
188            }
189            "Location" | "Dependencies" | "Acceptance criteria" | "Validation" => {
190                let (items, next_idx) = if let Some(r) = rest.clone() {
191                    if !r.trim().is_empty() {
192                        (vec![strip_inline_code(&r)], i + 1)
193                    } else {
194                        parse_list_block(&raw_lines, i + 1, base_indent)
195                    }
196                } else {
197                    parse_list_block(&raw_lines, i + 1, base_indent)
198                };
199
200                if let Some(task) = current_task.as_mut() {
201                    let cleaned: Vec<String> =
202                        items.into_iter().filter(|x| !x.trim().is_empty()).collect();
203                    match field.as_str() {
204                        "Location" => task.location.extend(cleaned),
205                        "Dependencies" => task.dependencies = Some(cleaned),
206                        "Acceptance criteria" => task.acceptance_criteria.extend(cleaned),
207                        "Validation" => task.validation.extend(cleaned),
208                        _ => {}
209                    }
210                }
211
212                i = next_idx;
213            }
214            _ => {
215                i += 1;
216            }
217        }
218    }
219
220    finish_task(
221        &mut current_task,
222        &mut current_sprint,
223        &mut errors,
224        display_path,
225    );
226    finish_sprint(&mut current_sprint, &mut sprints);
227
228    for sprint in &mut sprints {
229        for task in &mut sprint.tasks {
230            let Some(deps) = task.dependencies.clone() else {
231                continue;
232            };
233
234            let mut normalized: Vec<String> = Vec::new();
235            let mut saw_value = false;
236            for d in deps {
237                let trimmed = d.trim();
238                if trimmed.is_empty() {
239                    continue;
240                }
241                saw_value = true;
242                if trimmed.eq_ignore_ascii_case("none") {
243                    continue;
244                }
245                for part in trimmed.split(',') {
246                    let p = part.trim();
247                    if !p.is_empty() {
248                        normalized.push(p.to_string());
249                    }
250                }
251            }
252            if !saw_value {
253                task.dependencies = None;
254            } else {
255                task.dependencies = Some(normalized);
256            }
257        }
258    }
259
260    Ok((
261        Plan {
262            title: plan_title,
263            file: display_path.to_string(),
264            sprints,
265        },
266        errors,
267    ))
268}
269
270fn normalize_task_id(sprint: i32, seq: i32) -> String {
271    format!("Task {sprint}.{seq}")
272}
273
274fn parse_sprint_heading(line: &str) -> Option<(i32, String)> {
275    let rest = line.strip_prefix("## Sprint ")?;
276    let (num_part, name_part) = rest.split_once(':')?;
277    if num_part.is_empty() || !num_part.chars().all(|c| c.is_ascii_digit()) {
278        return None;
279    }
280    let number = num_part.parse::<i32>().ok()?;
281    let name = name_part.trim().to_string();
282    if name.is_empty() {
283        return None;
284    }
285    Some((number, name))
286}
287
288fn parse_task_heading(line: &str) -> Option<(i32, i32, String)> {
289    let rest = line.strip_prefix("### Task ")?;
290    let (id_part, name_part) = rest.split_once(':')?;
291    let (sprint_part, seq_part) = id_part.split_once('.')?;
292    if sprint_part.is_empty() || !sprint_part.chars().all(|c| c.is_ascii_digit()) {
293        return None;
294    }
295    if seq_part.is_empty() || !seq_part.chars().all(|c| c.is_ascii_digit()) {
296        return None;
297    }
298    let sprint_num = sprint_part.parse::<i32>().ok()?;
299    let seq_num = seq_part.parse::<i32>().ok()?;
300    let name = name_part.trim().to_string();
301    if name.is_empty() {
302        return None;
303    }
304    Some((sprint_num, seq_num, name))
305}
306
307fn parse_field_line(line: &str) -> Option<(usize, String, Option<String>)> {
308    let base_indent = line.chars().take_while(|c| *c == ' ').count();
309    let trimmed = line.trim_start_matches(' ');
310    let after_space = if let Some(after_dash) = trimmed.strip_prefix('-') {
311        after_dash.trim_start()
312    } else {
313        trimmed
314    };
315    let after_star = after_space.strip_prefix("**")?;
316    let (field, rest) = after_star.split_once("**:")?;
317    let field = field.to_string();
318    match field.as_str() {
319        "Location"
320        | "Description"
321        | "Dependencies"
322        | "Complexity"
323        | "Acceptance criteria"
324        | "Validation"
325        | "PR grouping intent"
326        | "Execution Profile" => Some((base_indent, field, Some(rest.trim().to_string()))),
327        _ => None,
328    }
329}
330
331fn parse_pr_grouping_intent(text: &str) -> Option<String> {
332    let token = extract_primary_token(text);
333    if token.is_empty() {
334        return None;
335    }
336    let normalized = token.to_ascii_lowercase();
337    if normalized.contains("per-sprint") || normalized == "persprint" {
338        Some("per-sprint".to_string())
339    } else if normalized.contains("group") {
340        Some("group".to_string())
341    } else {
342        None
343    }
344}
345
346fn parse_execution_profile(text: &str) -> Option<String> {
347    let token = extract_primary_token(text);
348    if token.is_empty() {
349        None
350    } else {
351        Some(token.to_ascii_lowercase())
352    }
353}
354
355fn parse_parallel_width(text: &str) -> Option<usize> {
356    let lower = text.to_ascii_lowercase();
357    let marker = "parallel width";
358    let pos = lower.find(marker)?;
359    let tail = &lower[pos + marker.len()..];
360    let mut digits = String::new();
361    let mut reading = false;
362    for ch in tail.chars() {
363        if ch.is_ascii_digit() {
364            digits.push(ch);
365            reading = true;
366            continue;
367        }
368        if reading {
369            break;
370        }
371    }
372    if digits.is_empty() {
373        None
374    } else {
375        digits.parse::<usize>().ok().filter(|v| *v > 0)
376    }
377}
378
379fn extract_primary_token(text: &str) -> String {
380    let trimmed = text.trim();
381    if trimmed.is_empty() {
382        return String::new();
383    }
384    if let Some(start) = trimmed.find('`')
385        && let Some(end_rel) = trimmed[start + 1..].find('`')
386    {
387        let token = trimmed[start + 1..start + 1 + end_rel].trim();
388        if !token.is_empty() {
389            return token.to_string();
390        }
391    }
392    trimmed
393        .split_whitespace()
394        .next()
395        .unwrap_or_default()
396        .trim()
397        .trim_end_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-')
398        .trim_start_matches(|c: char| !c.is_ascii_alphanumeric())
399        .to_string()
400}
401
402fn strip_inline_code(text: &str) -> String {
403    let t = text.trim();
404    if t.len() >= 2 && t.starts_with('`') && t.ends_with('`') {
405        return t[1..t.len() - 1].trim().to_string();
406    }
407    t.to_string()
408}
409
410fn parse_list_block(
411    lines: &[String],
412    start_idx: usize,
413    base_indent: usize,
414) -> (Vec<String>, usize) {
415    let mut items: Vec<String> = Vec::new();
416    let mut i = start_idx;
417    while i < lines.len() {
418        let raw = lines[i].as_str();
419        if raw.trim().is_empty() {
420            i += 1;
421            continue;
422        }
423
424        let indent = raw.chars().take_while(|c| *c == ' ').count();
425        let trimmed = raw.trim_start_matches(' ');
426        if !trimmed.starts_with('-') {
427            break;
428        }
429        let after_dash = &trimmed[1..];
430        if after_dash.is_empty() || !after_dash.chars().next().unwrap_or('x').is_whitespace() {
431            break;
432        }
433        if indent <= base_indent {
434            break;
435        }
436        let text = after_dash.trim_start().trim_end();
437        items.push(strip_inline_code(text));
438        i += 1;
439    }
440
441    (items, i)
442}