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 next = next.saturating_add(1);
123 drawn.push(draw_child(
124 &parent.child(next)?,
125 child,
126 "split a plan node",
127 )?);
128 }
129 let Some((first, last)) = locate_table(text) else {
130 return Err(refuse(
131 "the roadmap table is not there".to_owned(),
132 "repair the table before splitting a node",
133 ));
134 };
135 let mut after = None;
138 for (n, line) in text.split('\n').enumerate() {
139 if n < first || n > last {
140 continue;
141 }
142 let Some(cells) = split_table_row(line) else {
143 continue;
144 };
145 if !body_row(&cells) {
146 continue;
147 }
148 let Some(id) = cells.first().and_then(|raw| NodeId::parse(raw).ok()) else {
149 continue;
150 };
151 if &id == parent || parent.is_ancestor_of(&id) {
152 after = Some(n);
153 }
154 }
155 Ok(insert_after(text, after.unwrap_or(last), &drawn))
156}
157
158pub fn append_top_level(text: &str, id: &NodeId, child: &NewChild) -> Result<String, AxError> {
173 let refuse = |subject: String, recovery: &str| {
174 AxError::failure(AxCode::InvalidArgs, "add a plan row", subject)
175 .with_recovery(recovery.to_owned())
176 };
177 if id.parent().is_some() {
178 return Err(refuse(
179 format!("{id} is not a top-level index"),
180 "add under a node by splitting the node you hold",
181 ));
182 }
183 let rows = well_formed(text, "add a plan row")?;
184 if rows.iter().any(|row| &row.id == id) {
185 return Err(refuse(
186 format!("a row numbered {id} is already there"),
187 "read the plan again and add after its last top-level row",
188 ));
189 }
190 let drawn = draw_child(id, child, "add a plan row")?;
191 let Some((_, last)) = locate_table(text) else {
192 return Err(refuse(
193 "the roadmap table is not there".to_owned(),
194 "repair the table before adding a row",
195 ));
196 };
197 Ok(insert_after(text, last, &[drawn]))
198}
199
200fn draw_child(id: &NodeId, child: &NewChild, action: &'static str) -> Result<String, AxError> {
203 let refuse = |subject: String, recovery: &str| {
204 AxError::failure(AxCode::InvalidArgs, action, subject).with_recovery(recovery.to_owned())
205 };
206 if child.item.trim().is_empty() {
207 return Err(refuse(
208 "a child with no text".to_owned(),
209 "say what each child is; a row nobody can read is a row nobody can take",
210 ));
211 }
212 if child.item.contains('|') {
213 return Err(refuse(
214 format!("`{}` carries a column separator", child.item),
215 "write the item without `|`; it would split the row into more cells",
216 ));
217 }
218 if child.weight == 0 {
219 return Err(refuse(
220 format!("`{}` is weighted zero", child.item),
221 "give every child a weight of at least one; a node worth nothing is never given to \
222 anybody",
223 ));
224 }
225 Ok(format!(
226 "| {id} | {} | {} | | {} | |",
227 child.item.trim(),
228 child.weight,
229 RoadmapStatus::NotStarted.spelling()
230 ))
231}
232
233fn insert_after(text: &str, seam: usize, drawn: &[String]) -> String {
235 let mut out: Vec<String> = Vec::new();
236 for (n, line) in text.split('\n').enumerate() {
237 out.push(line.to_owned());
238 if n == seam {
239 out.extend(drawn.iter().cloned());
240 }
241 }
242 out.join("\n")
243}
244
245fn well_formed(text: &str, action: &'static str) -> Result<Vec<RoadmapRow>, AxError> {
248 match check_roadmap_shape(text) {
249 RoadmapShape::WellFormed { rows } => Ok(rows),
250 RoadmapShape::Malformed { problems } => Err(AxError::failure(
251 AxCode::InvalidArgs,
252 action,
253 problems.join("; "),
254 )
255 .with_recovery(format!(
256 "repair the {ROADMAP_COLUMNS}-column table in Roadmap.md first; a plan that does not \
257 parse has no denominator"
258 ))),
259 }
260}
261
262fn rewrite(text: &str, mut chosen: impl FnMut(&[String]) -> Option<String>) -> String {
265 let mut out = String::with_capacity(text.len());
266 let mut in_table = false;
267 let mut done = false;
268 let mut first = true;
269 for line in text.split('\n') {
270 if !first {
271 out.push('\n');
272 }
273 first = false;
274 let cells = split_table_row(line);
275 if cells.is_some() {
276 in_table = true;
277 } else if in_table {
278 in_table = false;
279 done = true; }
281 let hit = if done {
282 None
283 } else {
284 cells
285 .as_ref()
286 .filter(|cells| body_row(cells))
287 .and_then(|cells| chosen(cells))
288 };
289 match hit {
290 Some(replacement) => out.push_str(&replacement),
291 None => out.push_str(line),
292 }
293 }
294 out
295}
296
297#[cfg(test)]
298#[allow(
299 clippy::unwrap_used,
300 clippy::expect_used,
301 clippy::panic,
302 clippy::indexing_slicing,
303 clippy::arithmetic_side_effects,
304 reason = "test code"
305)]
306mod tests;