use anyhow::Result;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct Summary {
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 stack: Vec<(usize, Vec<SummaryItem>)> = vec![(0, Vec::new())];
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with("# ") {
title = Some(trimmed[2..].trim().to_string());
continue;
}
if trimmed == "---" || trimmed.chars().all(|c| c == '-') && trimmed.len() >= 3 {
push_item(&mut stack, SummaryItem::Separator);
continue;
}
if trimmed.starts_with("## ") || trimmed.starts_with("### ") {
let part_title = trimmed.trim_start_matches('#').trim().to_string();
push_item(&mut stack, SummaryItem::PartTitle(part_title));
continue;
}
if let Some(item) = parse_list_item(line) {
let indent = calculate_indent(line);
while stack.len() > 1 && stack.last().map(|(i, _)| *i > indent).unwrap_or(false) {
let (_, children) = stack.pop().unwrap();
if let Some((_, parent_items)) = stack.last_mut() {
if let Some(SummaryItem::Link { children: ref mut c, .. }) = parent_items.last_mut() {
*c = children;
}
}
}
push_item(&mut stack, item);
stack.push((indent + 1, Vec::new()));
}
}
while stack.len() > 1 {
let (_, children) = stack.pop().unwrap();
if let Some((_, parent_items)) = stack.last_mut() {
if let Some(SummaryItem::Link { children: ref mut c, .. }) = parent_items.last_mut() {
*c = children;
}
}
}
let items = stack.pop().unwrap().1;
Ok(Summary { title, items })
}
fn calculate_indent(line: &str) -> usize {
let mut spaces = 0;
let mut tabs = 0;
for ch in line.chars() {
match ch {
' ' => spaces += 1,
'\t' => tabs += 1,
_ => break,
}
}
tabs + (spaces / 2)
}
fn push_item(stack: &mut Vec<(usize, Vec<SummaryItem>)>, item: SummaryItem) {
if let Some((_, items)) = stack.last_mut() {
items.push(item);
}
}
fn parse_list_item(line: &str) -> Option<SummaryItem> {
let trimmed = line.trim();
if !trimmed.starts_with('*') && !trimmed.starts_with('-') {
return None;
}
let rest = trimmed[1..].trim();
if rest.starts_with('[') {
if let Some(title_end) = rest.find(']') {
let title = rest[1..title_end].to_string();
let after_title = &rest[title_end + 1..];
if after_title.starts_with('(') {
if let Some(path_end) = after_title.find(')') {
let path = after_title[1..path_end].to_string();
let path = if path.is_empty() || path == "#" {
None
} else {
let normalized = path.trim_start_matches("./").to_string();
Some(normalized)
};
return Some(SummaryItem::Link {
title,
path,
children: Vec::new(),
});
}
}
}
}
if !rest.is_empty() && !rest.starts_with('[') {
return Some(SummaryItem::Link {
title: rest.to_string(),
path: None,
children: Vec::new(),
});
}
None
}
#[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 顧客画面");
}
}
}