Skip to main content

pinto/backlog/
acceptance.rs

1//! Markdown Acceptance Criteria progress.
2
3use std::fmt;
4
5/// Completion counts for Markdown task-list items in a PBI body.
6#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
7pub struct AcceptanceCriteriaProgress {
8    completed: usize,
9    total: usize,
10}
11
12impl AcceptanceCriteriaProgress {
13    /// Parse Markdown task-list items from `body`.
14    ///
15    /// Unordered and ordered list markers, nested items, and blockquotes are accepted. Fenced
16    /// code blocks are ignored so examples in a PBI body do not affect its progress.
17    #[must_use]
18    pub fn from_markdown(body: &str) -> Self {
19        let mut progress = Self::default();
20        let mut fence: Option<(char, usize)> = None;
21
22        for line in body.lines() {
23            if let Some((marker, length)) = fence {
24                if is_fence(line, marker, length) {
25                    fence = None;
26                }
27                continue;
28            }
29            if let Some((marker, length)) = opening_fence(line) {
30                fence = Some((marker, length));
31                continue;
32            }
33
34            if let Some(completed) = checkbox_state(line) {
35                progress.total += 1;
36                progress.completed += usize::from(completed);
37            }
38        }
39
40        progress
41    }
42
43    /// Number of checked task-list items.
44    #[must_use]
45    pub const fn completed(self) -> usize {
46        self.completed
47    }
48
49    /// Total number of task-list items.
50    #[must_use]
51    pub const fn total(self) -> usize {
52        self.total
53    }
54
55    /// Whether at least one task-list item remains unchecked.
56    #[must_use]
57    pub const fn is_incomplete(self) -> bool {
58        self.completed < self.total
59    }
60}
61
62impl fmt::Display for AcceptanceCriteriaProgress {
63    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
64        if self.total == 0 {
65            output.write_str("-")
66        } else {
67            write!(output, "{}/{}", self.completed, self.total)
68        }
69    }
70}
71
72fn opening_fence(line: &str) -> Option<(char, usize)> {
73    let trimmed = line.trim_start();
74    let marker = trimmed.chars().next()?;
75    if marker != '`' && marker != '~' {
76        return None;
77    }
78    let length = trimmed
79        .chars()
80        .take_while(|character| *character == marker)
81        .count();
82    (length >= 3).then_some((marker, length))
83}
84
85fn is_fence(line: &str, marker: char, minimum_length: usize) -> bool {
86    let Some((candidate, length)) = opening_fence(line) else {
87        return false;
88    };
89    candidate == marker && length >= minimum_length
90}
91
92fn checkbox_state(line: &str) -> Option<bool> {
93    let mut content = line.trim_start();
94    while let Some(rest) = content.strip_prefix('>') {
95        content = rest.trim_start();
96    }
97
98    let marker_end = if let Some(rest) = content.strip_prefix(['-', '*', '+']) {
99        rest.starts_with(char::is_whitespace).then_some(1)
100    } else {
101        let digits = content
102            .char_indices()
103            .take_while(|(_, character)| character.is_ascii_digit())
104            .last()
105            .map_or(0, |(index, character)| index + character.len_utf8());
106        if digits == 0 {
107            None
108        } else {
109            let marker = content[digits..].chars().next()?;
110            (matches!(marker, '.' | ')')
111                && content[digits + marker.len_utf8()..].starts_with(char::is_whitespace))
112            .then_some(digits + marker.len_utf8())
113        }
114    }?;
115
116    let checkbox = content[marker_end..].trim_start();
117    let mut characters = checkbox.chars();
118    if characters.next()? != '[' {
119        return None;
120    }
121    let state = characters.next()?;
122    if !matches!(state, ' ' | 'x' | 'X') || characters.next()? != ']' {
123        return None;
124    }
125    let remainder = characters.next();
126    remainder
127        .is_none_or(char::is_whitespace)
128        .then_some(state != ' ')
129}
130
131#[cfg(test)]
132mod tests {
133    use super::AcceptanceCriteriaProgress;
134
135    #[test]
136    fn counts_completed_and_unchecked_task_list_items() {
137        let progress = AcceptanceCriteriaProgress::from_markdown(
138            "# Acceptance Criteria\n\n- [x] shipped\n- [ ] documented\n- [X] tested",
139        );
140
141        assert_eq!(progress.completed(), 2);
142        assert_eq!(progress.total(), 3);
143        assert_eq!(progress.to_string(), "2/3");
144    }
145
146    #[test]
147    fn counts_nested_ordered_and_quoted_task_items_but_ignores_fenced_code() {
148        let progress = AcceptanceCriteriaProgress::from_markdown(
149            "> - [x] root\n>   1. [ ] nested\n\n```markdown\n- [ ] example only\n```\n\n* [ ] actual",
150        );
151
152        assert_eq!(progress.completed(), 1);
153        assert_eq!(progress.total(), 3);
154    }
155
156    #[test]
157    fn empty_body_has_no_progress_and_is_not_incomplete() {
158        let progress = AcceptanceCriteriaProgress::from_markdown("");
159
160        assert_eq!(progress.completed(), 0);
161        assert_eq!(progress.total(), 0);
162        assert_eq!(progress.to_string(), "-");
163        assert!(!progress.is_incomplete());
164    }
165
166    #[test]
167    fn ignores_malformed_checkboxes_and_fence_marker_mismatches() {
168        let progress = AcceptanceCriteriaProgress::from_markdown(
169            "1x [ ] not a task\n- [q] invalid\n- [x]no separator\n\n```\n- [ ] code\n~~~\n- [ ] still code\n```\n\n- [ ] actual\n- [x] complete",
170        );
171
172        assert_eq!(progress.completed(), 1);
173        assert_eq!(progress.total(), 2);
174    }
175}