hallouminate 0.2.0

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
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
//! Auto-maintained `index.md` for wiki directories.
//!
//! Every wiki directory can carry an `index.md` whose link list is rewritten
//! between `<!-- HALLOUMINATE:INDEX-START -->` and
//! `<!-- HALLOUMINATE:INDEX-END -->` markers after a write or delete. The
//! prose outside markers stays exactly as the author wrote it — the daemon
//! never touches it. Authors opt out by removing the markers; we skip files
//! without them.
//!
//! The list inside the markers is generated by reading the directory's
//! immediate children (with a recursive existence check on each subdir
//! to prune empty subtrees) plus an H1 sniff per child markdown file.
//! It lists immediate-child markdown files first (excluding `index.md`
//! itself) and immediate-child subdirectories that themselves contain
//! markdown. That gives a consumer LLM a navigable tree without having
//! to ground or list_files; an author LLM can use it as a hand-off
//! summary of what's in the corpus.

use std::ffi::OsStr;
use std::path::{Path, PathBuf};

/// Filename of the auto-maintained directory index.
pub const INDEX_FILENAME: &str = "index.md";
/// Opening fence of the auto-generated link block; prose before it is the
/// author's and left untouched.
pub const INDEX_START_MARKER: &str = "<!-- HALLOUMINATE:INDEX-START -->";
/// Closing fence of the auto-generated link block; prose after it is the
/// author's and left untouched.
pub const INDEX_END_MARKER: &str = "<!-- HALLOUMINATE:INDEX-END -->";

/// Directories from the corpus root down to the parent of `file_relative`,
/// inclusive. Each entry is absolute, joined under `corpus_root`. The root
/// itself is always included so its `index.md` is rebuilt on every write.
pub fn ancestor_dirs(corpus_root: &Path, file_relative: &Path) -> Vec<PathBuf> {
    let mut out = vec![corpus_root.to_path_buf()];
    let Some(parent) = file_relative.parent() else {
        return out;
    };
    let mut acc = corpus_root.to_path_buf();
    for component in parent.components() {
        if let std::path::Component::Normal(seg) = component {
            acc.push(seg);
            out.push(acc.clone());
        }
    }
    out
}

/// True when `path` is `<dir>/index.md` (or just `index.md` at the root) —
/// the file the auto-builder owns. Used to skip rewriting the index that
/// add_markdown / delete_markdown just touched, so the LLM's verbatim
/// write is the final word for the leaf file itself.
pub fn is_index_md(file_relative: &Path) -> bool {
    file_relative
        .file_name()
        .and_then(OsStr::to_str)
        .map(|n| n == INDEX_FILENAME)
        .unwrap_or(false)
}

/// One immediate-child entry rendered into the link list.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChildEntry {
    /// Markdown-formatted line, e.g. `- [foo](./foo.md) — Foo topic`.
    pub line: String,
    /// Sort key (lowercased name with a `/` suffix on directories so dirs
    /// sort ahead of files of the same prefix).
    pub sort_key: String,
}

/// Read the first H1 from `path` (`# Title`). Returns `None` if the file is
/// unreadable or has no leading H1. Used for the trailing gloss on each
/// link entry, so the list is browsable without opening every child.
pub fn read_h1(path: &Path) -> Option<String> {
    let text = std::fs::read_to_string(path).ok()?;
    let mut lines = text.lines().peekable();

    // Skip a leading YAML frontmatter block, but only when the `---` fence
    // opens on line 1 (per the spec). A mid-document `---` is a thematic
    // break, not frontmatter, so it must not be mistaken for one.
    if lines.peek() == Some(&"---") {
        lines.next();
        // Consume through the closing fence. Unterminated frontmatter means
        // there's no body H1 to find — bail rather than scan into the content.
        loop {
            match lines.next() {
                Some("---") => break,
                Some(_) => continue,
                None => return None,
            }
        }
    }

    for line in lines {
        let trimmed = line.trim_start();
        if trimmed.is_empty() {
            continue;
        }
        if let Some(rest) = trimmed.strip_prefix("# ") {
            let title = rest.trim().to_string();
            return if title.is_empty() { None } else { Some(title) };
        }
        // First non-blank line wasn't an H1 — bail. The wiki convention
        // requires the H1 to be the first non-blank line, so anything else
        // means this file opted out.
        return None;
    }
    None
}

