Skip to main content

roder_roadmap/
parser.rs

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}