sprawling-kernel 0.0.9

Every decision of a sprawling city - addresses, events, gates, plans and errors - with nothing that touches a disk.
Documentation
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright (c) 2026 2youg1 and the sprawling contributors

//! Table rewrites: status changes and child insertions.
//! The part `crates/kernel/spec/Spine.lean` specifies this module with the
//! rest of `kernel::spine`.

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};

/// One row as the table writes it. The normal form: the same edit twice
/// renders the same bytes.
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()
    )
}

/// Rewrites one row of the first table, returning the whole text.
///
/// This is the table's only status-editing entrance.
///
/// # Errors
/// Refuses a text whose table does not parse, an index no row carries,
/// and a `Done` without evidence — that last one is exactly the row
/// `crate::plan` declines to count, and a writer that can produce it is
/// a writer that can make a plan look finished while the figure stands
/// still.
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())
    }))
}

/// Hangs new children under a node, returning the whole text.
///
/// The children land directly below the node's last descendant, so the
/// table stays in reading order and the numbers a person saw yesterday
/// still point at the same work. New ordinals continue after the
/// node's existing children rather than reusing a gap: a plan index is
/// a name, and reusing one would silently move somebody's evidence.
///
/// # Errors
/// Refuses a table that does not parse, an index no row carries, an
/// empty list of children, a child whose text is blank, a weight of
/// zero (a node worth nothing is a row nobody will ever be given), and
/// a split that would push the plan past `plan::NODE_DEPTH_MAX`.
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",
        ));
    };
    // The insertion point: after the parent's last descendant, or after
    // the parent itself when it has none.
    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))
}

/// Appends one top-level row under the plan's root, returning the whole
/// text.
///
/// The row lands after the table's last line, so a plan with a header
/// and no rows gets its first line. `id` is the index the caller chose
/// from the plan it read, and it is checked against this text rather
/// than recomputed: a caller that goes on to claim the row it added
/// names it by that index, and renumbering here would point the claim at
/// a row it never wrote.
///
/// # Errors
/// Refuses a table that does not parse or is not there, an `id` that is
/// not top-level or that a row already carries, and a child the table
/// cannot hold (blank, carrying `|`, weighted zero).
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]))
}

/// One new row as the table writes it, or the refusal that says why the
/// table cannot hold it.
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()
    ))
}

/// The text with `drawn` inserted after line `seam` of `text.split('\n')`.
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")
}

/// The rows of a well-formed table, or the refusal that says which line
/// is wrong.
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"
        ))),
    }
}

/// Replaces the body rows of the first table for which `chosen` returns
/// a replacement, leaving every other byte alone.
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; // the first table ended; later tables are not ours
        }
        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;