/// Enumerate the immediate-child entries to render for `dir`'s `index.md`.
/// Reads one directory; does not recurse. Includes:
///   - `*.md` files except `index.md` itself
///   - subdirectories that (anywhere beneath them) contain at least one
///     `*.md` so dirs that have nothing-but-junk don't pollute the list
pub fn enumerate_children(dir: &Path) -> std::io::Result<Vec<ChildEntry>> {
    let mut out: Vec<ChildEntry> = Vec::new();
    let read = match std::fs::read_dir(dir) {
        Ok(r) => r,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
        Err(e) => return Err(e),
    };
    for entry in read {
        let entry = entry?;
        let file_type = entry.file_type()?;
        // Refuse to traverse symlinks — they could redirect into another
        // corpus or outside the wiki root entirely. The daemon's
        // ensure_wiki_root_safe guards the wiki ROOT; this guards every
        // child the auto-index considers.
        if file_type.is_symlink() {
            continue;
        }
        let name_os = entry.file_name();
        let Some(name) = name_os.to_str() else {
            continue;
        };
        if file_type.is_file() {
            if !name.ends_with(".md") || name == INDEX_FILENAME {
                continue;
            }
            let stem = name.strip_suffix(".md").unwrap_or(name);
            let gloss = read_h1(&entry.path()).unwrap_or_default();
            let line = render_file_line(name, stem, &gloss);
            out.push(ChildEntry {
                line,
                sort_key: format!("1:{}", name.to_lowercase()),
            });
        } else if file_type.is_dir() {
            // Skip dotfiles — `.git`, `.cache`, etc. — and any subdir
            // without markdown inside.
            if name.starts_with('.') || !dir_contains_markdown(&entry.path()) {
                continue;
            }
            let gloss = read_h1(&entry.path().join(INDEX_FILENAME)).unwrap_or_default();
            let line = render_dir_line(name, &gloss);
            out.push(ChildEntry {
                line,
                sort_key: format!("0:{}", name.to_lowercase()),
            });
        }
    }
    out.sort_by(|a, b| a.sort_key.cmp(&b.sort_key));
    Ok(out)
}

fn render_file_line(filename: &str, stem: &str, gloss: &str) -> String {
    if gloss.is_empty() {
        format!("- [{stem}](./{filename})")
    } else {
        format!("- [{stem}](./{filename}) — {gloss}")
    }
}

fn render_dir_line(dirname: &str, gloss: &str) -> String {
    if gloss.is_empty() {
        format!("- [{dirname}/](./{dirname}/{INDEX_FILENAME})")
    } else {
        format!("- [{dirname}/](./{dirname}/{INDEX_FILENAME}) — {gloss}")
    }
}

fn dir_contains_markdown(dir: &Path) -> bool {
    let Ok(read) = std::fs::read_dir(dir) else {
        return false;
    };
    for entry in read.flatten() {
        let Ok(ft) = entry.file_type() else { continue };
        if ft.is_symlink() {
            continue;
        }
        if ft.is_file() {
            let name = entry.file_name();
            if name.to_str().is_some_and(|n| n.ends_with(".md")) {
                return true;
            }
        } else if ft.is_dir() {
            let name = entry.file_name();
            if name.to_string_lossy().starts_with('.') {
                continue;
            }
            if dir_contains_markdown(&entry.path()) {
                return true;
            }
        }
    }
    false
}

/// Render the default scaffold for a newly-created `index.md`. Includes the
/// H1 and the empty marker block — the caller fills the block on the next
/// pass.
pub fn render_scaffold(title: &str) -> String {
    format!("# {title}\n\n{INDEX_START_MARKER}\n{INDEX_END_MARKER}\n",)
}

/// Render the marker-bounded block body for `dir`.
pub fn render_block_body(children: &[ChildEntry]) -> String {
    if children.is_empty() {
        return String::new();
    }
    children
        .iter()
        .map(|c| c.line.as_str())
        .collect::<Vec<_>>()
        .join("\n")
        + "\n"
}

/// Outcome of a single `rewrite_index_md` call. `Untouched` keeps the
/// post-write reindex path honest — it would otherwise re-embed every
/// ancestor index on every mutation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RewriteOutcome {
    /// File didn't exist; scaffold + populated body written.
    Created,
    /// Existing file rewritten between markers.
    Updated,
    /// File exists but has no markers — author opted out; we left it.
    NoMarkers,
    /// File exists; rebuilt block matched what was already there.
    Unchanged,
}

