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}