Skip to main content

speccoder_openspec/
tasks.rs

1//! `tasks.md` of an OpenSpec change.
2//!
3//! Lines are matched the way OpenSpec counts them (any list marker, any
4//! indent, checkboxes inside code fences included), so progress agrees with
5//! `openspec list`. A task's ID is the number it starts with (`1.2`,
6//! `2.3a`, `1.4.1`), else `#<ordinal>`; groups are `## N.` headings; the
7//! order is a chain: nested tasks come before their parent.
8
9use speccoder_core::snapshot::{
10    DependencyMode, Diagnostic, ParsedTasks, Phase, Severity, Task, Totals,
11};
12
13use crate::hash::sha256_hex;
14
15/// A checkbox line as OpenSpec matches it.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Checkbox<'a> {
18    /// Leading whitespace, in bytes.
19    pub indent: usize,
20    /// Byte offset of what is between the brackets, within the line.
21    pub inner_start: usize,
22    /// What is between the brackets.
23    pub inner: &'a str,
24    /// The text after the brackets.
25    pub text: &'a str,
26}
27
28impl Checkbox<'_> {
29    /// `[x]` or `[X]`.
30    #[must_use]
31    pub fn done(&self) -> bool {
32        matches!(self.inner.trim(), "x" | "X")
33    }
34
35    /// Exactly `[ ]`: the only box the engine ticks.
36    #[must_use]
37    pub fn tickable(&self) -> bool {
38        self.inner == " "
39    }
40}
41
42/// Matches OpenSpec's task pattern
43/// `^\s*(?:[-*+]|\d{1,9}[.)])\s*\[(?:\s*([^\]\s]?)\s*\](?![(\[])|\s+\])\s*(.*)`.
44#[must_use]
45pub fn checkbox(line: &str) -> Option<Checkbox<'_>> {
46    let indent = line.len() - line.trim_start().len();
47    let rest = &line[indent..];
48    let after_marker = if let Some(r) = rest.strip_prefix(['-', '*', '+']) {
49        r
50    } else {
51        let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
52        if !(1..=9).contains(&digits) {
53            return None;
54        }
55        rest[digits..].strip_prefix(['.', ')'])?
56    };
57    let open = after_marker.trim_start();
58    let body = open.strip_prefix('[')?;
59    let close = body.find(']')?;
60    let inner = &body[..close];
61    let after = &body[close + 1..];
62    let blank = inner.chars().all(char::is_whitespace);
63    let one_char = inner.trim().chars().count() == 1;
64    if !(blank || one_char) {
65        return None;
66    }
67    // `[x](link)` is a link, not a checkbox — unless the box holds only spaces.
68    if after.starts_with(['(', '[']) && (inner.is_empty() || !blank) {
69        return None;
70    }
71    let inner_start = line.len() - body.len();
72    Some(Checkbox {
73        indent,
74        inner_start,
75        inner,
76        text: after.trim(),
77    })
78}
79
80/// The leading task number of `text` (`1.2`, `2.3a`, `1.4.1`), if any.
81#[must_use]
82pub fn leading_id(text: &str) -> Option<&str> {
83    let mut end = 0;
84    let bytes = text.as_bytes();
85    let mut parts = 0;
86    loop {
87        let digits = bytes[end..]
88            .iter()
89            .take_while(|b| b.is_ascii_digit())
90            .count();
91        if digits == 0 {
92            break;
93        }
94        end += digits;
95        parts += 1;
96        if bytes.get(end) == Some(&b'.') && bytes.get(end + 1).is_some_and(u8::is_ascii_digit) {
97            end += 1;
98        } else {
99            break;
100        }
101    }
102    if parts < 2 {
103        return None;
104    }
105    end += bytes[end..]
106        .iter()
107        .take_while(|b| b.is_ascii_alphabetic())
108        .count();
109    let next = text[end..].chars().next();
110    (next.is_none_or(char::is_whitespace)).then_some(&text[..end])
111}
112
113/// `## 2. Farewell` → 2.
114fn group_number(title: &str) -> Option<u32> {
115    let digits: String = title.chars().take_while(char::is_ascii_digit).collect();
116    if digits.is_empty() || !title[digits.len()..].starts_with('.') {
117        return None;
118    }
119    digits.parse().ok()
120}
121
122/// Task IDs named after `depends on` / `after` in the text.
123fn references(text: &str) -> Vec<String> {
124    let lower = text.to_lowercase();
125    let mut out = Vec::new();
126    for key in ["depends on ", "after "] {
127        for (i, _) in lower.match_indices(key) {
128            if i > 0 && lower.as_bytes()[i - 1].is_ascii_alphanumeric() {
129                continue;
130            }
131            let word: String = text[i + key.len()..]
132                .trim_start()
133                .chars()
134                .take_while(|c| c.is_ascii_alphanumeric() || *c == '.')
135                .collect();
136            let word = word.trim_end_matches('.');
137            if leading_id(word) == Some(word) && !out.iter().any(|o| o == word) {
138                out.push(word.to_owned());
139            }
140        }
141    }
142    out
143}
144
145struct Raw {
146    task: Task,
147    indent: usize,
148    group: usize,
149}
150
151/// Parses an OpenSpec `tasks.md` at `path` (for diagnostics).
152#[must_use]
153pub fn parse(path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>) {
154    let mut diags = Vec::new();
155    let diag = |severity, code: &str, line: usize, message: String, hint: &str| {
156        let d = Diagnostic::new(severity, code, message)
157            .at(path)
158            .line(u32::try_from(line + 1).unwrap_or(u32::MAX));
159        if hint.is_empty() { d } else { d.hint(hint) }
160    };
161    let mut phases: Vec<(Phase, usize, Option<u32>)> = Vec::new();
162    let mut raws: Vec<Raw> = Vec::new();
163    let mut fence: Option<&str> = None;
164    let mut unnumbered_warned: Option<usize> = None;
165    let mut offset = 0;
166    let mut ordinal = 0;
167    for (no, full) in text.split_inclusive('\n').enumerate() {
168        let start = offset;
169        offset += full.len();
170        let line = full.trim_end_matches(['\n', '\r']);
171        let trimmed = line.trim_start();
172        if let Some(f) = fence {
173            if trimmed.starts_with(f) {
174                fence = None;
175            }
176        } else if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
177            fence = Some(&trimmed[..3]);
178            continue;
179        } else if let Some(title) = line.strip_prefix("## ") {
180            let title = title.trim().to_owned();
181            let number = group_number(&title);
182            phases.push((
183                Phase {
184                    title,
185                    line: u32::try_from(no + 1).unwrap_or(u32::MAX),
186                    section_hash: String::new(),
187                },
188                start,
189                number,
190            ));
191            continue;
192        }
193        let Some(cb) = checkbox(line) else { continue };
194        ordinal += 1;
195        if fence.is_some() {
196            diags.push(diag(
197                Severity::Warning,
198                "in_fence",
199                no,
200                "a checkbox inside a code block: OpenSpec counts it as a task".to_owned(),
201                "move it out of the code block, or remove its brackets",
202            ));
203        }
204        if cb.text.is_empty() {
205            diags.push(diag(
206                Severity::Error,
207                "textless",
208                no,
209                "a checkbox without text".to_owned(),
210                "describe the task, or remove the line",
211            ));
212            continue;
213        }
214        if cb.inner.is_empty() {
215            diags.push(diag(
216                Severity::Warning,
217                "empty_brackets",
218                no,
219                "`[]` has no space: speccoder cannot tick it".to_owned(),
220                "write `[ ]`",
221            ));
222        } else if !cb.done() && !cb.tickable() {
223            diags.push(diag(
224                Severity::Warning,
225                "marker_not_space",
226                no,
227                format!(
228                    "`[{}]` is neither open nor done: speccoder cannot tick it",
229                    cb.inner
230                ),
231                "write `[ ]` (open) or `[x]` (done)",
232            ));
233        }
234        let (id, title) = match leading_id(cb.text) {
235            Some(id) => (id.to_owned(), cb.text[id.len()..].trim().to_owned()),
236            None => (format!("#{ordinal}"), cb.text.to_owned()),
237        };
238        let group = phases.len();
239        let (phase, number) = match phases.last() {
240            Some((p, _, n)) => (Some(p.title.clone()), *n),
241            None => (None, None),
242        };
243        if let (Some(n), Some(first)) = (number, id.split('.').next()) {
244            if first.parse::<u32>().ok().is_some_and(|f| f != n) && !id.starts_with('#') {
245                diags.push(diag(
246                    Severity::Warning,
247                    "misplaced_id",
248                    no,
249                    format!("{id} is under group {n}"),
250                    "renumber the task or move it to its group",
251                ));
252            }
253        } else if phase.is_some() && number.is_none() && unnumbered_warned != Some(group) {
254            unnumbered_warned = Some(group);
255            diags.push(diag(
256                Severity::Warning,
257                "unnumbered_section",
258                no,
259                format!(
260                    "`## {}` is not a numbered group; its tasks run after everything before them",
261                    phase.as_deref().unwrap_or_default()
262                ),
263                "number the heading (`## 3. …`)",
264            ));
265        }
266        let depends_on = references(&title);
267        if !depends_on.is_empty() {
268            diags.push(diag(
269                Severity::Warning,
270                "extension_dependency",
271                no,
272                format!(
273                    "{id} names its dependencies ({}): a speccoder extension OpenSpec ignores",
274                    depends_on.join(", ")
275                ),
276                "",
277            ));
278        }
279        raws.push(Raw {
280            task: Task {
281                id,
282                done: cb.done(),
283                parallel: false,
284                story: None,
285                title,
286                depends_on,
287                phase,
288                subsection: None,
289                line: u32::try_from(no + 1).unwrap_or(u32::MAX),
290                byte_range: [start, start + line.len()],
291                line_hash: sha256_hex(line.as_bytes()),
292            },
293            indent: cb.indent,
294            group,
295        });
296    }
297
298    // Duplicates.
299    for (i, r) in raws.iter().enumerate() {
300        if raws[..i].iter().any(|e| e.task.id == r.task.id) {
301            diags.push(
302                Diagnostic::new(
303                    Severity::Error,
304                    "duplicate_id",
305                    format!("task {} appears more than once", r.task.id),
306                )
307                .at(path)
308                .line(r.task.line)
309                .hint("give each task its own number"),
310            );
311        }
312    }
313    if raws.is_empty() {
314        diags.push(
315            Diagnostic::new(Severity::Error, "no_tasks", "tasks.md has no tasks")
316                .at(path)
317                .hint("add `- [ ] 1.1 …` lines under `## 1. …`"),
318        );
319    }
320
321    // Chain order: a nested task before its parent.
322    let mut tasks = Vec::with_capacity(raws.len());
323    let mut stack: Vec<Raw> = Vec::new();
324    for r in raws {
325        while stack
326            .last()
327            .is_some_and(|top| top.group != r.group || top.indent >= r.indent)
328        {
329            tasks.extend(stack.pop().map(|t| t.task));
330        }
331        stack.push(r);
332    }
333    while let Some(t) = stack.pop() {
334        tasks.push(t.task);
335    }
336
337    let starts: Vec<usize> = phases.iter().map(|(_, s, _)| *s).collect();
338    let phases = phases
339        .into_iter()
340        .enumerate()
341        .map(|(i, (mut p, start, _))| {
342            let end = starts.get(i + 1).copied().unwrap_or(text.len());
343            p.section_hash = sha256_hex(&text.as_bytes()[start..end]);
344            p
345        })
346        .collect();
347    let done = tasks.iter().filter(|t| t.done).count();
348    let totals = Totals {
349        total: tasks.len(),
350        done,
351        open: tasks.len() - done,
352    };
353    (
354        ParsedTasks {
355            path: path.to_owned(),
356            phases,
357            tasks,
358            totals,
359            dependency_mode: DependencyMode::Chain,
360        },
361        diags,
362    )
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn checkboxes_match_openspecs_pattern() {
371        for (line, inner, text) in [
372            ("- [ ] 1.1 a", " ", "1.1 a"),
373            ("  * [x] b", "x", "b"),
374            ("1) [X] c", "X", "c"),
375            ("- [~] d", "~", "d"),
376            ("- [] e", "", "e"),
377            ("- [  ]", "  ", ""),
378            ("+[ ]f", " ", "f"),
379        ] {
380            let cb = checkbox(line).unwrap_or_else(|| panic!("{line}"));
381            assert_eq!((cb.inner, cb.text), (inner, text), "{line}");
382            assert_eq!(
383                &line[cb.inner_start..cb.inner_start + cb.inner.len()],
384                inner
385            );
386        }
387        for line in [
388            "[ ] no marker",
389            "- [ab] two",
390            "- [x](link)",
391            "text",
392            "1234567890. [ ] x",
393        ] {
394            assert!(checkbox(line).is_none(), "{line}");
395        }
396        assert!(checkbox("- [ ](x)").is_some());
397    }
398
399    #[test]
400    fn ids_and_references() {
401        assert_eq!(leading_id("1.2 x"), Some("1.2"));
402        assert_eq!(leading_id("2.3a y"), Some("2.3a"));
403        assert_eq!(leading_id("1.4.1"), Some("1.4.1"));
404        assert_eq!(leading_id("1 x"), None);
405        assert_eq!(leading_id("1.2x3"), None);
406        assert_eq!(leading_id("Read 1.2"), None);
407        assert_eq!(references("do it, depends on 2.2"), ["2.2"]);
408        assert_eq!(references("After 9.9, which"), ["9.9"]);
409        assert!(references("hereafter 1.2").is_empty());
410    }
411}