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