1use crate::locator::Locator;
11use crate::node_id::NodeId;
12
13use super::row::{
14 EvidenceCell, ROADMAP_COLUMNS, ROADMAP_STATUS_SPELLINGS, RoadmapRow, RoadmapShape,
15 RoadmapStatus,
16};
17
18fn parse_status(raw: &str) -> Option<RoadmapStatus> {
19 ROADMAP_STATUS_SPELLINGS
20 .iter()
21 .find(|(_, spelling)| spelling.eq_ignore_ascii_case(raw))
22 .map(|(status, _)| *status)
23}
24
25pub(crate) fn split_table_row(line: &str) -> Option<Vec<String>> {
26 let trimmed = line.trim();
27 if !trimmed.starts_with('|') {
28 return None;
29 }
30 let inner = trimmed.strip_prefix('|')?.strip_suffix('|')?;
31 Some(
32 inner
33 .split('|')
34 .map(|cell| cell.trim().to_owned())
35 .collect(),
36 )
37}
38
39pub(crate) fn is_separator(cells: &[String]) -> bool {
40 !cells.is_empty()
41 && cells
42 .iter()
43 .all(|c| !c.is_empty() && c.chars().all(|ch| ch == '-' || ch == ':'))
44}
45
46pub(crate) fn body_row(cells: &[String]) -> bool {
51 cells.len() == ROADMAP_COLUMNS && !is_separator(cells)
52}
53
54pub(crate) fn locate_table(text: &str) -> Option<(usize, usize)> {
57 let mut first = None;
58 let mut last = 0;
59 for (n, line) in text.split('\n').enumerate() {
60 if split_table_row(line).is_some() {
61 if first.is_none() {
62 first = Some(n);
63 }
64 last = n;
65 } else if first.is_some() {
66 break;
67 }
68 }
69 first.map(|start| (start, last))
70}
71
72#[must_use]
76pub fn check_roadmap_shape(text: &str) -> RoadmapShape {
77 let mut rows = Vec::new();
78 let mut problems = Vec::new();
79 let mut in_table = false;
80 let mut header_seen = false;
81 for (n, line) in text.lines().enumerate() {
82 let line_no = n.saturating_add(1);
83 let Some(cells) = split_table_row(line) else {
84 if in_table {
85 break; }
87 continue;
88 };
89 in_table = true;
90 if cells.len() != ROADMAP_COLUMNS {
91 problems.push(format!(
92 "line {line_no}: {} columns, the roadmap table has exactly {ROADMAP_COLUMNS}",
93 cells.len()
94 ));
95 continue;
96 }
97 if !header_seen {
98 header_seen = true;
99 continue; }
101 if is_separator(&cells) {
102 continue;
103 }
104 match read_row(&cells) {
105 Ok(row) => rows.push(row),
106 Err(why) => problems.push(format!("line {line_no}: {why}")),
107 }
108 }
109 if !header_seen {
110 problems.push(format!("no {ROADMAP_COLUMNS}-column table found"));
111 }
112 if problems.is_empty() {
113 RoadmapShape::WellFormed { rows }
114 } else {
115 RoadmapShape::Malformed { problems }
116 }
117}
118
119pub(crate) fn read_row(cells: &[String]) -> Result<RoadmapRow, String> {
121 let (
122 Some(index_cell),
123 Some(item_cell),
124 Some(weight_cell),
125 Some(needs_cell),
126 Some(status_cell),
127 Some(evidence_cell),
128 ) = (
129 cells.first(),
130 cells.get(1),
131 cells.get(2),
132 cells.get(3),
133 cells.get(4),
134 cells.get(5),
135 )
136 else {
137 return Err("the row is short of cells".to_owned());
138 };
139 let id = NodeId::parse(index_cell).map_err(|err| err.subject().to_owned())?;
140 let weight = if weight_cell.is_empty() {
141 1
142 } else {
143 weight_cell
144 .parse::<u32>()
145 .map_err(|_| format!("weight `{weight_cell}` is not a number"))?
146 };
147 let mut needs = Vec::new();
148 for part in needs_cell.split(',') {
149 let trimmed = part.trim();
150 if trimmed.is_empty() {
151 continue;
152 }
153 needs.push(NodeId::parse(trimmed).map_err(|err| err.subject().to_owned())?);
154 }
155 let status = parse_status(status_cell)
156 .ok_or_else(|| format!("status `{status_cell}` outside the five-value enum"))?;
157 let evidence = if evidence_cell.is_empty() {
158 EvidenceCell::Empty
159 } else {
160 match Locator::parse(evidence_cell) {
161 Ok(locator) => EvidenceCell::Present(locator),
162 Err(_) => EvidenceCell::Invalid {
163 raw: evidence_cell.clone(),
164 },
165 }
166 };
167 Ok(RoadmapRow {
168 id,
169 item: item_cell.clone(),
170 weight,
171 needs,
172 status,
173 evidence,
174 })
175}
176
177#[cfg(test)]
178#[allow(
179 clippy::unwrap_used,
180 clippy::expect_used,
181 clippy::panic,
182 clippy::indexing_slicing,
183 clippy::arithmetic_side_effects,
184 reason = "test code"
185)]
186mod tests {
187 use super::super::rewrite::set_roadmap_status;
188 use super::*;
189 use crate::error::AxCode;
190 use crate::locator::Locator;
191 use crate::node_id::NodeId;
192 fn node(raw: &str) -> NodeId {
193 NodeId::parse(raw).unwrap()
194 }
195 fn locator() -> Locator {
196 Locator::parse(&format!("cas:b3-{}", "ab".repeat(32))).unwrap()
197 }
198 fn rows_of(text: &str) -> Vec<RoadmapRow> {
199 match check_roadmap_shape(text) {
200 RoadmapShape::WellFormed { rows } => rows,
201 RoadmapShape::Malformed { problems } => panic!("expected well-formed: {problems:?}"),
202 }
203 }
204
205 const GOOD: &str = "# Roadmap
206
207| # | Item | Weight | Needs | Status | Evidence |
208|---|------|--------|-------|--------|----------|
209| 1 | chain verification | 1 | | Done | cas:b3-abababababababababababababababababababababababababababababababab |
210| 2 | range retrieval | 3 | 1 | In progress | |
211| 3 | fixtures | 1 | | Awaiting approval | |
212| 4 | claimed without evidence | 1 | | Done | |
213| 5 | evidence that does not parse | 1 | | done | not-a-locator |
214";
215 #[test]
216 fn a_good_table_parses_every_column() {
217 let rows = rows_of(GOOD);
218 assert_eq!(rows.len(), 5);
219 assert_eq!(rows[0].status, RoadmapStatus::Done);
220 assert!(matches!(rows[0].evidence, EvidenceCell::Present(_)));
221 assert_eq!(rows[1].weight, 3);
222 assert_eq!(rows[1].needs, vec![node("1")]);
223 assert_eq!(rows[2].weight, 1, "an empty weight cell reads as one");
224 assert!(rows[2].needs.is_empty());
225 assert!(matches!(rows[3].evidence, EvidenceCell::Empty));
226 assert!(matches!(rows[4].evidence, EvidenceCell::Invalid { .. }));
227 assert_eq!(rows[4].status, RoadmapStatus::Done, "case is not contract");
228 }
229
230 #[test]
231 fn done_without_evidence_is_refused_where_it_is_written() {
232 let refusal = set_roadmap_status(GOOD, &node("2"), RoadmapStatus::Done, None).unwrap_err();
233 assert_eq!(refusal.code(), &AxCode::EvidenceMissing);
234 assert!(refusal.recovery().contains("cas:"));
235 let text =
236 set_roadmap_status(GOOD, &node("2"), RoadmapStatus::Done, Some(&locator())).unwrap();
237 assert!(text.contains("| 2 | range retrieval | 3 | 1 | Done | cas:b3-"));
238 }
239
240 #[test]
241 fn an_index_no_row_carries_is_refused_by_number() {
242 let refusal =
243 set_roadmap_status(GOOD, &node("99"), RoadmapStatus::InProgress, None).unwrap_err();
244 assert_eq!(refusal.code(), &AxCode::InvalidArgs);
245 assert!(refusal.subject().contains("99"));
246 }
247
248 #[test]
249 fn a_second_table_below_the_roadmap_is_not_edited() {
250 let text = format!(
251 "{GOOD}
252## Notes
253
254| # | Item | Weight | Needs | Status | Evidence |
255|---|---|---|---|---|---|
256| 2 | other table | 1 | | Not started | |
257"
258 );
259 let edited = set_roadmap_status(&text, &node("2"), RoadmapStatus::Blocked, None).unwrap();
260 assert!(
261 edited.contains("| 2 | other table | 1 | | Not started | |"),
262 "only the first table is the roadmap"
263 );
264 assert!(edited.contains("| 2 | range retrieval | 3 | 1 | Blocked | |"));
265 }
266
267 #[test]
268 fn wrong_column_count_and_alien_status_are_named_problems() {
269 let text = "\
270| # | Item | Status |
271|---|------|--------|
272| 1 | x | nearly there |
273";
274 let RoadmapShape::Malformed { problems } = check_roadmap_shape(text) else {
275 panic!("expected malformed");
276 };
277 assert!(problems.iter().any(|p| p.contains("3 columns")));
278 }
279
280 #[test]
281 fn free_text_status_is_rejected_by_the_enum() {
282 let text = "\
283| # | Item | Weight | Needs | Status | Evidence |
284|---|------|--------|-------|--------|----------|
285| 1 | x | 1 | | nearly there | |
286";
287 let RoadmapShape::Malformed { problems } = check_roadmap_shape(text) else {
288 panic!("expected malformed");
289 };
290 assert!(problems.iter().any(|p| p.contains("nearly there")));
291 }
292
293 #[test]
302 fn the_old_four_column_table_is_reported_rather_than_half_read() {
303 let old = "\
304| # | Item | Status | Evidence |
305|---|------|--------|----------|
306| 1 | x | Not started | |
307";
308 let RoadmapShape::Malformed { problems } = check_roadmap_shape(old) else {
309 panic!("expected malformed");
310 };
311 assert!(problems.iter().any(|p| p.contains("4 columns")));
312 }
313
314 #[test]
315 fn a_bad_index_and_a_bad_weight_are_named_by_line() {
316 let text = "\
317| # | Item | Weight | Needs | Status | Evidence |
318|---|------|--------|-------|--------|----------|
319| one | x | 1 | | Not started | |
320| 2 | y | heavy | | Not started | |
321| 3 | z | 1 | nine | Not started | |
322";
323 let RoadmapShape::Malformed { problems } = check_roadmap_shape(text) else {
324 panic!("expected malformed");
325 };
326 assert_eq!(problems.len(), 3);
327 assert!(problems[0].contains("line 3"));
328 assert!(problems[1].contains("heavy"));
329 assert!(problems[2].contains("line 5"));
330 }
331
332 #[test]
333 fn no_table_at_all_is_malformed() {
334 assert!(matches!(
335 check_roadmap_shape("just prose"),
336 RoadmapShape::Malformed { .. }
337 ));
338 }
339}