use comrak::{parse_document, Arena, Options};
use comrak::nodes::NodeValue;
#[derive(Debug, Clone)]
pub struct TocEntry {
pub level: u8,
pub text: String,
pub anchor: String,
}
pub fn extract_toc(content: &str) -> Vec<TocEntry> {
let arena = Arena::new();
let mut options = Options::default();
options.extension.strikethrough = true;
options.extension.table = true;
options.extension.autolink = true;
options.extension.tasklist = true;
options.extension.footnotes = true;
let root = parse_document(&arena, content, &options);
let mut entries = Vec::new();
for node in root.descendants() {
if let NodeValue::Heading(heading) = &node.data.borrow().value {
let level = heading.level;
let text = collect_text(node);
let anchor = slugify(&text);
entries.push(TocEntry { level, text, anchor });
}
}
entries
}
fn collect_text<'a>(node: &'a comrak::arena_tree::Node<'a, std::cell::RefCell<comrak::nodes::Ast>>) -> String {
let mut text = String::new();
for child in node.descendants() {
if let NodeValue::Text(ref t) = child.data.borrow().value {
text.push_str(t);
}
if let NodeValue::Code(ref c) = child.data.borrow().value {
text.push_str(&c.literal);
}
}
text
}
fn slugify(text: &str) -> String {
text.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else if c == ' ' { '-' } else { ' ' })
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join("")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugify_simple_text() {
assert_eq!(slugify("Hello World"), "hello-world");
}
#[test]
fn slugify_preserves_hyphens_and_underscores() {
assert_eq!(slugify("my-heading_here"), "my-heading_here");
}
#[test]
fn slugify_strips_special_characters() {
assert_eq!(slugify("Hello, World! (2024)"), "hello-world-2024");
}
#[test]
fn slugify_multiple_spaces_become_multiple_hyphens() {
assert_eq!(slugify("hello world"), "hello---world");
}
#[test]
fn slugify_empty_string() {
assert_eq!(slugify(""), "");
}
#[test]
fn slugify_only_special_chars() {
assert_eq!(slugify("!@#$%"), "");
}
#[test]
fn slugify_unicode_alphanumeric() {
let result = slugify("Café Résumé");
assert!(result.contains("café"));
assert!(result.contains("résumé"));
}
#[test]
fn slugify_numbers() {
assert_eq!(slugify("Chapter 1"), "chapter-1");
}
#[test]
fn extract_toc_empty_input() {
let entries = extract_toc("");
assert!(entries.is_empty());
}
#[test]
fn extract_toc_no_headings() {
let entries = extract_toc("Just some paragraph text.\n\nAnother paragraph.");
assert!(entries.is_empty());
}
#[test]
fn extract_toc_single_h1() {
let entries = extract_toc("# Hello World");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].level, 1);
assert_eq!(entries[0].text, "Hello World");
assert_eq!(entries[0].anchor, "hello-world");
}
#[test]
fn extract_toc_multiple_levels() {
let md = "# Title\n## Section\n### Subsection\n#### Deep";
let entries = extract_toc(md);
assert_eq!(entries.len(), 4);
assert_eq!(entries[0].level, 1);
assert_eq!(entries[1].level, 2);
assert_eq!(entries[2].level, 3);
assert_eq!(entries[3].level, 4);
}
#[test]
fn extract_toc_heading_with_inline_code() {
let md = "# The `main` function";
let entries = extract_toc(md);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].text, "The main function");
}
#[test]
fn extract_toc_heading_with_special_chars() {
let md = "## Hello, World! (2024)";
let entries = extract_toc(md);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].anchor, "hello-world-2024");
}
#[test]
fn extract_toc_mixed_content_and_headings() {
let md = "Some intro text.\n\n# First\n\nParagraph here.\n\n## Second\n\nMore text.";
let entries = extract_toc(md);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].text, "First");
assert_eq!(entries[1].text, "Second");
}
#[test]
fn extract_toc_h5_and_h6() {
let md = "##### Level 5\n###### Level 6";
let entries = extract_toc(md);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].level, 5);
assert_eq!(entries[1].level, 6);
}
#[test]
fn extract_toc_preserves_order() {
let md = "## B\n# A\n### C";
let entries = extract_toc(md);
assert_eq!(entries[0].text, "B");
assert_eq!(entries[1].text, "A");
assert_eq!(entries[2].text, "C");
}
}