1use crate::error::{AxCode, AxError};
11use crate::locator::Locator;
12use crate::node_id::NodeId;
13
14use super::grammar::{body_row, check_roadmap_shape, locate_table, split_table_row};
15use super::row::{NewChild, ROADMAP_COLUMNS, RoadmapRow, RoadmapShape, RoadmapStatus};
16
17fn draw_row(row: &RoadmapRow, status: RoadmapStatus, evidence: Option<&Locator>) -> String {
20 let needs = row
21 .needs
22 .iter()
23 .map(NodeId::to_string)
24 .collect::<Vec<String>>()
25 .join(", ");
26 let cell = evidence.map_or_else(String::new, Locator::to_string);
27 format!(
28 "| {} | {} | {} | {needs} | {} | {cell} |",
29 row.id,
30 row.item,
31 row.weight,
32 status.spelling()
33 )
34}
35
36pub fn set_roadmap_status(
47 text: &str,
48 id: &NodeId,
49 status: RoadmapStatus,
50 evidence: Option<&Locator>,
51) -> Result<String, AxError> {
52 if matches!(status, RoadmapStatus::Done) && evidence.is_none() {
53 return Err(AxError::failure(
54 AxCode::EvidenceMissing,
55 "mark a roadmap row done",
56 format!("row {id} has no evidence"),
57 )
58 .with_recovery("pass a retrievable locator: `cas:<hash>` or `file:<path>@<oid>`"));
59 }
60 let rows = well_formed(text, "edit a roadmap row")?;
61 let row = rows.iter().find(|row| &row.id == id).ok_or_else(|| {
62 AxError::failure(
63 AxCode::InvalidArgs,
64 "edit a roadmap row",
65 format!("no row numbered {id}"),
66 )
67 .with_recovery("read the roadmap and use an index the table carries")
68 })?;
69 let replacement = draw_row(row, status, evidence);
70 Ok(rewrite(text, |cells| {
71 cells
72 .first()
73 .and_then(|raw| NodeId::parse(raw).ok())
74 .is_some_and(|found| &found == id)
75 .then(|| replacement.clone())
76 }))
77}
78
79pub fn insert_children(
93 text: &str,
94 parent: &NodeId,
95 children: &[NewChild],
96) -> Result<String, AxError> {
97 let refuse = |subject: String, recovery: &str| {
98 AxError::failure(AxCode::InvalidArgs, "split a plan node", subject)
99 .with_recovery(recovery.to_owned())
100 };
101 if children.is_empty() {
102 return Err(refuse(
103 "no children given".to_owned(),
104 "name at least one child; splitting into nothing would delete the work",
105 ));
106 }
107 let rows = well_formed(text, "split a plan node")?;
108 if !rows.iter().any(|row| &row.id == parent) {
109 return Err(refuse(
110 format!("no row numbered {parent}"),
111 "read the roadmap and use an index the table carries",
112 ));
113 }
114 let mut next = rows
115 .iter()
116 .filter(|row| row.id.parent().as_ref() == Some(parent))
117 .map(|row| row.id.ordinal())
118 .max()
119 .unwrap_or(0);
120 let mut drawn = Vec::with_capacity(children.len());
121 for child in children {
122 if child.item.trim().is_empty() {
123 return Err(refuse(
124 "a child with no text".to_owned(),
125 "say what each child is; a row nobody can read is a row nobody can take",
126 ));
127 }
128 if child.item.contains('|') {
129 return Err(refuse(
130 format!("`{}` carries a column separator", child.item),
131 "write the item without `|`; it would split the row into more cells",
132 ));
133 }
134 if child.weight == 0 {
135 return Err(refuse(
136 format!("`{}` is weighted zero", child.item),
137 "give every child a weight of at least one; a node worth nothing is never given \
138 to anybody",
139 ));
140 }
141 next = next.saturating_add(1);
142 let id = parent.child(next)?;
143 drawn.push(format!(
144 "| {id} | {} | {} | | {} | |",
145 child.item.trim(),
146 child.weight,
147 RoadmapStatus::NotStarted.spelling()
148 ));
149 }
150 let Some((first, last)) = locate_table(text) else {
151 return Err(refuse(
152 "the roadmap table is not there".to_owned(),
153 "repair the table before splitting a node",
154 ));
155 };
156 let mut after = None;
159 for (n, line) in text.split('\n').enumerate() {
160 if n < first || n > last {
161 continue;
162 }
163 let Some(cells) = split_table_row(line) else {
164 continue;
165 };
166 if !body_row(&cells) {
167 continue;
168 }
169 let Some(id) = cells.first().and_then(|raw| NodeId::parse(raw).ok()) else {
170 continue;
171 };
172 if &id == parent || parent.is_ancestor_of(&id) {
173 after = Some(n);
174 }
175 }
176 let seam = after.unwrap_or(last);
177 let mut out: Vec<String> = Vec::new();
178 for (n, line) in text.split('\n').enumerate() {
179 out.push(line.to_owned());
180 if n == seam {
181 out.extend(drawn.iter().cloned());
182 }
183 }
184 Ok(out.join("\n"))
185}
186
187fn well_formed(text: &str, action: &'static str) -> Result<Vec<RoadmapRow>, AxError> {
190 match check_roadmap_shape(text) {
191 RoadmapShape::WellFormed { rows } => Ok(rows),
192 RoadmapShape::Malformed { problems } => Err(AxError::failure(
193 AxCode::InvalidArgs,
194 action,
195 problems.join("; "),
196 )
197 .with_recovery(format!(
198 "repair the {ROADMAP_COLUMNS}-column table in Roadmap.md first; a plan that does not \
199 parse has no denominator"
200 ))),
201 }
202}
203
204fn rewrite(text: &str, mut chosen: impl FnMut(&[String]) -> Option<String>) -> String {
207 let mut out = String::with_capacity(text.len());
208 let mut in_table = false;
209 let mut done = false;
210 let mut first = true;
211 for line in text.split('\n') {
212 if !first {
213 out.push('\n');
214 }
215 first = false;
216 let cells = split_table_row(line);
217 if cells.is_some() {
218 in_table = true;
219 } else if in_table {
220 in_table = false;
221 done = true; }
223 let hit = if done {
224 None
225 } else {
226 cells
227 .as_ref()
228 .filter(|cells| body_row(cells))
229 .and_then(|cells| chosen(cells))
230 };
231 match hit {
232 Some(replacement) => out.push_str(&replacement),
233 None => out.push_str(line),
234 }
235 }
236 out
237}
238
239#[cfg(test)]
240#[allow(
241 clippy::unwrap_used,
242 clippy::expect_used,
243 clippy::panic,
244 clippy::indexing_slicing,
245 clippy::arithmetic_side_effects,
246 reason = "test code"
247)]
248mod tests;