/// Best-effort title for a wiki directory derived from its absolute path —
/// the directory basename, or "wiki" at the root. Used only for the
/// scaffold H1; once a file exists we never touch its H1 again.
pub fn dir_title(dir: &Path, is_root: bool) -> String {
    if is_root {
        return "wiki".to_string();
    }
    dir.file_name()
        .and_then(OsStr::to_str)
        .map(|s| s.to_string())
        .unwrap_or_else(|| "wiki".to_string())
}

/// Compose the index.md content for `dir`. Returns the new file body and the
/// outcome relative to `existing` (so callers can skip a redundant write
/// when the rebuilt body matches what was already on disk).
pub fn compose_index_md(
    dir: &Path,
    is_root: bool,
    existing: Option<&str>,
) -> std::io::Result<(String, RewriteOutcome)> {
    let children = enumerate_children(dir)?;
    let body = render_block_body(&children);
    match existing {
        None => {
            let title = dir_title(dir, is_root);
            let scaffold = render_scaffold(&title);
            let new_content = inject_block(&scaffold, &body).unwrap_or(scaffold);
            Ok((new_content, RewriteOutcome::Created))
        }
        Some(existing) => match inject_block(existing, &body) {
            Some(new_content) => {
                if new_content == existing {
                    Ok((new_content, RewriteOutcome::Unchanged))
                } else {
                    Ok((new_content, RewriteOutcome::Updated))
                }
            }
            None => Ok((existing.to_string(), RewriteOutcome::NoMarkers)),
        },
    }
}

