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        if let Some(way) = super::successor::way_on(c, idx) {
55            bail!("{} is {status}; its body is history; {way}", d.id);
56        }
57        bail!("{} is {status}; its body is history. Supersede it: spec new {} --from {} --write", d.id, ty.name, d.id);
58    }
59    let text = crate::corpus::overlay::read(&c.cfg.root.join(&d.path))?;
60    let split = frontmatter::split(&text);
61    let head = &text[..text.len() - split.body.len()];
62    let lines: Vec<&str> = split.body.lines().collect();
63    let new_text = e.text.trim_end_matches('\n');
64    refuse_open_fence(new_text)?;
65    if let Some(ch) = &e.row {
66        return plan_row(d, head, &lines, split.body, e.section.as_deref(), ch);
67    }
68    if let Some(title) = &e.new_section {
69        return plan_new_section(d, head, &lines, split.body, e.after.as_deref(), title, new_text);
70    }
71    let (target, before, body) = match &e.section {
72        Some(name) => {
73            let Some((start, end)) = sections::span(split.body, name) else {
74                let msg = sections::not_found(split.body, split.body_line, name);
75                if sections::matches(split.body, name).len() > 1 {
76                    bail!("{msg}");
77                }
78                bail!("{msg}; to add it: --new-section {name:?}");
79            };
80            refuse_headings(new_text, lines[start].trim_start().chars().take_while(|c| *c == '#').count())?;
81            // Trailing blank lines separate sections; keep them outside the edited content.
82            let mut last = end;
83            while last > start + 1 && lines[last - 1].trim().is_empty() {
84                last -= 1;
85            }
86            let before = lines[start + 1..last].join("\n").trim_start_matches('\n').to_string();
87            let content = match e.mode {
88                Mode::Replace => new_text.to_string(),
89                Mode::Append if before.trim().is_empty() => new_text.to_string(),
90                Mode::Append => format!("{before}\n\n{new_text}"),
91            };
92            let mut out: Vec<String> = lines[..=start].iter().map(|s| s.to_string()).collect();
93            out.push(String::new());
94            out.extend(content.lines().map(str::to_string));
95            if end < lines.len() {
96                out.push(String::new());
97            }
98            out.extend(lines[end..].iter().map(|s| s.to_string()));
99            (lines[start].trim().to_string(), before, out.join("\n") + "\n")
100        }
101        None => {
102            let before = split.body.trim_end().to_string();
103            let body = match e.mode {
104                Mode::Replace => format!("{new_text}\n"),
105                Mode::Append => format!("{before}\n\n{new_text}\n"),
106            };
107            ("body".to_string(), before, format!("\n{}", body.trim_start_matches('\n')))
108        }
109    };
110    let after = match &e.section {
111        Some(name) => sections::extract(&body, name).unwrap_or_default(),
112        None => body.trim().to_string(),
113    };
114    let rewritten = edit::rewrite(&format!("{head}{body}"), |m| {
115        m.insert("updated".into(), Value::String(stamp::now_value()));
116    })?;
117    let mut plan = Plan::default();
118    if rewritten != text {
119        plan.write(&d.path, rewritten, false);
120    }
121    Ok(Outcome { plan, target, before, after })
122}
123
124/// Words a duplicate-key error: rows spread over tables outside a named section ask for
125/// --section; rows in one table (or a section already named) point at editing the whole section.
126fn duplicate_rows(e: anyhow::Error, d: &Doc, head: &str, lines: &[&str], section: Option<&str>) -> anyhow::Error {
127    let Some(dup) = e.downcast_ref::<tables::DuplicateRows>() else { return e };
128    let first = head.matches('\n').count() + 1;
129    let at: Vec<String> = dup.rows.iter().map(|r| (r + first).to_string()).collect();
130    let id = crate::shell::word(&d.id);
131    match section {
132        None if dup.tables > 1 => anyhow::anyhow!(
133            "'{}' names {} rows in {} tables (lines {}); name the section with --section; next: spec show {id}",
134            dup.key,
135            dup.rows.len(),
136            dup.tables,
137            at.join(", ")
138        ),
139        _ => {
140            let heading = || {
141                let above = dup.rows.first().map_or(0, |r| *r);
142                lines[..above].iter().rev().find(|l| l.starts_with('#')).map(|l| l.trim_start_matches('#').trim())
143            };
144            let s = crate::shell::opt("--section", section.or_else(heading).unwrap_or("<section>"));
145            anyhow::anyhow!(
146                "several rows have the key '{}' (lines {}); --row edits one row; next: spec edit {id} {s}",
147                dup.key,
148                at.join(", ")
149            )
150        }
151    }
152}
153
154/// `--row`: change one table row, in a section or anywhere in the body.
155fn plan_row(d: &Doc, head: &str, lines: &[&str], body: &str, section: Option<&str>, ch: &RowChange) -> Result<Outcome> {
156    let (lo, hi, target) = match &section {
157        Some(name) => {
158            let Some((start, end)) = sections::span(body, name) else {
159                bail!("{}", sections::not_found(body, head.matches('\n').count() + 1, name));
160            };
161            (start + 1, end, format!("row '{}' in {}", ch.key, lines[start].trim()))
162        }
163        None => (0, lines.len(), format!("row '{}'", ch.key)),
164    };
165    let (out, before, after) =
166        tables::edit_row(lines, lo, hi, ch).map_err(|e| duplicate_rows(e, d, head, lines, section))?;
167    let body = out.join("\n") + "\n";
168    let rewritten = edit::rewrite(&format!("{head}{body}"), |m| {
169        m.insert("updated".into(), Value::String(stamp::now_value()));
170    })?;
171    let mut plan = Plan::default();
172    if before != after {
173        plan.write(&d.path, rewritten, false);
174    }
175    Ok(Outcome { plan, target, before, after })
176}
177
178/// `--new-section`: add a section after another one, or at the end.
179fn plan_new_section(
180    d: &Doc,
181    head: &str,
182    lines: &[&str],
183    body: &str,
184    after: Option<&str>,
185    title: &str,
186    new_text: &str,
187) -> Result<Outcome> {
188    let title = title.trim();
189    if !sections::matches(body, title).is_empty()
190        && sections::matches(body, title)
191            .iter()
192            .all(|&i| lines[i].trim_start().trim_start_matches('#').trim().eq_ignore_ascii_case(title))
193    {
194        bail!("a section '{title}' already exists; edit it with --section");
195    }
196    let (at, level) = match &after {
197        Some(name) => {
198            let Some((start, end)) = sections::span(body, name) else {
199                bail!("{}", sections::not_found(body, head.matches('\n').count() + 1, name));
200            };
201            (end, lines[start].trim_start().chars().take_while(|c| *c == '#').count())
202        }
203        None => (lines.len(), 2),
204    };
205    refuse_headings(new_text, level)?;
206    let mut prefix: Vec<String> = lines[..at].iter().map(|s| s.to_string()).collect();
207    while prefix.last().is_some_and(|l| l.trim().is_empty()) {
208        prefix.pop();
209    }
210    let mut out = prefix;
211    out.push(String::new());
212    out.push(format!("{} {title}", "#".repeat(level)));
213    out.push(String::new());
214    out.extend(new_text.lines().map(str::to_string));
215    if at < lines.len() {
216        out.push(String::new());
217        out.extend(lines[at..].iter().map(|s| s.to_string()));
218    }
219    let body = format!("\n{}\n", out.join("\n").trim_start_matches('\n'));
220    let rewritten = edit::rewrite(&format!("{head}{body}"), |m| {
221        m.insert("updated".into(), Value::String(stamp::now_value()));
222    })?;
223    let mut plan = Plan::default();
224    plan.write(&d.path, rewritten, false);
225    Ok(Outcome { plan, target: format!("new section '{title}'"), before: String::new(), after: new_text.to_string() })
226}
227
228/// C208: text that opens a code fence and never closes it would turn every later heading of the
229/// document into code.
230fn refuse_open_fence(text: &str) -> Result<()> {
231    let mut fence = crate::markdown::Fence::default();
232    let mut opened = 0;
233    for (n, line) in text.lines().enumerate() {
234        if fence.step(line) && fence.inside() {
235            opened = n + 1;
236        }
237    }
238    if fence.inside() {
239        bail!("the text opens a code fence at line {opened} that it never closes; close it with the same fence line");
240    }
241    Ok(())
242}
243
244/// C125: section text may hold sub-headings, but a heading at the section's level or above would
245/// start another section (and could duplicate one).
246fn refuse_headings(text: &str, level: usize) -> Result<()> {
247    let mut fence = crate::markdown::Fence::default();
248    for line in text.lines() {
249        if fence.step(line) || fence.inside() {
250            continue;
251        }
252        let t = line.trim();
253        if crate::markdown::atx(line).is_some_and(|(hashes, _)| hashes <= level) {
254            bail!(
255                "the text holds the heading '{}', which would start a new section; add it with --new-section, or use a deeper heading ({})",
256                t.trim(),
257                "#".repeat(level + 1)
258            );
259        }
260    }
261    Ok(())
262}