use crate::error::{AxCode, AxError};
use crate::locator::Locator;
use crate::node_id::NodeId;
use super::grammar::{body_row, check_roadmap_shape, locate_table, split_table_row};
use super::row::{NewChild, ROADMAP_COLUMNS, RoadmapRow, RoadmapShape, RoadmapStatus};
fn draw_row(row: &RoadmapRow, status: RoadmapStatus, evidence: Option<&Locator>) -> String {
let needs = row
.needs
.iter()
.map(NodeId::to_string)
.collect::<Vec<String>>()
.join(", ");
let cell = evidence.map_or_else(String::new, Locator::to_string);
format!(
"| {} | {} | {} | {needs} | {} | {cell} |",
row.id,
row.item,
row.weight,
status.spelling()
)
}
pub fn set_roadmap_status(
text: &str,
id: &NodeId,
status: RoadmapStatus,
evidence: Option<&Locator>,
) -> Result<String, AxError> {
if matches!(status, RoadmapStatus::Done) && evidence.is_none() {
return Err(AxError::failure(
AxCode::EvidenceMissing,
"mark a roadmap row done",
format!("row {id} has no evidence"),
)
.with_recovery("pass a retrievable locator: `cas:<hash>` or `file:<path>@<oid>`"));
}
let rows = well_formed(text, "edit a roadmap row")?;
let row = rows.iter().find(|row| &row.id == id).ok_or_else(|| {
AxError::failure(
AxCode::InvalidArgs,
"edit a roadmap row",
format!("no row numbered {id}"),
)
.with_recovery("read the roadmap and use an index the table carries")
})?;
let replacement = draw_row(row, status, evidence);
Ok(rewrite(text, |cells| {
cells
.first()
.and_then(|raw| NodeId::parse(raw).ok())
.is_some_and(|found| &found == id)
.then(|| replacement.clone())
}))
}
pub fn insert_children(
text: &str,
parent: &NodeId,
children: &[NewChild],
) -> Result<String, AxError> {
let refuse = |subject: String, recovery: &str| {
AxError::failure(AxCode::InvalidArgs, "split a plan node", subject)
.with_recovery(recovery.to_owned())
};
if children.is_empty() {
return Err(refuse(
"no children given".to_owned(),
"name at least one child; splitting into nothing would delete the work",
));
}
let rows = well_formed(text, "split a plan node")?;
if !rows.iter().any(|row| &row.id == parent) {
return Err(refuse(
format!("no row numbered {parent}"),
"read the roadmap and use an index the table carries",
));
}
let mut next = rows
.iter()
.filter(|row| row.id.parent().as_ref() == Some(parent))
.map(|row| row.id.ordinal())
.max()
.unwrap_or(0);
let mut drawn = Vec::with_capacity(children.len());
for child in children {
next = next.saturating_add(1);
drawn.push(draw_child(
&parent.child(next)?,
child,
"split a plan node",
)?);
}
let Some((first, last)) = locate_table(text) else {
return Err(refuse(
"the roadmap table is not there".to_owned(),
"repair the table before splitting a node",
));
};
let mut after = None;
for (n, line) in text.split('\n').enumerate() {
if n < first || n > last {
continue;
}
let Some(cells) = split_table_row(line) else {
continue;
};
if !body_row(&cells) {
continue;
}
let Some(id) = cells.first().and_then(|raw| NodeId::parse(raw).ok()) else {
continue;
};
if &id == parent || parent.is_ancestor_of(&id) {
after = Some(n);
}
}
Ok(insert_after(text, after.unwrap_or(last), &drawn))
}
pub fn append_top_level(text: &str, id: &NodeId, child: &NewChild) -> Result<String, AxError> {
let refuse = |subject: String, recovery: &str| {
AxError::failure(AxCode::InvalidArgs, "add a plan row", subject)
.with_recovery(recovery.to_owned())
};
if id.parent().is_some() {
return Err(refuse(
format!("{id} is not a top-level index"),
"add under a node by splitting the node you hold",
));
}
let rows = well_formed(text, "add a plan row")?;
if rows.iter().any(|row| &row.id == id) {
return Err(refuse(
format!("a row numbered {id} is already there"),
"read the plan again and add after its last top-level row",
));
}
let drawn = draw_child(id, child, "add a plan row")?;
let Some((_, last)) = locate_table(text) else {
return Err(refuse(
"the roadmap table is not there".to_owned(),
"repair the table before adding a row",
));
};
Ok(insert_after(text, last, &[drawn]))
}
fn draw_child(id: &NodeId, child: &NewChild, action: &'static str) -> Result<String, AxError> {
let refuse = |subject: String, recovery: &str| {
AxError::failure(AxCode::InvalidArgs, action, subject).with_recovery(recovery.to_owned())
};
if child.item.trim().is_empty() {
return Err(refuse(
"a child with no text".to_owned(),
"say what each child is; a row nobody can read is a row nobody can take",
));
}
if child.item.contains('|') {
return Err(refuse(
format!("`{}` carries a column separator", child.item),
"write the item without `|`; it would split the row into more cells",
));
}
if child.weight == 0 {
return Err(refuse(
format!("`{}` is weighted zero", child.item),
"give every child a weight of at least one; a node worth nothing is never given to \
anybody",
));
}
Ok(format!(
"| {id} | {} | {} | | {} | |",
child.item.trim(),
child.weight,
RoadmapStatus::NotStarted.spelling()
))
}
fn insert_after(text: &str, seam: usize, drawn: &[String]) -> String {
let mut out: Vec<String> = Vec::new();
for (n, line) in text.split('\n').enumerate() {
out.push(line.to_owned());
if n == seam {
out.extend(drawn.iter().cloned());
}
}
out.join("\n")
}
fn well_formed(text: &str, action: &'static str) -> Result<Vec<RoadmapRow>, AxError> {
match check_roadmap_shape(text) {
RoadmapShape::WellFormed { rows } => Ok(rows),
RoadmapShape::Malformed { problems } => Err(AxError::failure(
AxCode::InvalidArgs,
action,
problems.join("; "),
)
.with_recovery(format!(
"repair the {ROADMAP_COLUMNS}-column table in Roadmap.md first; a plan that does not \
parse has no denominator"
))),
}
}
fn rewrite(text: &str, mut chosen: impl FnMut(&[String]) -> Option<String>) -> String {
let mut out = String::with_capacity(text.len());
let mut in_table = false;
let mut done = false;
let mut first = true;
for line in text.split('\n') {
if !first {
out.push('\n');
}
first = false;
let cells = split_table_row(line);
if cells.is_some() {
in_table = true;
} else if in_table {
in_table = false;
done = true; }
let hit = if done {
None
} else {
cells
.as_ref()
.filter(|cells| body_row(cells))
.and_then(|cells| chosen(cells))
};
match hit {
Some(replacement) => out.push_str(&replacement),
None => out.push_str(line),
}
}
out
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::arithmetic_side_effects,
reason = "test code"
)]
mod tests;