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 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 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
121fn 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
151fn plan_row(d: &Doc, head: &str, lines: &[&str], body: &str, section: Option<&str>, ch: &RowChange) -> Result<Outcome> {
153 let (lo, hi, target) = match §ion {
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
175fn 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
225fn 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
241fn 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}