Skip to main content

specfold_core/authoring/
edit.rs

1//! Changing a document's body: replace or append to one section, or to the whole body. Without
2//! this, agents edited files by hand and the `updated` date and lifecycle went unchecked.
3
4use super::plan::Plan;
5use crate::corpus::Corpus;
6use crate::markdown::tables::{self, RowChange};
7use crate::markdown::{frontmatter, frontmatter::edit, sections};
8use crate::model::doc::Doc;
9use crate::model::stamp;
10use anyhow::{Result, anyhow, bail};
11use serde_json::Value;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Mode {
15    /// The section's content (its heading kept) or the whole body.
16    Replace,
17    /// After the section's last line, or at the end of the body.
18    Append,
19}
20
21#[derive(Debug, Clone)]
22pub struct Edit {
23    pub section: Option<String>,
24    /// Add a new section with this heading instead of changing one; placed after `after`
25    /// (and its subsections) at the same level, or at the end of the body at level 2.
26    pub new_section: Option<String>,
27    pub after: Option<String>,
28    pub mode: Mode,
29    pub text: String,
30    /// Change one table row (by its first cell) instead of the section text.
31    pub row: Option<RowChange>,
32}
33
34/// What an edit changed, for previews.
35#[derive(Debug, Clone)]
36pub struct Outcome {
37    pub plan: Plan,
38    /// The section heading or `body`.
39    pub target: String,
40    pub before: String,
41    pub after: String,
42}
43
44const CLOSED: &[&str] = &["superseded", "archived", "rejected"];
45
46pub fn plan(c: &Corpus, idx: usize, e: &Edit) -> Result<Outcome> {
47    let d = &c.docs[idx];
48    let ty = c.cfg.registry.get(&d.id.ty).ok_or_else(|| anyhow!("unknown type {}", d.id.ty))?;
49    if ty.sealed {
50        bail!("{} is a sealed {}; record a new one instead of editing it", d.id, ty.name);
51    }
52    let status = d.status().unwrap_or_default();
53    if CLOSED.contains(&status.as_str()) {
54        bail!("{} is {status}; its body is history. Supersede it: spec new {} --from {} --write", d.id, ty.name, d.id);
55    }
56    let text = crate::corpus::overlay::read(&c.cfg.root.join(&d.path))?;
57    let split = frontmatter::split(&text);
58    let head = &text[..text.len() - split.body.len()];
59    let lines: Vec<&str> = split.body.lines().collect();
60    let new_text = e.text.trim_end_matches('\n');
61    refuse_open_fence(new_text)?;
62    if let Some(ch) = &e.row {
63        return plan_row(d, head, &lines, split.body, e.section.as_deref(), ch);
64    }
65    if let Some(title) = &e.new_section {
66        return plan_new_section(d, head, &lines, split.body, e.after.as_deref(), title, new_text);
67    }
68    let (target, before, body) = match &e.section {
69        Some(name) => {
70            let Some((start, end)) = sections::span(split.body, name) else {
71                let msg = sections::not_found(split.body, split.body_line, name);
72                if sections::matches(split.body, name).len() > 1 {
73                    bail!("{msg}");
74                }
75                bail!("{msg}; to add it: --new-section {name:?}");
76            };
77            refuse_headings(new_text, lines[start].trim_start().chars().take_while(|c| *c == '#').count())?;
78            // Trailing blank lines separate sections; keep them outside the edited content.
79            let mut last = end;
80            while last > start + 1 && lines[last - 1].trim().is_empty() {
81                last -= 1;
82            }
83            let before = lines[start + 1..last].join("\n").trim_start_matches('\n').to_string();
84            let content = match e.mode {
85                Mode::Replace => new_text.to_string(),
86                Mode::Append if before.trim().is_empty() => new_text.to_string(),
87                Mode::Append => format!("{before}\n\n{new_text}"),
88            };
89            let mut out: Vec<String> = lines[..=start].iter().map(|s| s.to_string()).collect();
90            out.push(String::new());
91            out.extend(content.lines().map(str::to_string));
92            if end < lines.len() {
93                out.push(String::new());
94            }
95            out.extend(lines[end..].iter().map(|s| s.to_string()));
96            (lines[start].trim().to_string(), before, out.join("\n") + "\n")
97        }
98        None => {
99            let before = split.body.trim_end().to_string();
100            let body = match e.mode {
101                Mode::Replace => format!("{new_text}\n"),
102                Mode::Append => format!("{before}\n\n{new_text}\n"),
103            };
104            ("body".to_string(), before, format!("\n{}", body.trim_start_matches('\n')))
105        }
106    };
107    let after = match &e.section {
108        Some(name) => sections::extract(&body, name).unwrap_or_default(),
109        None => body.trim().to_string(),
110    };
111    let rewritten = edit::rewrite(&format!("{head}{body}"), |m| {
112        m.insert("updated".into(), Value::String(stamp::now_value()));
113    })?;
114    let mut plan = Plan::default();
115    if rewritten != text {
116        plan.write(&d.path, rewritten, false);
117    }
118    Ok(Outcome { plan, target, before, after })
119}
120
121/// Words a duplicate-key error: rows spread over tables outside a named section ask for
122/// --section; rows in one table (or a section already named) point at editing the whole section.
123fn duplicate_rows(e: anyhow::Error, d: &Doc, head: &str, lines: &[&str], section: Option<&str>) -> anyhow::Error {
124    let Some(dup) = e.downcast_ref::<tables::DuplicateRows>() else { return e };
125    let first = head.matches('\n').count() + 1;
126    let at: Vec<String> = dup.rows.iter().map(|r| (r + first).to_string()).collect();
127    let id = crate::shell::word(&d.id);
128    match section {
129        None if dup.tables > 1 => anyhow::anyhow!(
130            "'{}' names {} rows in {} tables (lines {}); name the section with --section; next: spec show {id}",
131            dup.key,
132            dup.rows.len(),
133            dup.tables,
134            at.join(", ")
135        ),
136        _ => {
137            let heading = || {
138                let above = dup.rows.first().map_or(0, |r| *r);
139                lines[..above].iter().rev().find(|l| l.starts_with('#')).map(|l| l.trim_start_matches('#').trim())
140            };
141            let s = crate::shell::opt("--section", section.or_else(heading).unwrap_or("<section>"));
142            anyhow::anyhow!(
143                "several rows have the key '{}' (lines {}); --row edits one row; next: spec edit {id} {s}",
144                dup.key,
145                at.join(", ")
146            )
147        }
148    }
149}
150
151/// `--row`: change one table row, in a section or anywhere in the body.
152fn plan_row(d: &Doc, head: &str, lines: &[&str], body: &str, section: Option<&str>, ch: &RowChange) -> Result<Outcome> {
153    let (lo, hi, target) = match &section {
154        Some(name) => {
155            let Some((start, end)) = sections::span(body, name) else {
156                bail!("{}", sections::not_found(body, head.matches('\n').count() + 1, name));
157            };
158            (start + 1, end, format!("row '{}' in {}", ch.key, lines[start].trim()))
159        }
160        None => (0, lines.len(), format!("row '{}'", ch.key)),
161    };
162    let (out, before, after) =
163        tables::edit_row(lines, lo, hi, ch).map_err(|e| duplicate_rows(e, d, head, lines, section))?;
164    let body = out.join("\n") + "\n";
165    let rewritten = edit::rewrite(&format!("{head}{body}"), |m| {
166        m.insert("updated".into(), Value::String(stamp::now_value()));
167    })?;
168    let mut plan = Plan::default();
169    if before != after {
170        plan.write(&d.path, rewritten, false);
171    }
172    Ok(Outcome { plan, target, before, after })
173}
174
175/// `--new-section`: add a section after another one, or at the end.
176fn plan_new_section(
177    d: &Doc,
178    head: &str,
179    lines: &[&str],
180    body: &str,
181    after: Option<&str>,
182    title: &str,
183    new_text: &str,
184) -> Result<Outcome> {
185    let title = title.trim();
186    if !sections::matches(body, title).is_empty()
187        && sections::matches(body, title)
188            .iter()
189            .all(|&i| lines[i].trim_start().trim_start_matches('#').trim().eq_ignore_ascii_case(title))
190    {
191        bail!("a section '{title}' already exists; edit it with --section");
192    }
193    let (at, level) = match &after {
194        Some(name) => {
195            let Some((start, end)) = sections::span(body, name) else {
196                bail!("{}", sections::not_found(body, head.matches('\n').count() + 1, name));
197            };
198            (end, lines[start].trim_start().chars().take_while(|c| *c == '#').count())
199        }
200        None => (lines.len(), 2),
201    };
202    refuse_headings(new_text, level)?;
203    let mut prefix: Vec<String> = lines[..at].iter().map(|s| s.to_string()).collect();
204    while prefix.last().is_some_and(|l| l.trim().is_empty()) {
205        prefix.pop();
206    }
207    let mut out = prefix;
208    out.push(String::new());
209    out.push(format!("{} {title}", "#".repeat(level)));
210    out.push(String::new());
211    out.extend(new_text.lines().map(str::to_string));
212    if at < lines.len() {
213        out.push(String::new());
214        out.extend(lines[at..].iter().map(|s| s.to_string()));
215    }
216    let body = format!("\n{}\n", out.join("\n").trim_start_matches('\n'));
217    let rewritten = edit::rewrite(&format!("{head}{body}"), |m| {
218        m.insert("updated".into(), Value::String(stamp::now_value()));
219    })?;
220    let mut plan = Plan::default();
221    plan.write(&d.path, rewritten, false);
222    Ok(Outcome { plan, target: format!("new section '{title}'"), before: String::new(), after: new_text.to_string() })
223}
224
225/// C208: text that opens a code fence and never closes it would turn every later heading of the
226/// document into code.
227fn refuse_open_fence(text: &str) -> Result<()> {
228    let mut fence = crate::markdown::Fence::default();
229    let mut opened = 0;
230    for (n, line) in text.lines().enumerate() {
231        if fence.step(line) && fence.inside() {
232            opened = n + 1;
233        }
234    }
235    if fence.inside() {
236        bail!("the text opens a code fence at line {opened} that it never closes; close it with the same fence line");
237    }
238    Ok(())
239}
240
241/// C125: section text may hold sub-headings, but a heading at the section's level or above would
242/// start another section (and could duplicate one).
243fn refuse_headings(text: &str, level: usize) -> Result<()> {
244    let mut fence = crate::markdown::Fence::default();
245    for line in text.lines() {
246        if fence.step(line) || fence.inside() {
247            continue;
248        }
249        let t = line.trim();
250        if crate::markdown::atx(line).is_some_and(|(hashes, _)| hashes <= level) {
251            bail!(
252                "the text holds the heading '{}', which would start a new section; add it with --new-section, or use a deeper heading ({})",
253                t.trim(),
254                "#".repeat(level + 1)
255            );
256        }
257    }
258    Ok(())
259}