/// Replace whatever's between the markers with `body`. Returns `None` when
/// the markers are missing — the author opted out and we shouldn't touch
/// the file.
pub fn inject_block(existing: &str, body: &str) -> Option<String> {
    let start = existing.find(INDEX_START_MARKER)?;
    let end_rel = existing[start..].find(INDEX_END_MARKER)?;
    let end = start + end_rel;
    let body_section = if body.is_empty() {
        format!("{INDEX_START_MARKER}\n")
    } else {
        format!("{INDEX_START_MARKER}\n{body}")
    };
    let mut out = String::with_capacity(existing.len() + body.len());
    out.push_str(&existing[..start]);
    out.push_str(&body_section);
    out.push_str(&existing[end..]);
    Some(out)
}

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

    #[test]
    fn ancestor_dirs_returns_root_only_for_top_level_file() {
        let root = PathBuf::from("/r");
        let dirs = ancestor_dirs(&root, Path::new("foo.md"));
        assert_eq!(dirs, vec![PathBuf::from("/r")]);
    }

    #[test]
    fn ancestor_dirs_walks_each_parent_for_nested_file() {
        let root = PathBuf::from("/r");
        let dirs = ancestor_dirs(&root, Path::new("a/b/c.md"));
        assert_eq!(
            dirs,
            vec![
                PathBuf::from("/r"),
                PathBuf::from("/r/a"),
                PathBuf::from("/r/a/b"),
            ],
        );
    }

    #[test]
    fn is_index_md_matches_index_files_only() {
        assert!(is_index_md(Path::new("index.md")));
        assert!(is_index_md(Path::new("foo/index.md")));
        assert!(!is_index_md(Path::new("foo.md")));
        assert!(!is_index_md(Path::new("foo/bar.md")));
        // A directory literally named "index.md" (no, but still) — the
        // check is filename-only by design.
        assert!(is_index_md(Path::new("a/b/c/index.md")));
    }

    #[test]
    fn read_h1_returns_title_when_first_line_is_h1() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("a.md");
        std::fs::write(&path, "# Hello world\n\nbody\n").unwrap();
        assert_eq!(read_h1(&path), Some("Hello world".to_string()));
    }

    #[test]
    fn read_h1_skips_leading_blank_lines() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("a.md");
        std::fs::write(&path, "\n\n# Title\n").unwrap();
        assert_eq!(read_h1(&path), Some("Title".to_string()));
    }

    #[test]
    fn read_h1_returns_none_when_first_non_blank_is_not_h1() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("a.md");
        std::fs::write(&path, "intro text\n# late\n").unwrap();
        assert_eq!(read_h1(&path), None);
    }

    #[test]
    fn read_h1_returns_none_for_missing_file() {
        assert_eq!(read_h1(Path::new("/does/not/exist")), None);
    }

    #[test]
    fn read_h1_skips_frontmatter_before_h1() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("a.md");
        std::fs::write(&path, "---\nup:\n  - \"[[bar]]\"\n---\n\n# Foo Title\n").unwrap();
        assert_eq!(read_h1(&path), Some("Foo Title".to_string()));
    }

    #[test]
    fn read_h1_returns_none_when_first_content_after_frontmatter_is_not_h1() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("a.md");
        std::fs::write(&path, "---\nup: bar\n---\n\nintro text\n# late\n").unwrap();
        assert_eq!(read_h1(&path), None);
    }

    #[test]
    fn read_h1_returns_none_for_unterminated_frontmatter() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("a.md");
        // No closing fence — must not over-scan into the body and grab the H1.
        std::fs::write(&path, "---\nup: bar\n# Not a real title\n").unwrap();
        assert_eq!(read_h1(&path), None);
    }

    #[test]
    fn read_h1_skips_frontmatter_with_crlf_line_endings() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("a.md");
        // `str::lines()` strips the trailing `\r`, so the `== "---"` fence
        // checks match under CRLF. Lock that so a future move off `lines()`
        // can't silently regress Windows-authored frontmatter pages.
        std::fs::write(&path, "---\r\nup: bar\r\n---\r\n\r\n# Foo Title\r\n").unwrap();
        assert_eq!(read_h1(&path), Some("Foo Title".to_string()));
    }

    #[test]
    fn read_h1_treats_mid_body_dashes_as_thematic_break_not_frontmatter() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("a.md");
        // `---` not on line 1 is a thematic break; H1 detection is unchanged.
        std::fs::write(&path, "# Real Title\n\n---\n\nbody\n").unwrap();
        assert_eq!(read_h1(&path), Some("Real Title".to_string()));
    }

    #[test]
    fn enumerate_children_skips_index_md_and_includes_h1_gloss() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("index.md"), "# index\n").unwrap();
        std::fs::write(tmp.path().join("alpha.md"), "# Alpha topic\n").unwrap();
        std::fs::write(tmp.path().join("beta.md"), "no h1 here\n").unwrap();
        let children = enumerate_children(tmp.path()).unwrap();
        let lines: Vec<&str> = children.iter().map(|c| c.line.as_str()).collect();
        assert_eq!(
            lines,
            vec!["- [alpha](./alpha.md) — Alpha topic", "- [beta](./beta.md)",],
        );
    }

    #[test]
    fn enumerate_children_lists_subdirs_with_markdown_before_files() {
        let tmp = tempfile::tempdir().unwrap();
        let sub = tmp.path().join("nested");
        std::fs::create_dir(&sub).unwrap();
        std::fs::write(sub.join("inner.md"), "# Inner\n").unwrap();
        std::fs::write(sub.join("index.md"), "# Nested index\n").unwrap();
        std::fs::write(tmp.path().join("top.md"), "# Top\n").unwrap();
        let children = enumerate_children(tmp.path()).unwrap();
        let lines: Vec<&str> = children.iter().map(|c| c.line.as_str()).collect();
        assert_eq!(
            lines,
            vec![
                "- [nested/](./nested/index.md) — Nested index",
                "- [top](./top.md) — Top",
            ],
        );
    }

    #[test]
    fn enumerate_children_omits_dirs_without_any_markdown() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir(tmp.path().join("empty")).unwrap();
        let no_md = tmp.path().join("no_md");
        std::fs::create_dir(&no_md).unwrap();
        std::fs::write(no_md.join("readme.txt"), "x").unwrap();
        std::fs::write(tmp.path().join("a.md"), "# A\n").unwrap();
        let children = enumerate_children(tmp.path()).unwrap();
        let lines: Vec<&str> = children.iter().map(|c| c.line.as_str()).collect();
        assert_eq!(lines, vec!["- [a](./a.md) — A"]);
    }

    #[test]
    fn enumerate_children_skips_symlinks_into_corpus() {
        // Defense in depth against a malicious-or-buggy corpus that swaps a
        // child for a symlink. The wiki root itself is guarded by
        // `ensure_wiki_root_safe` daemon-side; child symlinks could still
        // redirect to attacker-controlled markdown.
        let tmp = tempfile::tempdir().unwrap();
        let outside = tempfile::tempdir().unwrap();
        std::fs::write(outside.path().join("alien.md"), "# Alien\n").unwrap();
        std::os::unix::fs::symlink(outside.path().join("alien.md"), tmp.path().join("alien.md"))
            .unwrap();
        std::fs::write(tmp.path().join("real.md"), "# Real\n").unwrap();
        let children = enumerate_children(tmp.path()).unwrap();
        let lines: Vec<&str> = children.iter().map(|c| c.line.as_str()).collect();
        assert_eq!(lines, vec!["- [real](./real.md) — Real"]);
    }

    #[test]
    fn compose_index_md_scaffolds_when_existing_is_none() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("a.md"), "# Alpha\n").unwrap();
        let (content, outcome) = compose_index_md(tmp.path(), true, None).unwrap();
        assert_eq!(outcome, RewriteOutcome::Created);
        assert!(content.starts_with("# wiki"), "got: {content}");
        assert!(content.contains(INDEX_START_MARKER));
        assert!(content.contains(INDEX_END_MARKER));
        assert!(content.contains("- [a](./a.md) — Alpha"));
    }

    #[test]
    fn compose_index_md_rewrites_only_between_markers_preserving_prose() {
        let tmp = tempfile::tempdir().unwrap();
        let prose = format!(
            "# Custom title\n\nThis is curated prose the author wrote.\n\n\
             {INDEX_START_MARKER}\n- [stale](./stale.md) — Stale\n{INDEX_END_MARKER}\n\n\
             ## Footer\n\nMore prose.\n",
        );
        std::fs::write(tmp.path().join("a.md"), "# Alpha\n").unwrap();
        let (content, outcome) = compose_index_md(tmp.path(), true, Some(&prose)).unwrap();
        assert_eq!(outcome, RewriteOutcome::Updated);
        assert!(content.starts_with("# Custom title"), "prose H1 preserved");
        assert!(content.contains("This is curated prose"));
        assert!(content.contains("## Footer"), "trailing prose preserved");
        assert!(content.contains("- [a](./a.md) — Alpha"));
        assert!(!content.contains("stale"), "old entry removed");
    }

    #[test]
    fn compose_index_md_returns_no_markers_when_author_opted_out() {
        let tmp = tempfile::tempdir().unwrap();
        let prose = "# Author-only\n\nNo markers here.\n";
        std::fs::write(tmp.path().join("a.md"), "# Alpha\n").unwrap();
        let (content, outcome) = compose_index_md(tmp.path(), true, Some(prose)).unwrap();
        assert_eq!(outcome, RewriteOutcome::NoMarkers);
        assert_eq!(content, prose, "file untouched");
    }

    #[test]
    fn compose_index_md_unchanged_when_block_matches_existing() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("a.md"), "# Alpha\n").unwrap();
        let current =
            format!("# wiki\n\n{INDEX_START_MARKER}\n- [a](./a.md) — Alpha\n{INDEX_END_MARKER}\n",);
        let (content, outcome) = compose_index_md(tmp.path(), true, Some(&current)).unwrap();
        assert_eq!(outcome, RewriteOutcome::Unchanged);
        assert_eq!(content, current);
    }

    #[test]
    fn inject_block_replaces_existing_content_between_markers() {
        let input =
            format!("prefix {INDEX_START_MARKER}\nold\nmore old\n{INDEX_END_MARKER} suffix");
        let out = inject_block(&input, "new\n").unwrap();
        assert_eq!(
            out,
            format!("prefix {INDEX_START_MARKER}\nnew\n{INDEX_END_MARKER} suffix"),
        );
    }

    #[test]
    fn inject_block_handles_empty_body() {
        let input = format!("{INDEX_START_MARKER}\nold\n{INDEX_END_MARKER}");
        let out = inject_block(&input, "").unwrap();
        assert_eq!(out, format!("{INDEX_START_MARKER}\n{INDEX_END_MARKER}"));
    }

    #[test]
    fn inject_block_returns_none_without_markers() {
        assert_eq!(inject_block("no markers anywhere", "body").as_deref(), None);
    }
}