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}