1use 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 Replace,
16 Append,
18}
19
20#[derive(Debug, Clone)]
21pub struct Edit {
22 pub section: Option<String>,
23 pub new_section: Option<String>,
26 pub after: Option<String>,
27 pub mode: Mode,
28 pub text: String,
29 pub row: Option<RowChange>,
31}
32
33#[derive(Debug, Clone)]
35pub struct Outcome {
36 pub plan: Plan,
37 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 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}