Skip to main content

dbmd_core/
edit.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Body and section editing — the corpus-write primitives behind
4//! `dbmd body set/append` and `dbmd section set/append`.
5//!
6//! Everything here is a pure `&str -> String` transformation over a file's
7//! markdown BODY (the verbatim text after the frontmatter block); reading the
8//! file, the frozen-page policy, the `updated` re-stamp, the atomic write,
9//! and the index write-through all belong to the caller (the CLI bodies),
10//! exactly as with every other mutation.
11//!
12//! Section addressing shares the extractor's boundary rule via
13//! [`parser::extract_section_spans`] — a section runs from its heading line
14//! to the next heading at an equal-or-shallower level (an `# H1` terminates a
15//! span without being a section), fenced code blocks hide headings, and the
16//! span text is verbatim. Replacing a section therefore replaces its whole
17//! subtree (deeper `###…` sub-sections included), which is the same unit the
18//! read views (`sections`, `outline`, `section get`) present.
19//!
20//! Newline discipline: section edits are STRUCTURAL — inserted content is
21//! newline-terminated so a following heading always starts on its own line —
22//! while [`append_body`] is RAW (the joint gains a newline when the existing
23//! body lacks one, the appended content itself rides verbatim). `dbmd body
24//! set` is rawer still and does not come through here: the new body is stored
25//! exactly as given.
26
27use crate::parser::{extract_section_spans, SectionSpan};
28
29/// A section-addressing failure. Whole-body operations cannot fail.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum EditError {
32    /// No section carries the requested heading text.
33    SectionNotFound {
34        /// The heading text that matched nothing.
35        heading: String,
36    },
37    /// More than one section carries the requested heading text — the address
38    /// is ambiguous and the edit refuses rather than guessing.
39    SectionAmbiguous {
40        /// The heading text that matched more than once.
41        heading: String,
42        /// The 1-based body lines of every match.
43        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/// The result of a section edit: the new body plus where the edited (or
69/// created) section sits — `line` is 1-based within the body, the
70/// [`parser::Section::line`] frame.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct SectionEdit {
73    /// The whole new body.
74    pub body: String,
75    /// The edited section's heading level (2–6).
76    pub level: u8,
77    /// The edited section's heading line, 1-based within the body.
78    pub line: u32,
79}
80
81/// Find the one section addressed by `heading` (exact text match on the
82/// extracted heading, surrounding whitespace trimmed from the query).
83pub 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
101/// Replace the addressed section's content — everything under its heading
102/// line to the span end, deeper sub-sections included — with `content`. The
103/// heading line itself is preserved byte-for-byte (gaining a terminating
104/// newline only when unterminated content must follow it).
105pub 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
132/// Append `content` at the end of the addressed section (before the next
133/// sibling-or-shallower heading), newline-terminated.
134pub 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
157/// Append a NEW section at the end of the body: one separating blank line,
158/// the `#`-run heading at `level` (2–6), then the newline-terminated content.
159pub 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
183/// Append raw `content` at the end of the body. The joint gains a newline
184/// when the existing body lacks one; the content itself rides verbatim.
185pub 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
195/// Newline-terminate a non-empty block (structural section content must not
196/// swallow whatever follows it); empty content stays empty.
197fn 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    /// An `# H1` line terminates a section span without being a section — the
266    /// extractor's rule, which the splice must share or an edit would swallow
267    /// the H1.
268    #[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    /// A `## heading` inside a fenced code block is content, not an address
276    /// and not a boundary.
277    #[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    /// A heading line at EOF without a trailing newline gains one only when
306    /// content must follow it.
307    #[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}