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::stamp;
9use anyhow::{Result, anyhow, bail};
10use serde_json::Value;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Mode {
14    /// The section's content (its heading kept) or the whole body.
15    Replace,
16    /// After the section's last line, or at the end of the body.
17    Append,
18}
19
20#[derive(Debug, Clone)]
21pub struct Edit {
22    pub section: Option<String>,
23    /// Add a new section with this heading instead of changing one; placed after `after`
24    /// (and its subsections) at the same level, or at the end of the body at level 2.
25    pub new_section: Option<String>,
26    pub after: Option<String>,
27    pub mode: Mode,
28    pub text: String,
29    /// Change one table row (by its first cell) instead of the section text.
30    pub row: Option<RowChange>,
31}
32
33/// What an edit changed, for previews.
34#[derive(Debug, Clone)]
35pub struct Outcome {
36    pub plan: Plan,
37    /// The section heading or `body`.
38    pub target: String,
39    pub before: String,
40    pub after: String,
41}
42
43const CLOSED: &[&str] = &["superseded", "archived", "rejected"];
44
45pub fn plan(c: &Corpus, idx: usize, e: &Edit) -> Result<Outcome> {
46    let d = &c.docs[idx];
47    let ty = c.cfg.registry.get(&d.id.ty).ok_or_else(|| anyhow!("unknown type {}", d.id.ty))?;
48    if ty.sealed {
49        bail!("{} is a sealed {}; record a new one instead of editing it", d.id, ty.name);
50    }
51    let status = d.status().unwrap_or_default();
52    if CLOSED.contains(&status.as_str()) {
53        bail!("{} is {status}; its body is history. Supersede it: spec new {} --from {}", d.id, ty.name, d.id);
54    }
55    let text = std::fs::read_to_string(c.cfg.root.join(&d.path))?;
56    let split = frontmatter::split(&text);
57    let head = &text[..text.len() - split.body.len()];
58    let lines: Vec<&str> = split.body.lines().collect();
59    let new_text = e.text.trim_end_matches('\n');
60    if let Some(ch) = &e.row {
61        let (lo, hi, target) = match &e.section {
62            Some(name) => {
63                let Some((start, end)) = sections::span(split.body, name) else {
64                    let have: Vec<String> = sections::outline(split.body).into_iter().map(|(_, t)| t).collect();
65                    bail!("no section '{name}' (sections: {})", have.join(" | "));
66                };
67                (start + 1, end, format!("row '{}' in {}", ch.key, lines[start].trim()))
68            }
69            None => (0, lines.len(), format!("row '{}'", ch.key)),
70        };
71        let (out, before, after) = tables::edit_row(&lines, lo, hi, ch)?;
72        let body = out.join("\n") + "\n";
73        let rewritten = edit::rewrite(&format!("{head}{body}"), |m| {
74            m.insert("updated".into(), Value::String(stamp::now_value()));
75        })?;
76        let mut plan = Plan::default();
77        if before != after {
78            plan.write(&d.path, rewritten, false);
79        }
80        return Ok(Outcome { plan, target, before, after });
81    }
82    if let Some(title) = &e.new_section {
83        let title = title.trim();
84        if sections::span(split.body, title).is_some() {
85            bail!("a section '{title}' already exists; edit it with --section");
86        }
87        let (at, level) = match &e.after {
88            Some(name) => {
89                let Some((start, end)) = sections::span(split.body, name) else {
90                    let have: Vec<String> = sections::outline(split.body).into_iter().map(|(_, t)| t).collect();
91                    bail!("no section '{name}' (sections: {})", have.join(" | "));
92                };
93                (end, lines[start].trim_start().chars().take_while(|c| *c == '#').count())
94            }
95            None => (lines.len(), 2),
96        };
97        let mut prefix: Vec<String> = lines[..at].iter().map(|s| s.to_string()).collect();
98        while prefix.last().is_some_and(|l| l.trim().is_empty()) {
99            prefix.pop();
100        }
101        let mut out = prefix;
102        out.push(String::new());
103        out.push(format!("{} {title}", "#".repeat(level)));
104        out.push(String::new());
105        out.extend(new_text.lines().map(str::to_string));
106        if at < lines.len() {
107            out.push(String::new());
108            out.extend(lines[at..].iter().map(|s| s.to_string()));
109        }
110        let body = format!("\n{}\n", out.join("\n").trim_start_matches('\n'));
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        plan.write(&d.path, rewritten, false);
116        return Ok(Outcome {
117            plan,
118            target: format!("new section '{title}'"),
119            before: String::new(),
120            after: new_text.to_string(),
121        });
122    }
123    let (target, before, body) = match &e.section {
124        Some(name) => {
125            let Some((start, end)) = sections::span(split.body, name) else {
126                let have: Vec<String> = sections::outline(split.body).into_iter().map(|(_, t)| t).collect();
127                bail!("no section '{name}' (sections: {}); to add it: --new-section {name:?}", have.join(" | "));
128            };
129            // Trailing blank lines separate sections; keep them outside the edited content.
130            let mut last = end;
131            while last > start + 1 && lines[last - 1].trim().is_empty() {
132                last -= 1;
133            }
134            let before = lines[start + 1..last].join("\n").trim_start_matches('\n').to_string();
135            let content = match e.mode {
136                Mode::Replace => new_text.to_string(),
137                Mode::Append if before.trim().is_empty() => new_text.to_string(),
138                Mode::Append => format!("{before}\n\n{new_text}"),
139            };
140            let mut out: Vec<String> = lines[..=start].iter().map(|s| s.to_string()).collect();
141            out.push(String::new());
142            out.extend(content.lines().map(str::to_string));
143            if end < lines.len() {
144                out.push(String::new());
145            }
146            out.extend(lines[end..].iter().map(|s| s.to_string()));
147            (lines[start].trim().to_string(), before, out.join("\n") + "\n")
148        }
149        None => {
150            let before = split.body.trim_end().to_string();
151            let body = match e.mode {
152                Mode::Replace => format!("{new_text}\n"),
153                Mode::Append => format!("{before}\n\n{new_text}\n"),
154            };
155            ("body".to_string(), before, format!("\n{}", body.trim_start_matches('\n')))
156        }
157    };
158    let after = match &e.section {
159        Some(name) => sections::extract(&body, name).unwrap_or_default(),
160        None => body.trim().to_string(),
161    };
162    let rewritten = edit::rewrite(&format!("{head}{body}"), |m| {
163        m.insert("updated".into(), Value::String(stamp::now_value()));
164    })?;
165    let mut plan = Plan::default();
166    if rewritten != text {
167        plan.write(&d.path, rewritten, false);
168    }
169    Ok(Outcome { plan, target, before, after })
170}