handoff-mcp-server 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ //! v5 (spec §3.1): a document's `.md` file *is* the original body, so
2
+ //! reassembly from physical fragment files is no longer needed. What
3
+ //! remains useful is byte-offset section slicing: given a document's full
4
+ //! body and a [`SectionIndex`](super::model::SectionIndex) entry (as
5
+ //! computed by [`super::split::compute_sections`]), extract just that
6
+ //! section's text.
7
+
8
+ use anyhow::{bail, Result};
9
+
10
+ use super::model::SectionIndex;
11
+
12
+ /// Concatenates `fragment_bodies` (already in order) into a single `String`.
13
+ /// Kept as a small utility for callers that still hold a list of body slices
14
+ /// (e.g. `split::SplitDocument::fragments`) and want to reconstruct the full
15
+ /// body without going through file I/O.
16
+ pub fn reassemble(fragment_bodies: &[&str]) -> String {
17
+ fragment_bodies.concat()
18
+ }
19
+
20
+ /// Extracts one section's text from `body` by byte-offset slice, per
21
+ /// `section.byte_offset`/`section.byte_length`.
22
+ ///
23
+ /// `body` must be the same byte sequence the section indexes were computed
24
+ /// against (i.e. the document body after BOM/frontmatter stripping — the
25
+ /// same body persisted to `_doc.<slug>.md`). Since `body` is read fresh from
26
+ /// disk on every call, it can drift out from under `section` (edited
27
+ /// out-of-band, e.g. truncated) between when `sections` was computed and
28
+ /// when this is called — so this returns `Err` rather than panicking:
29
+ ///
30
+ /// - `Err` if `section`'s byte range doesn't fit within `body` at all (would
31
+ /// otherwise panic on slice-index-out-of-bounds), or lands on a non-UTF8
32
+ /// char boundary.
33
+ /// - `Err` if the range fits but `section.content_hash` doesn't match the
34
+ /// hash of the extracted slice (body changed but still long enough to
35
+ /// slice) — this is the drift check the doc comment used to merely
36
+ /// recommend callers perform themselves; it's now built in so every
37
+ /// caller gets it for free.
38
+ pub fn extract_section<'a>(body: &'a str, section: &SectionIndex) -> Result<&'a str> {
39
+ let end = section
40
+ .byte_offset
41
+ .checked_add(section.byte_length)
42
+ .filter(|&end| end <= body.len())
43
+ .ok_or_else(|| {
44
+ anyhow::anyhow!(
45
+ "section seq={} byte range [{}, {}) is out of bounds for body of length {} \
46
+ (document body has drifted since sections were indexed)",
47
+ section.seq,
48
+ section.byte_offset,
49
+ section.byte_offset + section.byte_length,
50
+ body.len()
51
+ )
52
+ })?;
53
+ if !body.is_char_boundary(section.byte_offset) || !body.is_char_boundary(end) {
54
+ bail!(
55
+ "section seq={} byte range [{}, {}) does not fall on a UTF-8 character \
56
+ boundary in the current body (document body has drifted)",
57
+ section.seq,
58
+ section.byte_offset,
59
+ end
60
+ );
61
+ }
62
+ let slice = &body[section.byte_offset..end];
63
+ let actual_hash = lexsim::content_hash(slice);
64
+ if actual_hash != section.content_hash {
65
+ bail!(
66
+ "section seq={} content_hash mismatch: expected {}, got {} \
67
+ (document body has drifted since sections were indexed)",
68
+ section.seq,
69
+ section.content_hash,
70
+ actual_hash
71
+ );
72
+ }
73
+ Ok(slice)
74
+ }
75
+
76
+ #[cfg(test)]
77
+ mod tests {
78
+ use super::*;
79
+
80
+ #[test]
81
+ fn reassemble_concatenates_bodies() {
82
+ assert_eq!(reassemble(&["a", "b", "c"]), "abc");
83
+ assert_eq!(reassemble(&[]), "");
84
+ }
85
+
86
+ #[test]
87
+ fn extract_section_slices_by_byte_offset() {
88
+ let body = "Preamble.\n## A\nBody A\n";
89
+ let section = SectionIndex {
90
+ seq: 1,
91
+ heading: "A".to_string(),
92
+ level: 2,
93
+ byte_offset: 10,
94
+ byte_length: "## A\nBody A\n".len(),
95
+ content_hash: lexsim::content_hash("## A\nBody A\n"),
96
+ };
97
+ assert_eq!(extract_section(body, &section).unwrap(), "## A\nBody A\n");
98
+ }
99
+
100
+ #[test]
101
+ fn extract_section_at_start_of_body() {
102
+ let body = "Preamble text.\n";
103
+ let section = SectionIndex {
104
+ seq: 0,
105
+ heading: String::new(),
106
+ level: 0,
107
+ byte_offset: 0,
108
+ byte_length: body.len(),
109
+ content_hash: lexsim::content_hash(body),
110
+ };
111
+ assert_eq!(extract_section(body, &section).unwrap(), body);
112
+ }
113
+
114
+ /// Regression test for a MAJOR bug found in review: `extract_section`
115
+ /// used to slice `body[byte_offset..byte_offset+byte_length]`
116
+ /// unconditionally, which panics with a slice-bounds error when `body`
117
+ /// has drifted (e.g. truncated by an out-of-band edit) so it's shorter
118
+ /// than the section's recorded range. Both `doc_get(format=section)` and
119
+ /// `doc_query` call this with a `body` freshly read from disk on every
120
+ /// request, so a panic here broke the JSON-RPC contract silently (no
121
+ /// response ever sent for that request). Must return `Err`, not panic.
122
+ #[test]
123
+ fn extract_section_errors_when_body_truncated_shorter_than_range() {
124
+ let full_body = "## A\nBody A that is fairly long\n";
125
+ let section = SectionIndex {
126
+ seq: 1,
127
+ heading: "A".to_string(),
128
+ level: 2,
129
+ byte_offset: 0,
130
+ byte_length: full_body.len(),
131
+ content_hash: lexsim::content_hash(full_body),
132
+ };
133
+ // Simulate drift: body on disk was truncated independently of the
134
+ // stored section index.
135
+ let drifted_body = "## A\n";
136
+ let result = extract_section(drifted_body, &section);
137
+ assert!(result.is_err(), "expected Err, got {result:?}");
138
+ }
139
+
140
+ /// Companion drift case: body is long enough to slice without an
141
+ /// out-of-bounds panic, but the content at that range no longer matches
142
+ /// what was indexed (edited in place). Must be caught by the
143
+ /// `content_hash` check, not silently return stale/wrong text.
144
+ #[test]
145
+ fn extract_section_errors_when_body_edited_in_place_hash_mismatch() {
146
+ let original = "## A\nOriginal body\n";
147
+ let section = SectionIndex {
148
+ seq: 1,
149
+ heading: "A".to_string(),
150
+ level: 2,
151
+ byte_offset: 0,
152
+ byte_length: original.len(),
153
+ content_hash: lexsim::content_hash(original),
154
+ };
155
+ let edited = "## A\nEditedd body\n";
156
+ assert_eq!(
157
+ edited.len(),
158
+ original.len(),
159
+ "test fixture must keep byte_length identical to isolate the hash check"
160
+ );
161
+ let result = extract_section(edited, &section);
162
+ assert!(
163
+ result.is_err(),
164
+ "expected Err on hash mismatch, got {result:?}"
165
+ );
166
+ }
167
+ }
@@ -0,0 +1,377 @@
1
+ //! Splits a single authored Markdown body into byte-exact fragments at ATX
2
+ //! heading boundaries, using `pulldown-cmark`'s byte-offset event stream so
3
+ //! that `#`/`##` inside fenced code blocks or block quotes is never
4
+ //! misdetected as a heading (see wiki/130-document-management.md §5.1 "M1").
5
+ //!
6
+ //! The split is purely byte-slicing: heading lines are never re-rendered, so
7
+ //! [`reassemble`](super::reassemble::reassemble) can reconstruct the original
8
+ //! document exactly by concatenating fragment bodies in `seq` order.
9
+
10
+ use anyhow::{bail, Result};
11
+ use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag};
12
+
13
+ use super::model::SectionIndex;
14
+
15
+ const BOM: &str = "\u{FEFF}";
16
+
17
+ /// Default ATX heading level at which a document is split into fragments
18
+ /// (`##` = level 2). Overridable per call and, once P1-2 storage/config
19
+ /// wiring lands, per `config.toml` `doc_split_level`.
20
+ pub const DEFAULT_SPLIT_LEVEL: u8 = 2;
21
+
22
+ /// One fragment produced by [`split`]. `body` is a raw byte slice of the
23
+ /// input `body` string passed to `split` — it is never re-rendered, so
24
+ /// concatenating all fragments' `body` in `seq` order reproduces the
25
+ /// original document (minus a stripped BOM/frontmatter, which `split`
26
+ /// reports separately for the caller to persist).
27
+ #[derive(Debug, Clone, PartialEq, Eq)]
28
+ pub struct SplitFragment<'a> {
29
+ /// 0-based position in the document. seq 0 is always the preamble
30
+ /// (text before the first heading at or above `split_level`), even if
31
+ /// empty.
32
+ pub seq: usize,
33
+ /// The heading text (without the leading `#` markers or surrounding
34
+ /// whitespace) that starts this fragment, or `None` for the seq-0
35
+ /// preamble when the document has no heading before it.
36
+ pub heading: Option<String>,
37
+ /// ATX heading level (1-6) that starts this fragment, or 0 for the
38
+ /// seq-0 preamble.
39
+ pub level: u8,
40
+ /// Raw byte slice of this fragment's body, including its own leading
41
+ /// heading line (if any) verbatim.
42
+ pub body: &'a str,
43
+ }
44
+
45
+ /// Result of [`split`]: the fragment list plus document-level facts needed
46
+ /// to persist and losslessly reassemble the original body (BOM presence,
47
+ /// line-ending style, extracted frontmatter).
48
+ #[derive(Debug, Clone, PartialEq, Eq)]
49
+ pub struct SplitDocument<'a> {
50
+ /// Fragments in `seq` order, covering the document body *after* the BOM
51
+ /// and frontmatter (if any) have been stripped out of `fragments[0]`.
52
+ pub fragments: Vec<SplitFragment<'a>>,
53
+ /// `true` if `body` started with a UTF-8 BOM (`\u{FEFF}`).
54
+ pub has_bom: bool,
55
+ /// `"lf"` or `"crlf"`, detected from the first line ending encountered.
56
+ /// A document with no line endings at all (single line) is `"lf"`.
57
+ pub line_ending: &'static str,
58
+ /// The raw YAML frontmatter block (between the `---` fences, exclusive),
59
+ /// if the document starts with one. Excluded from `fragments[0].body`
60
+ /// per spec §5.1 scope rule 6.
61
+ pub frontmatter: Option<&'a str>,
62
+ /// `true` if the original document had a line ending immediately after
63
+ /// the closing `---` fence (e.g. `"---\ntitle: Foo\n---\nbody"`), `false`
64
+ /// if the closing fence was the last thing in the document (e.g.
65
+ /// `"---\ntitle: Foo\n---"` with nothing after it, not even a newline).
66
+ /// Meaningless when `frontmatter` is `None`. Callers reassembling the
67
+ /// document must conditionally omit the eol after `---` when this is
68
+ /// `false`, or they will invent a byte that was never in the original
69
+ /// body (see `extract_frontmatter` doc comment for why the eol cannot be
70
+ /// inferred from `frontmatter`/`fragments[0]` alone).
71
+ pub frontmatter_trailing_eol: bool,
72
+ }
73
+
74
+ /// Splits `body` into fragments at ATX heading boundaries of `split_level`
75
+ /// or higher (i.e. `level <= split_level`, since `H1` < `H2` numerically).
76
+ ///
77
+ /// Returns `Err` if `body` mixes LF and CRLF line endings (spec §5.1: mixed
78
+ /// CRLF is rejected outright rather than guessed at).
79
+ pub fn split(body: &str, split_level: u8) -> Result<SplitDocument<'_>> {
80
+ let line_ending = detect_line_ending(body)?;
81
+
82
+ let has_bom = body.starts_with(BOM);
83
+ let after_bom = if has_bom { &body[BOM.len()..] } else { body };
84
+
85
+ let (frontmatter, after_frontmatter, frontmatter_byte_len, frontmatter_trailing_eol) =
86
+ extract_frontmatter(after_bom, line_ending);
87
+
88
+ let heading_bounds = collect_heading_bounds(after_frontmatter, split_level);
89
+
90
+ let mut fragments = Vec::with_capacity(heading_bounds.len() + 1);
91
+
92
+ // seq 0: preamble before the first qualifying heading (possibly empty).
93
+ let first_start = heading_bounds
94
+ .first()
95
+ .map(|h| h.start)
96
+ .unwrap_or(after_frontmatter.len());
97
+ fragments.push(SplitFragment {
98
+ seq: 0,
99
+ heading: None,
100
+ level: 0,
101
+ body: &after_frontmatter[..first_start],
102
+ });
103
+ let mut cursor = first_start;
104
+
105
+ for (i, bound) in heading_bounds.iter().enumerate() {
106
+ let end = heading_bounds
107
+ .get(i + 1)
108
+ .map(|next| next.start)
109
+ .unwrap_or(after_frontmatter.len());
110
+ fragments.push(SplitFragment {
111
+ seq: i + 1,
112
+ heading: Some(bound.text.clone()),
113
+ level: bound.level,
114
+ body: &after_frontmatter[cursor..end],
115
+ });
116
+ cursor = end;
117
+ }
118
+
119
+ // Frontmatter length is reported to the caller via the returned struct;
120
+ // it is intentionally not folded back into any fragment body (spec
121
+ // §5.1 scope rule 6: frontmatter goes to `source.frontmatter`, not
122
+ // fragment seq 0).
123
+ let _ = frontmatter_byte_len;
124
+
125
+ Ok(SplitDocument {
126
+ fragments,
127
+ has_bom,
128
+ line_ending,
129
+ frontmatter,
130
+ frontmatter_trailing_eol,
131
+ })
132
+ }
133
+
134
+ /// Converts a [`SplitDocument`]'s fragments into a [`SectionIndex`] manifest
135
+ /// (v5, spec §3.1): one entry per fragment, with a cumulative `byte_offset`
136
+ /// into the *body as reported by `split`* (i.e. after BOM/frontmatter
137
+ /// stripping — the same body callers persist to `_doc.<slug>.md`'s section
138
+ /// range), `byte_length`, and a content hash of each fragment's body slice.
139
+ /// Performs no file I/O — this is purely an in-memory transform.
140
+ pub fn compute_sections(split_doc: &SplitDocument<'_>) -> Vec<SectionIndex> {
141
+ let mut offset = 0usize;
142
+ split_doc
143
+ .fragments
144
+ .iter()
145
+ .map(|frag| {
146
+ let section = SectionIndex {
147
+ seq: frag.seq,
148
+ heading: frag.heading.clone().unwrap_or_default(),
149
+ level: frag.level,
150
+ byte_offset: offset,
151
+ byte_length: frag.body.len(),
152
+ content_hash: lexsim::content_hash(frag.body),
153
+ };
154
+ offset += frag.body.len();
155
+ section
156
+ })
157
+ .collect()
158
+ }
159
+
160
+ /// A single heading boundary: byte offset (into the frontmatter-stripped
161
+ /// body) where the heading line starts, its level, and its trimmed text.
162
+ struct HeadingBound {
163
+ start: usize,
164
+ level: u8,
165
+ text: String,
166
+ }
167
+
168
+ /// Runs the pulldown-cmark offset-tracking parser and collects the byte
169
+ /// start of every ATX heading whose level is `<= split_level`. Fenced code
170
+ /// blocks and block quotes are handled by the parser itself, so a `##`
171
+ /// inside a fence never appears here.
172
+ fn collect_heading_bounds(text: &str, split_level: u8) -> Vec<HeadingBound> {
173
+ let parser = Parser::new_ext(text, Options::all());
174
+ let mut bounds = Vec::new();
175
+ let mut in_qualifying_heading: Option<(usize, u8)> = None;
176
+ let mut heading_text = String::new();
177
+
178
+ for (event, range) in parser.into_offset_iter() {
179
+ match event {
180
+ Event::Start(Tag::Heading { level, .. }) => {
181
+ let numeric_level = heading_level_to_u8(level);
182
+ if numeric_level <= split_level && is_atx_heading(text, &range) {
183
+ in_qualifying_heading = Some((range.start, numeric_level));
184
+ heading_text.clear();
185
+ }
186
+ }
187
+ Event::Text(ref t) | Event::Code(ref t) if in_qualifying_heading.is_some() => {
188
+ heading_text.push_str(t);
189
+ }
190
+ Event::End(pulldown_cmark::TagEnd::Heading(_)) => {
191
+ if let Some((start, level)) = in_qualifying_heading.take() {
192
+ bounds.push(HeadingBound {
193
+ start,
194
+ level,
195
+ text: heading_text.trim().to_string(),
196
+ });
197
+ }
198
+ }
199
+ _ => {}
200
+ }
201
+ }
202
+
203
+ bounds
204
+ }
205
+
206
+ /// Returns `true` if the heading `range` (as reported by pulldown-cmark's
207
+ /// offset iterator) begins with a literal `#` in the source `text`.
208
+ ///
209
+ /// pulldown-cmark emits the identical `Event::Start(Tag::Heading { level,
210
+ /// .. })` for both ATX (`## Foo`) and setext (`Foo\n---`) headings, with no
211
+ /// syntax discriminator on the event itself. Per spec §5.1 scope restriction,
212
+ /// setext headings must never be treated as split boundaries (they stay
213
+ /// embedded in the enclosing fragment's body), so this raw-byte check is the
214
+ /// only reliable way to reject them: an ATX heading's byte range always
215
+ /// starts at the line's leading `#`, whereas a setext heading's range starts
216
+ /// at the title text itself (the underline is a separate, later span).
217
+ fn is_atx_heading(text: &str, range: &std::ops::Range<usize>) -> bool {
218
+ text.as_bytes().get(range.start).is_some_and(|&b| b == b'#')
219
+ }
220
+
221
+ fn heading_level_to_u8(level: HeadingLevel) -> u8 {
222
+ match level {
223
+ HeadingLevel::H1 => 1,
224
+ HeadingLevel::H2 => 2,
225
+ HeadingLevel::H3 => 3,
226
+ HeadingLevel::H4 => 4,
227
+ HeadingLevel::H5 => 5,
228
+ HeadingLevel::H6 => 6,
229
+ }
230
+ }
231
+
232
+ /// Detects the document's line-ending style. Returns an error if both `\r\n`
233
+ /// and a bare `\n` (not preceded by `\r`) appear in the same document.
234
+ fn detect_line_ending(body: &str) -> Result<&'static str> {
235
+ let bytes = body.as_bytes();
236
+ let mut saw_crlf = false;
237
+ let mut saw_lone_lf = false;
238
+
239
+ for (i, &b) in bytes.iter().enumerate() {
240
+ if b == b'\n' {
241
+ let preceded_by_cr = i > 0 && bytes[i - 1] == b'\r';
242
+ if preceded_by_cr {
243
+ saw_crlf = true;
244
+ } else {
245
+ saw_lone_lf = true;
246
+ }
247
+ }
248
+ }
249
+
250
+ if saw_crlf && saw_lone_lf {
251
+ bail!("document mixes CRLF and LF line endings; mixed line endings are not supported");
252
+ }
253
+
254
+ Ok(if saw_crlf { "crlf" } else { "lf" })
255
+ }
256
+
257
+ /// Extracts a leading YAML frontmatter block (`---\n...\n---\n`) using
258
+ /// pulldown-cmark's `MetadataBlock` event, so the same fenced-code-aware
259
+ /// parser that drives heading detection also drives frontmatter detection
260
+ /// (no separate ad hoc regex).
261
+ ///
262
+ /// Returns `(frontmatter_text, remaining_body, frontmatter_byte_len,
263
+ /// trailing_eol_present)`. When there is no frontmatter, returns `(None,
264
+ /// text, 0, false)` unchanged.
265
+ ///
266
+ /// `line_ending` (`"lf"` or `"crlf"`, as already detected from the whole
267
+ /// document by [`detect_line_ending`]) selects the fence delimiter
268
+ /// (`"---\n"` vs `"---\r\n"`) so that a CRLF document's opening fence is
269
+ /// actually recognized instead of falling through to the `unwrap_or(block)`
270
+ /// fallback, which would otherwise leave both `---` fences embedded in the
271
+ /// reported frontmatter and corrupt the byte-identical round trip.
272
+ ///
273
+ /// pulldown-cmark's `MetadataBlock` range ends right at the closing `---`
274
+ /// fence and never includes the line ending that follows it (verified across
275
+ /// LF/CRLF and with/without trailing content): e.g. for
276
+ /// `"---\ntitle: Foo\n---\n# Title\n"`, `range.end` is `18` (just past the
277
+ /// closing `---`), leaving the `\n` at byte 18 unconsumed and still present
278
+ /// at the start of `after_frontmatter`. If left as-is, the caller
279
+ /// (`handle_doc_get`/`handle_doc_list`) which re-adds `---{eol}` after the
280
+ /// frontmatter on reassembly would double that line ending. So this function
281
+ /// consumes one extra `eol` past `range.end` here, when present, to keep
282
+ /// `after_frontmatter` exactly what followed the frontmatter block's own
283
+ /// trailing newline. Whether that extra `eol` was actually present is
284
+ /// reported back as `trailing_eol_present`, since a caller re-adding
285
+ /// `---{eol}` on reassembly cannot otherwise tell a document that ended
286
+ /// exactly at the closing fence (no eol at all) apart from one that had
287
+ /// content following it (see [`SplitDocument::frontmatter_trailing_eol`]).
288
+ fn extract_frontmatter<'a>(
289
+ text: &'a str,
290
+ line_ending: &'static str,
291
+ ) -> (Option<&'a str>, &'a str, usize, bool) {
292
+ let parser = Parser::new_ext(text, Options::all());
293
+ // Only the very first event can be a metadata block (pulldown-cmark only
294
+ // recognizes YAML frontmatter at the start of the document), so a single
295
+ // peek is enough.
296
+ let Some((event, range)) = parser.into_offset_iter().next() else {
297
+ return (None, text, 0, false);
298
+ };
299
+ if !matches!(event, Event::Start(Tag::MetadataBlock(_))) {
300
+ return (None, text, 0, false);
301
+ }
302
+ // `range` for the Start event already covers the whole block
303
+ // (`---\n...\n---`), since pulldown-cmark treats metadata blocks as an
304
+ // atomic (non-nested) event span, but excludes the line ending after the
305
+ // closing fence (see doc comment above).
306
+ let block = &text[range.clone()];
307
+ let eol = if line_ending == "crlf" { "\r\n" } else { "\n" };
308
+ let fence_with_eol = format!("---{eol}");
309
+ let inner = block
310
+ .strip_prefix(fence_with_eol.as_str())
311
+ .and_then(|s| {
312
+ s.strip_suffix(fence_with_eol.as_str())
313
+ .or_else(|| s.strip_suffix("---"))
314
+ })
315
+ .unwrap_or(block);
316
+ let trailing_eol_present = text[range.end..].starts_with(eol);
317
+ let end = if trailing_eol_present {
318
+ range.end + eol.len()
319
+ } else {
320
+ range.end
321
+ };
322
+ (Some(inner), &text[end..], end, trailing_eol_present)
323
+ }
324
+
325
+ #[cfg(test)]
326
+ mod tests {
327
+ use super::*;
328
+
329
+ #[test]
330
+ fn compute_sections_assigns_cumulative_byte_offsets() {
331
+ let body = "Preamble.\n\n## A\nBody A\n## B\nBody B\n";
332
+ let split_doc = split(body, 2).unwrap();
333
+ let sections = compute_sections(&split_doc);
334
+
335
+ assert_eq!(sections.len(), split_doc.fragments.len());
336
+ let mut expected_offset = 0usize;
337
+ for (section, frag) in sections.iter().zip(split_doc.fragments.iter()) {
338
+ assert_eq!(section.seq, frag.seq);
339
+ assert_eq!(section.level, frag.level);
340
+ assert_eq!(section.heading, frag.heading.clone().unwrap_or_default());
341
+ assert_eq!(section.byte_offset, expected_offset);
342
+ assert_eq!(section.byte_length, frag.body.len());
343
+ assert_eq!(section.content_hash, lexsim::content_hash(frag.body));
344
+ expected_offset += frag.body.len();
345
+ }
346
+ }
347
+
348
+ #[test]
349
+ fn compute_sections_offsets_slice_the_split_body_correctly() {
350
+ let body = "Preamble.\n\n## A\nBody A\n## B\nBody B\n";
351
+ let split_doc = split(body, 2).unwrap();
352
+ let sections = compute_sections(&split_doc);
353
+
354
+ // Reconstruct the after-frontmatter body by concatenating fragments,
355
+ // then verify each section's byte_offset/byte_length slices it back
356
+ // out identically to the fragment body it was computed from.
357
+ let full: String = split_doc.fragments.iter().map(|f| f.body).collect();
358
+ for (section, frag) in sections.iter().zip(split_doc.fragments.iter()) {
359
+ let slice = &full[section.byte_offset..section.byte_offset + section.byte_length];
360
+ assert_eq!(slice, frag.body);
361
+ }
362
+ }
363
+
364
+ #[test]
365
+ fn compute_sections_no_headings_is_single_section_at_offset_zero() {
366
+ let body = "Just plain text.\n";
367
+ let split_doc = split(body, 2).unwrap();
368
+ let sections = compute_sections(&split_doc);
369
+
370
+ assert_eq!(sections.len(), 1);
371
+ assert_eq!(sections[0].seq, 0);
372
+ assert_eq!(sections[0].byte_offset, 0);
373
+ assert_eq!(sections[0].byte_length, body.len());
374
+ assert_eq!(sections[0].heading, "");
375
+ assert_eq!(sections[0].level, 0);
376
+ }
377
+ }
@@ -1,4 +1,5 @@
1
1
  pub mod config;
2
+ pub mod docs;
2
3
  pub mod git;
3
4
  pub mod memory;
4
5
  pub mod referrals;