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}