pinto/backlog/
acceptance.rs1use std::fmt;
4
5#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
7pub struct AcceptanceCriteriaProgress {
8 completed: usize,
9 total: usize,
10}
11
12impl AcceptanceCriteriaProgress {
13 #[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 #[must_use]
45 pub const fn completed(self) -> usize {
46 self.completed
47 }
48
49 #[must_use]
51 pub const fn total(self) -> usize {
52 self.total
53 }
54
55 #[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}