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        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    // The insertion point: after the parent's last descendant, or after
157    // the parent itself when it has none.
158    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
187/// The rows of a well-formed table, or the refusal that says which line
188/// is wrong.
189fn 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
204/// Replaces the body rows of the first table for which `chosen` returns
205/// a replacement, leaving every other byte alone.
206fn 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; // the first table ended; later tables are not ours
222        }
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;