Skip to main content

kernel/spine/
rewrite.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Table rewrites: status changes and child insertions.
7//! The part `crates/kernel/spec/Spine.lean` specifies this module with the
8//! rest of `kernel::spine`.
9
10use 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
17/// One row as the table writes it. The normal form: the same edit twice
18/// renders the same bytes.
19fn 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
36/// Rewrites one row of the first table, returning the whole text.
37///
38/// This is the table's only status-editing entrance.
39///
40/// # Errors
41/// Refuses a text whose table does not parse, an index no row carries,
42/// and a `Done` without evidence — that last one is exactly the row
43/// `crate::plan` declines to count, and a writer that can produce it is
44/// a writer that can make a plan look finished while the figure stands
45/// still.
46pub 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
79/// Hangs new children under a node, returning the whole text.
80///
81/// The children land directly below the node's last descendant, so the
82/// table stays in reading order and the numbers a person saw yesterday
83/// still point at the same work. New ordinals continue after the
84/// node's existing children rather than reusing a gap: a plan index is
85/// a name, and reusing one would silently move somebody's evidence.
86///
87/// # Errors
88/// Refuses a table that does not parse, an index no row carries, an
89/// empty list of children, a child whose text is blank, a weight of
90/// zero (a node worth nothing is a row nobody will ever be given), and
91/// a split that would push the plan past `plan::NODE_DEPTH_MAX`.
92pub 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    // The insertion point: after the parent's last descendant, or after
136    // the parent itself when it has none.
137    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
158/// Appends one top-level row under the plan's root, returning the whole
159/// text.
160///
161/// The row lands after the table's last line, so a plan with a header
162/// and no rows gets its first line. `id` is the index the caller chose
163/// from the plan it read, and it is checked against this text rather
164/// than recomputed: a caller that goes on to claim the row it added
165/// names it by that index, and renumbering here would point the claim at
166/// a row it never wrote.
167///
168/// # Errors
169/// Refuses a table that does not parse or is not there, an `id` that is
170/// not top-level or that a row already carries, and a child the table
171/// cannot hold (blank, carrying `|`, weighted zero).
172pub 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
200/// One new row as the table writes it, or the refusal that says why the
201/// table cannot hold it.
202fn 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
233/// The text with `drawn` inserted after line `seam` of `text.split('\n')`.
234fn 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
245/// The rows of a well-formed table, or the refusal that says which line
246/// is wrong.
247fn 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
262/// Replaces the body rows of the first table for which `chosen` returns
263/// a replacement, leaving every other byte alone.
264fn 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; // the first table ended; later tables are not ours
280        }
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;