1use crate::parser::{extract_section_spans, SectionSpan};
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum EditError {
32 SectionNotFound {
34 heading: String,
36 },
37 SectionAmbiguous {
40 heading: String,
42 lines: Vec<u32>,
44 },
45}
46
47impl std::fmt::Display for EditError {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 match self {
50 EditError::SectionNotFound { heading } => {
51 write!(f, "no section with heading `{heading}`")
52 }
53 EditError::SectionAmbiguous { heading, lines } => {
54 let lines: Vec<String> = lines.iter().map(|l| format!("L{l}")).collect();
55 write!(
56 f,
57 "heading `{heading}` matches {} sections ({})",
58 lines.len(),
59 lines.join(", ")
60 )
61 }
62 }
63 }
64}
65
66impl std::error::Error for EditError {}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct SectionEdit {
73 pub body: String,
75 pub level: u8,
77 pub line: u32,
79}
80
81pub fn find_section<'a>(
84 spans: &'a [SectionSpan],
85 heading: &str,
86) -> Result<&'a SectionSpan, EditError> {
87 let want = heading.trim();
88 let matches: Vec<&SectionSpan> = spans.iter().filter(|s| s.section.heading == want).collect();
89 match matches.as_slice() {
90 [] => Err(EditError::SectionNotFound {
91 heading: want.to_string(),
92 }),
93 [one] => Ok(one),
94 many => Err(EditError::SectionAmbiguous {
95 heading: want.to_string(),
96 lines: many.iter().map(|s| s.section.line).collect(),
97 }),
98 }
99}
100
101pub fn replace_section(body: &str, heading: &str, content: &str) -> Result<SectionEdit, EditError> {
106 let spans = extract_section_spans(body);
107 let target = find_section(&spans, heading)?;
108 let (start, end, level, line) = (
109 target.start,
110 target.end,
111 target.section.level,
112 target.section.line,
113 );
114 let lines: Vec<&str> = body.split_inclusive('\n').collect();
115
116 let mut out = String::with_capacity(body.len() + content.len());
117 out.push_str(&lines[..start].concat());
118 let heading_line = lines[start];
119 out.push_str(heading_line);
120 if !heading_line.ends_with('\n') && !content.is_empty() {
121 out.push('\n');
122 }
123 out.push_str(&terminated(content));
124 out.push_str(&lines[end..].concat());
125 Ok(SectionEdit {
126 body: out,
127 level,
128 line,
129 })
130}
131
132pub fn append_to_section(
135 body: &str,
136 heading: &str,
137 content: &str,
138) -> Result<SectionEdit, EditError> {
139 let spans = extract_section_spans(body);
140 let target = find_section(&spans, heading)?;
141 let (end, level, line) = (target.end, target.section.level, target.section.line);
142 let lines: Vec<&str> = body.split_inclusive('\n').collect();
143
144 let mut out = lines[..end].concat();
145 if !out.ends_with('\n') && !content.is_empty() {
146 out.push('\n');
147 }
148 out.push_str(&terminated(content));
149 out.push_str(&lines[end..].concat());
150 Ok(SectionEdit {
151 body: out,
152 level,
153 line,
154 })
155}
156
157pub fn append_section(body: &str, heading: &str, level: u8, content: &str) -> SectionEdit {
160 let mut out = String::with_capacity(body.len() + heading.len() + content.len() + 16);
161 out.push_str(body);
162 if !out.is_empty() {
163 if !out.ends_with('\n') {
164 out.push('\n');
165 }
166 if !out.ends_with("\n\n") {
167 out.push('\n');
168 }
169 }
170 let line = (out.split_inclusive('\n').count() + 1) as u32;
171 out.push_str(&"#".repeat(usize::from(level)));
172 out.push(' ');
173 out.push_str(heading.trim());
174 out.push('\n');
175 out.push_str(&terminated(content));
176 SectionEdit {
177 body: out,
178 level,
179 line,
180 }
181}
182
183pub fn append_body(body: &str, content: &str) -> String {
186 let mut out = String::with_capacity(body.len() + content.len() + 1);
187 out.push_str(body);
188 if !out.is_empty() && !out.ends_with('\n') && !content.is_empty() {
189 out.push('\n');
190 }
191 out.push_str(content);
192 out
193}
194
195fn terminated(content: &str) -> String {
198 if content.is_empty() || content.ends_with('\n') {
199 content.to_string()
200 } else {
201 format!("{content}\n")
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 const BODY: &str = "\
210intro paragraph
211
212## Status
213active since May
214detail line
215
216### Sub-note
217nested content
218
219## Log
220- entry one
221";
222
223 #[test]
224 fn replace_replaces_the_whole_subtree() {
225 let edited = replace_section(BODY, "Status", "replaced\n").unwrap();
226 assert_eq!(
227 edited.body,
228 "intro paragraph\n\n## Status\nreplaced\n## Log\n- entry one\n"
229 );
230 assert_eq!(edited.level, 2);
231 assert_eq!(edited.line, 3);
232 }
233
234 #[test]
235 fn replace_targets_a_subsection_alone() {
236 let edited = replace_section(BODY, "Sub-note", "tightened\n").unwrap();
237 assert_eq!(
238 edited.body,
239 "intro paragraph\n\n## Status\nactive since May\ndetail line\n\n### Sub-note\ntightened\n## Log\n- entry one\n"
240 );
241 assert_eq!(edited.level, 3);
242 }
243
244 #[test]
245 fn replace_with_empty_content_leaves_heading_only() {
246 let edited = replace_section(BODY, "Log", "").unwrap();
247 assert!(edited.body.ends_with("## Log\n"));
248 }
249
250 #[test]
251 fn append_lands_before_the_next_sibling() {
252 let edited = append_to_section(BODY, "Status", "- appended").unwrap();
253 assert_eq!(
254 edited.body,
255 "intro paragraph\n\n## Status\nactive since May\ndetail line\n\n### Sub-note\nnested content\n\n- appended\n## Log\n- entry one\n"
256 );
257 }
258
259 #[test]
260 fn append_at_eof_terminates_cleanly() {
261 let edited = append_to_section(BODY, "Log", "- entry two").unwrap();
262 assert!(edited.body.ends_with("## Log\n- entry one\n- entry two\n"));
263 }
264
265 #[test]
269 fn h1_terminates_the_span() {
270 let body = "## Notes\nold\n# Title\nafter\n";
271 let edited = replace_section(body, "Notes", "new\n").unwrap();
272 assert_eq!(edited.body, "## Notes\nnew\n# Title\nafter\n");
273 }
274
275 #[test]
278 fn fenced_headings_are_invisible() {
279 let body = "## Real\n```\n## Fake\n```\ntail\n";
280 assert!(matches!(
281 replace_section(body, "Fake", "x"),
282 Err(EditError::SectionNotFound { .. })
283 ));
284 let edited = replace_section(body, "Real", "gone\n").unwrap();
285 assert_eq!(edited.body, "## Real\ngone\n");
286 }
287
288 #[test]
289 fn duplicate_headings_are_ambiguous() {
290 let body = "## Twice\na\n## Twice\nb\n";
291 match replace_section(body, "Twice", "x") {
292 Err(EditError::SectionAmbiguous { lines, .. }) => assert_eq!(lines, vec![1, 3]),
293 other => panic!("expected ambiguity, got {other:?}"),
294 }
295 }
296
297 #[test]
298 fn missing_heading_is_not_found() {
299 assert!(matches!(
300 append_to_section(BODY, "Nope", "x"),
301 Err(EditError::SectionNotFound { .. })
302 ));
303 }
304
305 #[test]
308 fn unterminated_heading_line_edges() {
309 let body = "## End";
310 let edited = replace_section(body, "End", "x").unwrap();
311 assert_eq!(edited.body, "## End\nx\n");
312 let untouched = replace_section(body, "End", "").unwrap();
313 assert_eq!(untouched.body, "## End");
314 }
315
316 #[test]
317 fn append_section_separates_with_one_blank_line() {
318 let edited = append_section("existing\n", "Fresh", 2, "content");
319 assert_eq!(edited.body, "existing\n\n## Fresh\ncontent\n");
320 assert_eq!(edited.line, 3);
321
322 let on_empty = append_section("", "Fresh", 3, "content\n");
323 assert_eq!(on_empty.body, "### Fresh\ncontent\n");
324 assert_eq!(on_empty.line, 1);
325
326 let already_spaced = append_section("existing\n\n", "Fresh", 2, "");
327 assert_eq!(already_spaced.body, "existing\n\n## Fresh\n");
328 }
329
330 #[test]
331 fn append_body_is_raw_with_a_safe_joint() {
332 assert_eq!(append_body("a\n", "b"), "a\nb");
333 assert_eq!(append_body("a", "b\n"), "a\nb\n");
334 assert_eq!(append_body("", "b"), "b");
335 assert_eq!(append_body("a\n", ""), "a\n");
336 }
337
338 #[test]
339 fn find_section_trims_the_query_only() {
340 let spans = extract_section_spans(BODY);
341 assert!(find_section(&spans, " Status ").is_ok());
342 assert!(find_section(&spans, "status").is_err());
343 }
344}