1use 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 Replace,
17 Append,
19}
20
21#[derive(Debug, Clone)]
22pub struct Edit {
23 pub section: Option<String>,
24 pub new_section: Option<String>,
27 pub after: Option<String>,
28 pub mode: Mode,
29 pub text: String,
30 pub row: Option<RowChange>,
32}
33
34#[derive(Debug, Clone)]
36pub struct Outcome {
37 pub plan: Plan,
38 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 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
124fn 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
154fn plan_row(d: &Doc, head: &str, lines: &[&str], body: &str, section: Option<&str>, ch: &RowChange) -> Result<Outcome> {
156 let (lo, hi, target) = match §ion {
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
178fn 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
228fn 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
244fn 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}