use std::ffi::OsStr;
use std::path::{Path, PathBuf};
pub const INDEX_FILENAME: &str = "index.md";
pub const INDEX_START_MARKER: &str = "<!-- HALLOUMINATE:INDEX-START -->";
pub const INDEX_END_MARKER: &str = "<!-- HALLOUMINATE:INDEX-END -->";
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
}
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)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChildEntry {
pub line: String,
pub sort_key: String,
}
pub fn read_h1(path: &Path) -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
let mut lines = text.lines().peekable();
if lines.peek() == Some(&"---") {
lines.next();
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) };
}
return None;
}
None
}
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()?;
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() {
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
}
pub fn render_scaffold(title: &str) -> String {
format!("# {title}\n\n{INDEX_START_MARKER}\n{INDEX_END_MARKER}\n",)
}
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"
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RewriteOutcome {
Created,
Updated,
NoMarkers,
Unchanged,
}
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())
}
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)),
},
}
}
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")));
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");
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");
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");
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 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 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"]);
}
#[test]
fn enumerate_children_skips_symlinks_into_corpus() {
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"]);
}
#[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(¤t)).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);
}
}