hallouminate 0.2.2

A markdown corpus indexer for LLMs to build and query their own per-repo wikis.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
//! Pure splice / replace helpers for `add_markdown` section-scoped writes.
//!
//! No I/O — callers pass the existing file text and receive composed text.
//! All three functions produce byte-identical output for the unmodified
//! regions, so a splice/replace is indistinguishable from a whole-file
//! rewrite at the indexer layer.

use pulldown_cmark::{Event, OffsetIter, Parser, Tag};

// ── Shared types ──────────────────────────────────────────────────────────────

/// Where to splice within a section when `under_heading` is set.
#[derive(
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
    serde::Serialize,
    serde::Deserialize,
    schemars::JsonSchema,
    Default,
)]
#[serde(rename_all = "lowercase")]
pub enum Position {
    /// Splice just before the next same-or-higher heading (or EOF).
    #[default]
    Append,
    /// Splice just after the matched heading line, before existing body.
    Prepend,
}

/// 1-based inclusive line range for `replace_lines`. `start` and `end` are
/// both 1-based; the range covers lines `start..=end`. `start == end` replaces
/// a single line.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub struct LineRange {
    pub start: usize,
    pub end: usize,
}

// ── Error types ───────────────────────────────────────────────────────────────

#[derive(Debug, PartialEq, Eq)]
pub enum SectionError {
    NotFound,
    Duplicate,
}

impl std::fmt::Display for SectionError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SectionError::NotFound => write!(f, "heading not found"),
            SectionError::Duplicate => write!(f, "heading ambiguous"),
        }
    }
}

#[derive(Debug, PartialEq, Eq)]
pub enum RangeError {
    OutOfRange,
    Inverted,
}

impl std::fmt::Display for RangeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RangeError::OutOfRange => write!(f, "line range out of range"),
            RangeError::Inverted => write!(f, "start > end"),
        }
    }
}

#[derive(Debug, PartialEq, Eq)]
pub enum MatchError {
    NotFound,
    Ambiguous(usize),
}

impl std::fmt::Display for MatchError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            MatchError::NotFound => write!(f, "match not found"),
            MatchError::Ambiguous(n) => write!(f, "match ambiguous \u{2014} {n} occurrences"),
        }
    }
}

// ── normalize_block ──────────────────────────────────────────────────────────

/// Ensure the fragment is surrounded by blank lines so it never fuses onto
/// an adjacent line or heading. Specifically:
/// - A leading `\n` is prepended so a splice never attaches the fragment's
///   first line to the preceding heading or paragraph.
/// - A trailing `\n\n` is appended so the next heading / body starts on
///   its own line.
fn normalize_block(fragment: &str) -> String {
    let trimmed = fragment.trim_end_matches('\n');
    format!("\n{trimmed}\n\n")
}

// ── Mode 2: section splice ───────────────────────────────────────────────────

