1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::Context;
6
7use crate::{
8 ChecklistItem, Diagnostic, DiagnosticSeverity, Document, DocumentSummary, LineRange, Task,
9 validate_document,
10};
11
12#[derive(Debug, Clone, Copy, Default)]
13pub struct ListOptions {
14 pub include_index: bool,
15}
16
17pub fn parse_document(path: impl Into<PathBuf>, content: &str) -> Document {
18 let path = path.into();
19 let id = document_id(&path);
20 let mut document = Document {
21 id,
22 path: path.clone(),
23 title: String::new(),
24 goal: String::new(),
25 architecture: String::new(),
26 tech_stack: String::new(),
27 owned_paths: Vec::new(),
28 tasks: Vec::new(),
29 acceptance: Vec::new(),
30 diagnostics: Vec::new(),
31 };
32 let mut in_tasks = false;
33 let mut in_owned_paths = false;
34 let mut in_acceptance = false;
35 let mut current_task: Option<usize> = None;
36 let mut in_run_block_for_task: Option<usize> = None;
37 let mut run_buffer = Vec::new();
38 let mut seen_task_ids = HashSet::new();
39 let lines = content.lines().collect::<Vec<_>>();
40
41 for (index, line) in lines.iter().enumerate() {
42 let line_no = index + 1;
43 if document.title.is_empty()
44 && let Some(title) = line.strip_prefix("# ")
45 {
46 document.title = title.trim().to_string();
47 }
48 if let Some(value) = bold_field(line, "Goal") {
49 document.goal = value.to_string();
50 }
51 if let Some(value) = bold_field(line, "Architecture") {
52 document.architecture = value.to_string();
53 }
54 if let Some(value) = bold_field(line, "Tech Stack") {
55 document.tech_stack = value.to_string();
56 }
57 if line.trim() == "## Tasks" {
58 in_tasks = true;
59 in_owned_paths = false;
60 in_acceptance = false;
61 continue;
62 }
63 if line.trim() == "## Owned Paths" {
64 in_tasks = false;
65 in_owned_paths = true;
66 in_acceptance = false;
67 continue;
68 }
69 if line.trim() == "## Phase Acceptance" || line.trim() == "## Final Roadmap Acceptance" {
70 finish_run_block(
71 &mut document.tasks,
72 &mut in_run_block_for_task,
73 &mut run_buffer,
74 );
75 in_tasks = false;
76 in_owned_paths = false;
77 in_acceptance = true;
78 current_task = None;
79 continue;
80 }
81 if in_owned_paths && line.starts_with("## ") {
82 in_owned_paths = false;
83 }
84 if in_owned_paths && let Some(path) = file_path_bullet(line) {
85 document.owned_paths.push(path.to_string());
86 continue;
87 }
88 if in_tasks && line.starts_with("## ") && line.trim() != "## Tasks" {
89 finish_run_block(
90 &mut document.tasks,
91 &mut in_run_block_for_task,
92 &mut run_buffer,
93 );
94 current_task = None;
95 }
96 if in_tasks && line.trim() == "Run:" {
97 in_run_block_for_task = current_task;
98 run_buffer.clear();
99 continue;
100 }
101 if in_run_block_for_task.is_some() {
102 if line.starts_with("```") {
103 continue;
104 }
105 if line.trim() == "Acceptance:" {
106 finish_run_block(
107 &mut document.tasks,
108 &mut in_run_block_for_task,
109 &mut run_buffer,
110 );
111 continue;
112 }
113 if line.starts_with("### ") || line.starts_with("## ") {
114 finish_run_block(
115 &mut document.tasks,
116 &mut in_run_block_for_task,
117 &mut run_buffer,
118 );
119 } else {
120 run_buffer.push((*line).to_string());
121 continue;
122 }
123 }
124 if let Some((checked, text)) = checkbox(line) {
125 if in_acceptance {
126 let id = checklist_id(line_no, text);
127 document.acceptance.push(ChecklistItem {
128 id,
129 text: text.to_string(),
130 checked,
131 line: line_no,
132 });
133 } else if in_tasks {
134 finish_run_block(
135 &mut document.tasks,
136 &mut in_run_block_for_task,
137 &mut run_buffer,
138 );
139 let id = unique_task_id(&mut seen_task_ids, line_no, text);
140 document.tasks.push(Task {
141 id,
142 heading: text.to_string(),
143 checked,
144 line: line_no,
145 level: line.chars().take_while(|ch| ch.is_whitespace()).count() / 2,
146 body_range: LineRange {
147 start: line_no,
148 end: line_no,
149 },
150 run_blocks: Vec::new(),
151 paths: Vec::new(),
152 });
153 current_task = Some(document.tasks.len() - 1);
154 }
155 continue;
156 }
157 if let Some(task_index) = current_task {
158 document.tasks[task_index].body_range.end = line_no;
159 if let Some(path) = file_path_bullet(line) {
160 document.tasks[task_index].paths.push(path.to_string());
161 }
162 }
163 }
164 finish_run_block(
165 &mut document.tasks,
166 &mut in_run_block_for_task,
167 &mut run_buffer,
168 );
169 document.diagnostics = validate_document(&document).diagnostics;
170 document
171}
172
173pub fn set_task_checked(
174 path: impl AsRef<Path>,
175 task_id: &str,
176 checked: bool,
177 _evidence: &str,
178) -> anyhow::Result<()> {
179 let path = path.as_ref();
180 let content = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
181 let document = parse_document(path, &content);
182 let task = document
183 .tasks
184 .iter()
185 .find(|task| task.id == task_id)
186 .ok_or_else(|| anyhow::anyhow!("task not found: {task_id}"))?;
187 let mut lines = content.split_inclusive('\n').collect::<Vec<_>>();
188 let line_index = task.line.saturating_sub(1);
189 let original = lines
190 .get(line_index)
191 .copied()
192 .ok_or_else(|| anyhow::anyhow!("task line out of range: {}", task.line))?;
193 let next = if checked {
194 original.replacen("- [ ]", "- [x]", 1)
195 } else {
196 original.replacen("- [x]", "- [ ]", 1)
197 };
198 lines[line_index] = &next;
199 let updated = lines.join("");
200 atomic_write(path, updated.as_bytes())
201}
202
203pub fn list_documents(
204 workspace: impl AsRef<Path>,
205 options: ListOptions,
206) -> anyhow::Result<Vec<DocumentSummary>> {
207 let roadmap_dir = workspace.as_ref().join("roadmap");
208 let mut summaries = Vec::new();
209 for entry in fs::read_dir(&roadmap_dir)
210 .with_context(|| format!("read roadmap dir {}", roadmap_dir.display()))?
211 {
212 let entry = entry?;
213 let path = entry.path();
214 if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
215 continue;
216 }
217 if !options.include_index
218 && path
219 .file_name()
220 .and_then(|name| name.to_str())
221 .is_some_and(|name| name.starts_with("00-"))
222 {
223 continue;
224 }
225 let content = fs::read_to_string(&path)?;
226 let document = parse_document(&path, &content);
227 let checked_tasks = document.tasks.iter().filter(|task| task.checked).count();
228 let unchecked_tasks = document.tasks.len().saturating_sub(checked_tasks);
229 summaries.push(DocumentSummary {
230 id: document.id,
231 path,
232 title: document.title,
233 checked_tasks,
234 unchecked_tasks,
235 });
236 }
237 summaries.sort_by_key(|summary| {
238 (
239 phase_number(&summary.path).unwrap_or(usize::MAX),
240 summary.path.clone(),
241 )
242 });
243 Ok(summaries)
244}
245
246pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
247 let parent = path
248 .parent()
249 .ok_or_else(|| anyhow::anyhow!("path has no parent: {}", path.display()))?;
250 fs::create_dir_all(parent)?;
251 let temp = parent.join(format!(
252 ".{}.tmp-{}",
253 path.file_name()
254 .and_then(|name| name.to_str())
255 .unwrap_or("state"),
256 std::process::id()
257 ));
258 fs::write(&temp, bytes)?;
259 fs::rename(&temp, path)?;
260 Ok(())
261}
262
263fn finish_run_block(tasks: &mut [Task], task: &mut Option<usize>, buffer: &mut Vec<String>) {
264 if let Some(task_index) = task.take() {
265 let run = buffer.join("\n").trim().to_string();
266 if !run.is_empty() {
267 tasks[task_index].run_blocks.push(run);
268 }
269 buffer.clear();
270 }
271}
272
273fn bold_field<'a>(line: &'a str, name: &str) -> Option<&'a str> {
274 line.strip_prefix(&format!("**{name}:**"))
275 .map(str::trim)
276 .filter(|value| !value.is_empty())
277}
278
279fn checkbox(line: &str) -> Option<(bool, &str)> {
280 let trimmed = line.trim_start();
281 if let Some(text) = trimmed.strip_prefix("- [ ] ") {
282 Some((false, text.trim()))
283 } else {
284 trimmed
285 .strip_prefix("- [x] ")
286 .or_else(|| trimmed.strip_prefix("- [X] "))
287 .map(|text| (true, text.trim()))
288 }
289}
290
291fn unique_task_id(seen: &mut HashSet<String>, _line: usize, text: &str) -> String {
292 let base = format!("task-{}", slug(text));
293 if seen.insert(base.clone()) {
294 return base;
295 }
296 let mut suffix = 2;
297 loop {
298 let candidate = format!("{base}-{suffix}");
299 if seen.insert(candidate.clone()) {
300 return candidate;
301 }
302 suffix += 1;
303 }
304}
305
306fn checklist_id(line: usize, text: &str) -> String {
307 format!("acceptance-{}-{}", line, slug(text))
308}
309
310fn slug(text: &str) -> String {
311 let mut out = String::new();
312 for ch in text.chars().flat_map(char::to_lowercase) {
313 if ch.is_ascii_alphanumeric() {
314 out.push(ch);
315 } else if !out.ends_with('-') {
316 out.push('-');
317 }
318 }
319 out.trim_matches('-').chars().take(64).collect()
320}
321
322fn document_id(path: &Path) -> String {
323 path.file_stem()
324 .and_then(|name| name.to_str())
325 .unwrap_or("roadmap")
326 .to_string()
327}
328
329fn file_path_bullet(line: &str) -> Option<&str> {
330 line.trim()
331 .strip_prefix("- Create: `")
332 .or_else(|| line.trim().strip_prefix("- Modify: `"))
333 .and_then(|rest| rest.strip_suffix('`'))
334}
335
336fn phase_number(path: &Path) -> Option<usize> {
337 path.file_name()
338 .and_then(|name| name.to_str())
339 .and_then(|name| name.split('-').next())
340 .and_then(|number| number.parse().ok())
341}
342
343fn invalid_path(path: &Path) -> Option<Diagnostic> {
344 let is_roadmap_file = path
345 .parent()
346 .and_then(|parent| parent.file_name())
347 .and_then(|name| name.to_str())
348 == Some("roadmap")
349 && path.extension().and_then(|ext| ext.to_str()) == Some("md");
350 (!is_roadmap_file).then(|| Diagnostic {
351 path: path.to_path_buf(),
352 line: None,
353 severity: DiagnosticSeverity::Error,
354 message: "roadmap documents must live under roadmap/*.md".to_string(),
355 })
356}
357
358pub(crate) fn path_diagnostic(path: &Path) -> Option<Diagnostic> {
359 invalid_path(path)
360}