use anyhow::Result;
use pulldown_cmark::{Event, Parser, Tag, TagEnd};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct Summary {
#[allow(dead_code)]
pub title: Option<String>,
pub items: Vec<SummaryItem>,
}
#[derive(Debug, Clone)]
pub enum SummaryItem {
Link {
title: String,
path: Option<String>,
children: Vec<SummaryItem>,
},
Separator,
PartTitle(String),
}
impl Summary {
pub fn parse(book_dir: &Path) -> Result<Self> {
let summary_path = book_dir.join("SUMMARY.md");
let content = fs::read_to_string(&summary_path)?;
parse_summary(&content)
}
}
pub fn parse_summary(content: &str) -> Result<Summary> {
let mut title = None;
let mut items = Vec::new();
let parser = Parser::new(content);
let mut in_list_stack: Vec<Vec<SummaryItem>> = Vec::new(); let mut current_link: Option<(String, Option<String>)> = None; let mut current_text = String::new();
let mut in_heading = false;
let mut heading_level = 0;
let mut pending_item_text = String::new();
for event in parser {
match event {
Event::Start(Tag::Heading { level, .. }) => {
in_heading = true;
heading_level = level as usize;
current_text.clear();
}
Event::End(TagEnd::Heading(_)) => {
in_heading = false;
let text = current_text.trim().to_string();
if heading_level == 1 {
title = Some(text);
} else if heading_level == 2 || heading_level == 3 {
items.push(SummaryItem::PartTitle(text));
}
current_text.clear();
}
Event::Start(Tag::List(_)) => {
if !in_list_stack.is_empty() {
if let Some((link_title, link_path)) = current_link.take() {
if let Some(current_list) = in_list_stack.last_mut() {
current_list.push(SummaryItem::Link {
title: link_title,
path: link_path,
children: Vec::new(),
});
}
} else if !pending_item_text.is_empty() {
if let Some(current_list) = in_list_stack.last_mut() {
current_list.push(SummaryItem::Link {
title: pending_item_text.trim().to_string(),
path: None,
children: Vec::new(),
});
}
pending_item_text.clear();
}
}
in_list_stack.push(Vec::new());
}
Event::End(TagEnd::List(_)) => {
if let Some(completed_items) = in_list_stack.pop() {
if in_list_stack.is_empty() {
items.extend(completed_items);
} else {
if let Some(parent_list) = in_list_stack.last_mut() {
if let Some(SummaryItem::Link { children, .. }) = parent_list.last_mut()
{
*children = completed_items;
}
}
}
}
}
Event::Start(Tag::Item) => {
current_link = None;
current_text.clear();
pending_item_text.clear();
}
Event::End(TagEnd::Item) => {
if let Some(current_list) = in_list_stack.last_mut() {
if let Some((link_title, link_path)) = current_link.take() {
current_list.push(SummaryItem::Link {
title: link_title,
path: link_path,
children: Vec::new(),
});
} else if !pending_item_text.is_empty() {
current_list.push(SummaryItem::Link {
title: pending_item_text.trim().to_string(),
path: None,
children: Vec::new(),
});
}
}
current_text.clear();
pending_item_text.clear();
}
Event::Start(Tag::Link { dest_url, .. }) if current_link.is_none() => {
current_text.clear();
let path = dest_url.to_string();
let path = if path.is_empty() || path == "#" {
None
} else {
Some(path.trim_start_matches("./").to_string())
};
current_link = Some((String::new(), path));
}
Event::End(TagEnd::Link) => {
if let Some((ref mut link_title, _)) = current_link {
if link_title.is_empty() {
*link_title = current_text.trim().to_string();
}
}
current_text.clear();
}
Event::Rule => {
items.push(SummaryItem::Separator);
}
Event::Text(text) => {
if in_heading {
current_text.push_str(&text);
} else if !in_list_stack.is_empty() {
current_text.push_str(&text);
if current_link.is_none() {
pending_item_text.push_str(&text);
}
}
}
Event::Code(code) => {
if in_heading {
current_text.push_str(&code);
} else if !in_list_stack.is_empty() {
current_text.push_str(&code);
if current_link.is_none() {
pending_item_text.push_str(&code);
}
}
}
_ => {}
}
}
Ok(Summary { title, items })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_summary() {
let content = r#"# Summary
* [Introduction](README.md)
* [Chapter 1](chapter1.md)
* [Section 1.1](chapter1/section1.md)
* [Section 1.2](chapter1/section2.md)
* [Chapter 2](chapter2.md)
"#;
let summary = parse_summary(content).unwrap();
assert_eq!(summary.title, Some("Summary".to_string()));
assert_eq!(summary.items.len(), 3);
}
#[test]
fn test_parse_nested_summary() {
let content = r#"# Summary
* [表紙](README.md)
* 顧客画面
* ポートフォリオ
* [TOP](Customer/AssetStatus/PortfolioTop.md)
* [国内株式現物](./Customer/AssetStatus/PortfolioStock.md)
"#;
let summary = parse_summary(content).unwrap();
assert_eq!(summary.items.len(), 2);
if let SummaryItem::Link {
title, children, ..
} = &summary.items[1]
{
assert_eq!(title, "顧客画面");
assert_eq!(
children.len(),
1,
"顧客画面 should have 1 child (ポートフォリオ)"
);
if let SummaryItem::Link {
title: child_title,
children: grandchildren,
..
} = &children[0]
{
assert_eq!(child_title, "ポートフォリオ");
assert_eq!(
grandchildren.len(),
2,
"ポートフォリオ should have 2 children (TOP, 国内株式現物)"
);
} else {
panic!("Expected Link for ポートフォリオ");
}
} else {
panic!("Expected Link for 顧客画面");
}
}
#[test]
fn test_parse_2space_indent() {
let content = r#"# Summary
* [Introduction](README.md)
* [Chapter 1](chapter1.md)
* [Section 1.1](chapter1/section1.md)
* [Section 1.2](chapter1/section2.md)
* [Subsection 1.2.1](chapter1/section2/sub1.md)
* [Chapter 2](chapter2.md)
"#;
let summary = parse_summary(content).unwrap();
assert_eq!(summary.items.len(), 3, "Should have 3 top-level items");
if let SummaryItem::Link {
title, children, ..
} = &summary.items[1]
{
assert_eq!(title, "Chapter 1");
assert_eq!(children.len(), 2, "Chapter 1 should have 2 children");
if let SummaryItem::Link {
title: sec_title,
children: sec_children,
..
} = &children[1]
{
assert_eq!(sec_title, "Section 1.2");
assert_eq!(sec_children.len(), 1, "Section 1.2 should have 1 child");
} else {
panic!("Expected Link for Section 1.2");
}
} else {
panic!("Expected Link for Chapter 1");
}
}
#[test]
fn test_parse_4space_indent() {
let content = r#"# Summary
* [Introduction](README.md)
* [Chapter 1](chapter1.md)
* [Section 1.1](chapter1/section1.md)
* [Section 1.2](chapter1/section2.md)
* [Subsection 1.2.1](chapter1/section2/sub1.md)
* [Chapter 2](chapter2.md)
"#;
let summary = parse_summary(content).unwrap();
assert_eq!(summary.items.len(), 3, "Should have 3 top-level items");
if let SummaryItem::Link {
title, children, ..
} = &summary.items[1]
{
assert_eq!(title, "Chapter 1");
assert_eq!(children.len(), 2, "Chapter 1 should have 2 children");
if let SummaryItem::Link {
title: sec_title,
children: sec_children,
..
} = &children[1]
{
assert_eq!(sec_title, "Section 1.2");
assert_eq!(sec_children.len(), 1, "Section 1.2 should have 1 child");
} else {
panic!("Expected Link for Section 1.2");
}
} else {
panic!("Expected Link for Chapter 1");
}
}
#[test]
fn test_parse_mixed_indent() {
let content = r#"# Summary
* [Item 1](item1.md)
* [Item 1.1](item1-1.md)
* [Item 2](item2.md)
* [Item 2.1](item2-1.md)
"#;
let summary = parse_summary(content).unwrap();
assert_eq!(summary.items.len(), 2, "Should have 2 top-level items");
if let SummaryItem::Link {
title, children, ..
} = &summary.items[0]
{
assert_eq!(title, "Item 1");
assert_eq!(children.len(), 1, "Item 1 should have 1 child");
} else {
panic!("Expected Link for Item 1");
}
if let SummaryItem::Link {
title, children, ..
} = &summary.items[1]
{
assert_eq!(title, "Item 2");
assert_eq!(children.len(), 1, "Item 2 should have 1 child");
} else {
panic!("Expected Link for Item 2");
}
}
#[test]
fn test_parse_tab_indent() {
let content =
"# Summary\n\n* [Item 1](item1.md)\n\t* [Item 1.1](item1-1.md)\n* [Item 2](item2.md)\n";
let summary = parse_summary(content).unwrap();
assert_eq!(summary.items.len(), 2, "Should have 2 top-level items");
if let SummaryItem::Link {
title, children, ..
} = &summary.items[0]
{
assert_eq!(title, "Item 1");
assert_eq!(
children.len(),
1,
"Item 1 should have 1 child (tab-indented)"
);
} else {
panic!("Expected Link for Item 1");
}
}
#[test]
fn test_parse_absolute_paths() {
let content = r#"# Summary
* [Relative](chapter1.md)
* [With Dot Slash](./chapter2.md)
* [Absolute](/chapter3.md)
* [Absolute Nested](/dir/chapter4.md)
"#;
let summary = parse_summary(content).unwrap();
assert_eq!(summary.items.len(), 4);
if let SummaryItem::Link { path, .. } = &summary.items[0] {
assert_eq!(path.as_deref(), Some("chapter1.md"));
}
if let SummaryItem::Link { path, .. } = &summary.items[1] {
assert_eq!(path.as_deref(), Some("chapter2.md"), "./ should be removed");
}
if let SummaryItem::Link { path, .. } = &summary.items[2] {
assert_eq!(
path.as_deref(),
Some("/chapter3.md"),
"Leading / should be preserved for root-relative paths"
);
}
if let SummaryItem::Link { path, .. } = &summary.items[3] {
assert_eq!(
path.as_deref(),
Some("/dir/chapter4.md"),
"Leading / should be preserved for root-relative paths"
);
}
}
#[test]
fn test_fuzz_empty_input() {
let result = parse_summary("");
assert!(result.is_ok());
}
#[test]
fn test_fuzz_only_whitespace() {
let result = parse_summary(" \n\n\t\t\n ");
assert!(result.is_ok());
}
#[test]
fn test_fuzz_only_heading() {
let result = parse_summary("# Summary");
assert!(result.is_ok());
}
#[test]
fn test_fuzz_broken_links() {
let inputs = vec![
"* []()",
"* [title]()",
"* [](path.md)",
"* [broken",
"* broken](link.md)",
"* [link](path with spaces.md)",
"* [](javascript:alert(1))",
"* [🎉](emoji.md)",
];
for input in inputs {
let result = parse_summary(input);
assert!(result.is_ok(), "Should not panic on: {}", input);
}
}
#[test]
fn test_fuzz_deep_nesting() {
let mut content = String::from("# Summary\n");
for i in 0..20 {
let indent = " ".repeat(i);
content.push_str(&format!("{}* [Level {}](l{}.md)\n", indent, i, i));
}
let result = parse_summary(&content);
assert!(result.is_ok());
}
#[test]
fn test_fuzz_null_bytes() {
let content = "# Summary\n* [Test\0](file\0.md)\n";
let result = parse_summary(content);
assert!(result.is_ok());
}
#[test]
fn test_fuzz_very_long_line() {
let long_title = "A".repeat(10000);
let content = format!("* [{}](test.md)\n", long_title);
let result = parse_summary(&content);
assert!(result.is_ok());
}
#[test]
fn test_first_link_wins_with_two_links_in_item() {
let content = "# Summary\n\n* [Old](old.md) → [New](new.md)\n";
let summary = parse_summary(content).unwrap();
let links: Vec<(String, Option<String>)> = summary
.items
.iter()
.filter_map(|i| match i {
SummaryItem::Link { title, path, .. } => Some((title.clone(), path.clone())),
_ => None,
})
.collect();
assert_eq!(links.len(), 1);
assert_eq!(links[0].0, "Old");
assert_eq!(links[0].1.as_deref(), Some("old.md"));
}
#[test]
fn test_trailing_text_after_link_does_not_break_title() {
let content = "# Summary\n\n* [Page](page.md) (draft)\n";
let summary = parse_summary(content).unwrap();
match &summary.items[0] {
SummaryItem::Link { title, path, .. } => {
assert_eq!(title, "Page");
assert_eq!(path.as_deref(), Some("page.md"));
}
other => panic!("unexpected item: {:?}", other),
}
}
}