/// Splice `fragment` into the section under `heading` (matched by rendered
/// heading text, trimmed). Returns the full composed document.
///
/// - `position == Append` → insert just before the next same-or-higher
///   heading (or EOF).
/// - `position == Prepend` → insert just after the heading line.
///
/// Errors:
/// - [`SectionError::NotFound`] when no heading matches.
/// - [`SectionError::Duplicate`] when more than one heading matches.
pub fn splice_under_heading(
    doc: &str,
    heading: &str,
    position: Position,
    fragment: &str,
) -> Result<String, SectionError> {
    // Each entry: (level_u8, heading_start_byte, byte_after_heading_line)
    let mut headings: Vec<(u8, usize, usize)> = Vec::new();
    let mut match_indices: Vec<usize> = Vec::new();

    let mut iter: OffsetIter<'_> = Parser::new(doc).into_offset_iter();
    while let Some((event, range)) = iter.next() {
        let level = match &event {
            Event::Start(Tag::Heading { level, .. }) => heading_level_u8(*level),
            _ => continue,
        };
        // Collect rendered heading text by consuming through End(Heading).
        let title = collect_heading_text(&mut iter);
        // `range.end` is the byte offset past the closing of the heading
        // node per pulldown-cmark's offset iterator contract.
        // For ATX headings this is the end of the `# …\n` line.
        // For setext headings this is the end of the underline line.
        let h_start = range.start;
        let h_line_end = range.end;
        let idx = headings.len();
        headings.push((level, h_start, h_line_end));
        if title.trim() == heading.trim() {
            match_indices.push(idx);
        }
    }

    match match_indices.len() {
        0 => return Err(SectionError::NotFound),
        1 => {}
        _ => return Err(SectionError::Duplicate),
    }

    let matched = match_indices[0];
    let (level, _h_start, h_line_end) = headings[matched];

    // Section ends at the first following heading at the same or higher level
    // (lower or equal numeric level value), otherwise at EOF.
    let section_end = headings[matched + 1..]
        .iter()
        .find(|(lvl, _, _)| *lvl <= level)
        .map(|(_, start, _)| *start)
        .unwrap_or(doc.len());

    let insert_at = match position {
        Position::Prepend => h_line_end,
        Position::Append => section_end,
    };

    let block = normalize_block(fragment);
    Ok(format!(
        "{}{}{}",
        &doc[..insert_at],
        block,
        &doc[insert_at..]
    ))
}

use super::collect_heading_text;

/// Map pulldown-cmark's `HeadingLevel` to a `u8` where lower == higher level
/// in the document hierarchy (H1 == 1).
fn heading_level_u8(level: pulldown_cmark::HeadingLevel) -> u8 {
    use pulldown_cmark::HeadingLevel;
    match level {
        HeadingLevel::H1 => 1,
        HeadingLevel::H2 => 2,
        HeadingLevel::H3 => 3,
        HeadingLevel::H4 => 4,
        HeadingLevel::H5 => 5,
        HeadingLevel::H6 => 6,
    }
}

// ── Mode 3: line-range replace ───────────────────────────────────────────────

/// Replace lines `[range.start, range.end]` (1-based, inclusive) with `body`.
/// `body` is spliced with a single trailing newline (terminator hygiene): no
/// surrounding blank lines are injected, so table rows and list items edit
/// surgically without breaking CommonMark structure.
///
/// Errors:
/// - [`RangeError::OutOfRange`] when `start == 0`, `end == 0`, or
///   `end > line_count`.
/// - [`RangeError::Inverted`] when `start > end`.
pub fn replace_line_range(doc: &str, range: LineRange, body: &str) -> Result<String, RangeError> {
    let LineRange { start, end } = range;
    if start == 0 || end == 0 {
        return Err(RangeError::OutOfRange);
    }
    if start > end {
        return Err(RangeError::Inverted);
    }
    // split_inclusive keeps line terminators attached so suffix reattaches
    // cleanly without fusing the body onto the next line.
    let lines: Vec<&str> = doc.split_inclusive('\n').collect();
    if end > lines.len() {
        return Err(RangeError::OutOfRange);
    }
    // 1-based inclusive [start, end] → 0-based [start-1, end)
    let prefix: String = lines[..start - 1].concat();
    let suffix: String = lines[end..].concat();
    Ok(format!("{prefix}{}\n{suffix}", body.trim_matches('\n')))
}

// ── Mode 4: unique-literal-match replace ─────────────────────────────────────

/// Replace the UNIQUE non-overlapping occurrence of `needle` in `doc` with
/// `body`. Empty needle is treated as not-found.
///
/// Errors:
/// - [`MatchError::NotFound`] when `needle` is empty or has zero matches.
/// - [`MatchError::Ambiguous`] when `needle` appears more than once.
pub fn replace_unique_match(doc: &str, needle: &str, body: &str) -> Result<String, MatchError> {
    if needle.is_empty() {
        return Err(MatchError::NotFound);
    }
    let count = doc.matches(needle).count();
    match count {
        0 => Err(MatchError::NotFound),
        1 => Ok(doc.replacen(needle, body, 1)),
        _ => Err(MatchError::Ambiguous(count)),
    }
}

