specfold-core 0.3.0

Library behind specfold: typed Markdown documents, owners, a derived graph and checks.
Documentation
//! Changing a document's body: replace or append to one section, or to the whole body. Without
//! this, agents edited files by hand and the `updated` date and lifecycle went unchecked.

use super::plan::Plan;
use crate::corpus::Corpus;
use crate::markdown::tables::{self, RowChange};
use crate::markdown::{frontmatter, frontmatter::edit, sections};
use crate::model::stamp;
use anyhow::{Result, anyhow, bail};
use serde_json::Value;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
    /// The section's content (its heading kept) or the whole body.
    Replace,
    /// After the section's last line, or at the end of the body.
    Append,
}

#[derive(Debug, Clone)]
pub struct Edit {
    pub section: Option<String>,
    /// Add a new section with this heading instead of changing one; placed after `after`
    /// (and its subsections) at the same level, or at the end of the body at level 2.
    pub new_section: Option<String>,
    pub after: Option<String>,
    pub mode: Mode,
    pub text: String,
    /// Change one table row (by its first cell) instead of the section text.
    pub row: Option<RowChange>,
}

/// What an edit changed, for previews.
#[derive(Debug, Clone)]
pub struct Outcome {
    pub plan: Plan,
    /// The section heading or `body`.
    pub target: String,
    pub before: String,
    pub after: String,
}

const CLOSED: &[&str] = &["superseded", "archived", "rejected"];

pub fn plan(c: &Corpus, idx: usize, e: &Edit) -> Result<Outcome> {
    let d = &c.docs[idx];
    let ty = c.cfg.registry.get(&d.id.ty).ok_or_else(|| anyhow!("unknown type {}", d.id.ty))?;
    if ty.sealed {
        bail!("{} is a sealed {}; record a new one instead of editing it", d.id, ty.name);
    }
    let status = d.status().unwrap_or_default();
    if CLOSED.contains(&status.as_str()) {
        bail!("{} is {status}; its body is history. Supersede it: spec new {} --from {}", d.id, ty.name, d.id);
    }
    let text = std::fs::read_to_string(c.cfg.root.join(&d.path))?;
    let split = frontmatter::split(&text);
    let head = &text[..text.len() - split.body.len()];
    let lines: Vec<&str> = split.body.lines().collect();
    let new_text = e.text.trim_end_matches('\n');
    if let Some(ch) = &e.row {
        let (lo, hi, target) = match &e.section {
            Some(name) => {
                let Some((start, end)) = sections::span(split.body, name) else {
                    let have: Vec<String> = sections::outline(split.body).into_iter().map(|(_, t)| t).collect();
                    bail!("no section '{name}' (sections: {})", have.join(" | "));
                };
                (start + 1, end, format!("row '{}' in {}", ch.key, lines[start].trim()))
            }
            None => (0, lines.len(), format!("row '{}'", ch.key)),
        };
        let (out, before, after) = tables::edit_row(&lines, lo, hi, ch)?;
        let body = out.join("\n") + "\n";
        let rewritten = edit::rewrite(&format!("{head}{body}"), |m| {
            m.insert("updated".into(), Value::String(stamp::now_value()));
        })?;
        let mut plan = Plan::default();
        if before != after {
            plan.write(&d.path, rewritten, false);
        }
        return Ok(Outcome { plan, target, before, after });
    }
    if let Some(title) = &e.new_section {
        let title = title.trim();
        if sections::span(split.body, title).is_some() {
            bail!("a section '{title}' already exists; edit it with --section");
        }
        let (at, level) = match &e.after {
            Some(name) => {
                let Some((start, end)) = sections::span(split.body, name) else {
                    let have: Vec<String> = sections::outline(split.body).into_iter().map(|(_, t)| t).collect();
                    bail!("no section '{name}' (sections: {})", have.join(" | "));
                };
                (end, lines[start].trim_start().chars().take_while(|c| *c == '#').count())
            }
            None => (lines.len(), 2),
        };
        let mut prefix: Vec<String> = lines[..at].iter().map(|s| s.to_string()).collect();
        while prefix.last().is_some_and(|l| l.trim().is_empty()) {
            prefix.pop();
        }
        let mut out = prefix;
        out.push(String::new());
        out.push(format!("{} {title}", "#".repeat(level)));
        out.push(String::new());
        out.extend(new_text.lines().map(str::to_string));
        if at < lines.len() {
            out.push(String::new());
            out.extend(lines[at..].iter().map(|s| s.to_string()));
        }
        let body = format!("\n{}\n", out.join("\n").trim_start_matches('\n'));
        let rewritten = edit::rewrite(&format!("{head}{body}"), |m| {
            m.insert("updated".into(), Value::String(stamp::now_value()));
        })?;
        let mut plan = Plan::default();
        plan.write(&d.path, rewritten, false);
        return Ok(Outcome {
            plan,
            target: format!("new section '{title}'"),
            before: String::new(),
            after: new_text.to_string(),
        });
    }
    let (target, before, body) = match &e.section {
        Some(name) => {
            let Some((start, end)) = sections::span(split.body, name) else {
                let have: Vec<String> = sections::outline(split.body).into_iter().map(|(_, t)| t).collect();
                bail!("no section '{name}' (sections: {}); to add it: --new-section {name:?}", have.join(" | "));
            };
            // Trailing blank lines separate sections; keep them outside the edited content.
            let mut last = end;
            while last > start + 1 && lines[last - 1].trim().is_empty() {
                last -= 1;
            }
            let before = lines[start + 1..last].join("\n").trim_start_matches('\n').to_string();
            let content = match e.mode {
                Mode::Replace => new_text.to_string(),
                Mode::Append if before.trim().is_empty() => new_text.to_string(),
                Mode::Append => format!("{before}\n\n{new_text}"),
            };
            let mut out: Vec<String> = lines[..=start].iter().map(|s| s.to_string()).collect();
            out.push(String::new());
            out.extend(content.lines().map(str::to_string));
            if end < lines.len() {
                out.push(String::new());
            }
            out.extend(lines[end..].iter().map(|s| s.to_string()));
            (lines[start].trim().to_string(), before, out.join("\n") + "\n")
        }
        None => {
            let before = split.body.trim_end().to_string();
            let body = match e.mode {
                Mode::Replace => format!("{new_text}\n"),
                Mode::Append => format!("{before}\n\n{new_text}\n"),
            };
            ("body".to_string(), before, format!("\n{}", body.trim_start_matches('\n')))
        }
    };
    let after = match &e.section {
        Some(name) => sections::extract(&body, name).unwrap_or_default(),
        None => body.trim().to_string(),
    };
    let rewritten = edit::rewrite(&format!("{head}{body}"), |m| {
        m.insert("updated".into(), Value::String(stamp::now_value()));
    })?;
    let mut plan = Plan::default();
    if rewritten != text {
        plan.write(&d.path, rewritten, false);
    }
    Ok(Outcome { plan, target, before, after })
}