// ── Unit tests ────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    // ── T1: append under existing H2 ──────────────────────────────────────────
    #[test]
    fn t1_append_under_h2_lands_before_next_heading() {
        // WHY: the basic append path must place the fragment before the next
        // same-or-higher heading so the section boundary is respected.
        let doc = "# Title\n\n## Section A\n\nExisting body.\n\n## Section B\n\nOther.\n";
        let result = splice_under_heading(doc, "Section A", Position::Append, "New line.").unwrap();
        // Fragment lands before "## Section B"
        assert!(
            result.contains("Existing body.\n\n\nNew line.\n\n## Section B"),
            "fragment must be before Section B; got: {result:?}"
        );
        // Rest is byte-identical
        assert!(result.contains("# Title"), "title must be preserved");
        assert!(
            result.contains("## Section B\n\nOther."),
            "section B must be preserved"
        );
    }

    // ── T2: prepend under existing H2 ─────────────────────────────────────────
    #[test]
    fn t2_prepend_under_h2_inserts_after_heading_line() {
        // WHY: prepend must insert right after the heading line, before
        // existing body — not at the end of the section.
        let doc = "## Section A\n\nExisting body.\n";
        let result = splice_under_heading(doc, "Section A", Position::Prepend, "First!").unwrap();
        // Byte-exact: heading + blank + fragment + blank + original body (normalize_block
        // prepends \n and appends \n\n; existing blank after heading survives).
        assert_eq!(
            result, "## Section A\n\nFirst!\n\n\nExisting body.\n",
            "prepend must produce exact heading\\n\\nfragment\\n\\n shape: {result:?}"
        );
    }

    // ── T3: append under last section (no following heading) ──────────────────
    #[test]
    fn t3_append_under_last_section_lands_at_eof() {
        // WHY: when there is no following heading the section ends at EOF;
        // the fragment must still be inserted correctly.
        let doc = "## Only Section\n\nSome text.\n";
        let result =
            splice_under_heading(doc, "Only Section", Position::Append, "New line.").unwrap();
        // doc ends with \n, normalize_block prepends \n → two \n total (one blank line)
        assert!(
            result.ends_with("Some text.\n\nNew line.\n\n"),
            "fragment must be at EOF; got: {result:?}"
        );
    }

    // ── T4: section boundary at next same-or-higher heading, not deeper H4 ────
    #[test]
    fn t4_section_end_stops_at_same_or_higher_not_child_heading() {
        // WHY: an H4 inside an H3 section must not terminate that section;
        // only a heading at H3 or above closes it.
        let doc =
            "## H2\n\n### H3 Target\n\nBody.\n\n#### H4 Child\n\nDeep.\n\n## H2 Next\n\nAfter.\n";
        let result = splice_under_heading(doc, "H3 Target", Position::Append, "New!").unwrap();
        // Fragment must appear before "## H2 Next", not before "#### H4 Child"
        let frag_pos = result.find("New!").expect("fragment present");
        let h4_pos = result.find("#### H4 Child").expect("H4 present");
        let h2_next_pos = result.find("## H2 Next").expect("H2 Next present");
        assert!(
            h4_pos < frag_pos,
            "H4 child must be before fragment (section continues past H4): {result:?}"
        );
        assert!(
            frag_pos < h2_next_pos,
            "fragment must be before H2 Next: {result:?}"
        );
    }

    // ── T5: heading not found ──────────────────────────────────────────────────
    #[test]
    fn t5_heading_not_found_returns_not_found_error() {
        // WHY: a typo'd heading must fail loudly, never silently append at EOF.
        let doc = "## Real Heading\n\nContent.\n";
        let err = splice_under_heading(doc, "Typo Heading", Position::Append, "x").unwrap_err();
        assert_eq!(err, SectionError::NotFound);
    }

    // ── T6: duplicate heading text ─────────────────────────────────────────────
    #[test]
    fn t6_duplicate_heading_returns_duplicate_error() {
        // WHY: ambiguous heading must fail; the correct target is unknowable.
        let doc = "## Same\n\nFirst.\n\n## Same\n\nSecond.\n";
        let err = splice_under_heading(doc, "Same", Position::Append, "x").unwrap_err();
        assert_eq!(err, SectionError::Duplicate);
    }

    // ── T7: # in fenced code block is not matched ──────────────────────────────
    #[test]
    fn t7_hash_in_fenced_code_not_matched_as_heading() {
        // WHY: pulldown-cmark treats fenced content as opaque, so `# x`
        // inside a code block must never match as a heading.
        let doc = "## Real\n\n```\n# not a heading\n```\n\nBody.\n";
        // If the code-block `#` were mistakenly treated as a heading, there
        // would be a duplicate and we'd get Duplicate; instead only "Real" matches.
        let result = splice_under_heading(doc, "Real", Position::Append, "Added.").unwrap();
        assert!(
            result.contains("Added."),
            "splice must succeed with only the real heading: {result:?}"
        );
    }

    // ── T8: setext heading (H2 underline) ───────────────────────────────────────
    #[test]
    fn t8_setext_heading_matched_and_splice_after_underline() {
        // WHY: setext (`Text\n---`) headings must be matchable. The prepend
        // insert point is after the underline line, not after the text line.
        let doc = "Section A\n---------\n\nExisting body.\n";
        let result =
            splice_under_heading(doc, "Section A", Position::Prepend, "Before body!").unwrap();
        // Byte-exact: underline + blank + fragment + blank + original body (insert
        // point is after underline line; normalize_block prepends \n, appends \n\n).
        assert_eq!(
            result, "Section A\n---------\n\nBefore body!\n\n\nExisting body.\n",
            "prepend after setext underline must produce exact shape: {result:?}"
        );
    }

    // ── T9: replace a middle range [3, 5] ─────────────────────────────────────
    #[test]
    fn t9_replace_middle_range() {
        // WHY: core replace_lines path; terminator hygiene means exactly one \n
        // after the replacement body, no surrounding blank lines — table rows
        // and list items must not have their structure broken.
        let doc = "line1\nline2\nline3\nline4\nline5\nline6\n";
        let result = replace_line_range(doc, LineRange { start: 3, end: 5 }, "replaced").unwrap();
        assert_eq!(
            result, "line1\nline2\nreplaced\nline6\n",
            "replace_lines [3,5] must produce exact composed output: {result:?}"
        );
    }

    // ── T10: single-line replace (start == end) ────────────────────────────────
    #[test]
    fn t10_single_line_replace() {
        // WHY: start == end must replace exactly one line with terminator hygiene —
        // single trailing newline, no surrounding blank lines.
        let doc = "alpha\nbeta\ngamma\n";
        let result = replace_line_range(doc, LineRange { start: 2, end: 2 }, "replaced").unwrap();
        assert_eq!(
            result, "alpha\nreplaced\ngamma\n",
            "single-line replace must produce exact composed output: {result:?}"
        );
    }

    // ── T11: start == 0 or end == 0 → OutOfRange ──────────────────────────────
    #[test]
    fn t11_zero_start_or_end_returns_out_of_range() {
        // WHY: 1-based indexing; 0 is invalid and must be rejected.
        let doc = "a\nb\n";
        assert_eq!(
            replace_line_range(doc, LineRange { start: 0, end: 1 }, "x").unwrap_err(),
            RangeError::OutOfRange
        );
        assert_eq!(
            replace_line_range(doc, LineRange { start: 1, end: 0 }, "x").unwrap_err(),
            RangeError::OutOfRange
        );
    }

    // ── T12: end > line_count → OutOfRange ────────────────────────────────────
    #[test]
    fn t12_end_beyond_line_count_returns_out_of_range() {
        // WHY: accessing a non-existent line must fail loudly.
        let doc = "a\nb\n";
        assert_eq!(
            replace_line_range(doc, LineRange { start: 1, end: 99 }, "x").unwrap_err(),
            RangeError::OutOfRange
        );
    }

    // ── T13: start > end → Inverted ───────────────────────────────────────────
    #[test]
    fn t13_start_greater_than_end_returns_inverted() {
        // WHY: an inverted range is always a caller error; fail loudly.
        let doc = "a\nb\nc\n";
        assert_eq!(
            replace_line_range(doc, LineRange { start: 3, end: 1 }, "x").unwrap_err(),
            RangeError::Inverted
        );
    }

    // ── T14: unique literal substring replaced ─────────────────────────────────
    #[test]
    fn t14_unique_literal_match_replaced() {
        // WHY: core replace_unique_match path; rest must be byte-identical.
        let doc = "The quick brown fox jumps over the lazy dog.\n";
        let result = replace_unique_match(doc, "brown fox", "red cat").unwrap();
        assert_eq!(result, "The quick red cat jumps over the lazy dog.\n");
    }

    // ── T15: HALLOUMINATE:INDEX marker block preserved ────────────────────────
    #[test]
    fn t15_index_marker_block_intact_when_editing_sibling_region() {
        // WHY: the INDEX marker block is daemon-owned; a section edit
        // targeting another region must not disturb it.
        let doc = concat!(
            "# Wiki Index\n",
            "\n",
            "<!-- HALLOUMINATE:INDEX-START -->\n",
            "- [A](a.md)\n",
            "<!-- HALLOUMINATE:INDEX-END -->\n",
            "\n",
            "## Prose Section\n",
            "\n",
            "Existing content.\n",
        );
        let result =
            splice_under_heading(doc, "Prose Section", Position::Append, "New entry.").unwrap();
        // Marker block must be intact
        assert!(
            result.contains("<!-- HALLOUMINATE:INDEX-START -->"),
            "INDEX-START marker must be preserved: {result:?}"
        );
        assert!(
            result.contains("<!-- HALLOUMINATE:INDEX-END -->"),
            "INDEX-END marker must be preserved: {result:?}"
        );
        // Edit must have applied
        assert!(
            result.contains("New entry."),
            "splice must be present: {result:?}"
        );
    }

    // ── T16: newline hygiene — fragment lands with surrounding blank lines ──────
    #[test]
    fn t16_newline_hygiene_no_line_fusion() {
        // WHY: normalize_block must ensure the spliced fragment never fuses
        // onto an adjacent line — surrounding blank lines are required.
        let doc = "## Section\n\nExisting.\n";
        let result = splice_under_heading(doc, "Section", Position::Append, "Fragment").unwrap();
        // The result must not have "Existing.Fragment"
        assert!(
            !result.contains("Existing.Fragment"),
            "fragment must not fuse with preceding text: {result:?}"
        );
        // Fragment followed eventually by EOF, not immediately by another word
        let frag_pos = result.find("Fragment").expect("fragment present");
        let after_frag = &result[frag_pos + "Fragment".len()..];
        assert!(
            after_frag.starts_with('\n'),
            "fragment must end with newline: after={after_frag:?}"
        );
    }

    // ── T17: substring not found ───────────────────────────────────────────────
    #[test]
    fn t17_match_not_found_returns_not_found() {
        // WHY: zero matches must fail loudly.
        let doc = "Hello world.\n";
        assert_eq!(
            replace_unique_match(doc, "xyz", "abc").unwrap_err(),
            MatchError::NotFound
        );
    }

    // ── T18: ambiguous match (>1 occurrences) ─────────────────────────────────
    #[test]
    fn t18_ambiguous_match_returns_ambiguous() {
        // WHY: >1 non-overlapping matches must fail — first-match guess
        // is a corruption hazard the caller can't see.
        let doc = "foo bar foo baz\n";
        assert_eq!(
            replace_unique_match(doc, "foo", "qux").unwrap_err(),
            MatchError::Ambiguous(2)
        );
    }

    // ── T19: empty replace_match string → NotFound ────────────────────────────
    #[test]
    fn t19_empty_needle_treated_as_not_found() {
        // WHY: an empty string matches at every position; that is ambiguous
        // in an unbounded way. Treated as not-found per decision D5.
        let doc = "Some text.\n";
        assert_eq!(
            replace_unique_match(doc, "", "x").unwrap_err(),
            MatchError::NotFound
        );
    }
}