use std::path::Path;
use crate::book_index::{self, IndexEntry, IndexUnit};
use crate::config::Config;
use crate::error::{Error, Result};
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
use crate::store::Store;
pub fn run(project: &Path, book_name: Option<&str>, format: &str, out: Option<&Path>) -> Result<()> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg)?;
let h = Hierarchy::load(&store)?;
let mut terms: Vec<String> = Vec::new();
let mut see_refs: Vec<(String, String)> = Vec::new();
if cfg.docs.index.from_glossary {
for e in crate::glossary::glossary_entries_from_store(&store, &h, book_name) {
for syn in &e.synonyms {
see_refs.push((syn.clone(), e.term.clone()));
}
terms.push(e.term);
}
}
terms.extend(cfg.docs.index.terms.iter().cloned());
if terms.is_empty() {
return Err(Error::Config(
"index: no terms to index — add Glossary entries or list them in `docs.index.terms`".into(),
));
}
let units = gather_units(&layout, &h, book_name)?;
let entries = book_index::build(&terms, &see_refs, &units);
let rendered = match format.to_ascii_lowercase().as_str() {
"md" | "markdown" => render_md(&entries),
"typ" | "typst" => render_typst(&entries),
"json" => render_json(&entries),
other => {
return Err(Error::Config(format!(
"index: unknown --format `{other}` (use `md`, `typst`, or `json`)"
)))
}
};
match out {
Some(p) => {
std::fs::write(p, rendered.as_bytes()).map_err(Error::Io)?;
println!("index: {} entry(ies) → {}", entries.len(), p.display());
}
None => print!("{rendered}"),
}
Ok(())
}
fn gather_units(layout: &ProjectLayout, h: &Hierarchy, book_name: Option<&str>) -> Result<Vec<IndexUnit>> {
let books: Vec<&Node> = match book_name {
Some(_) => vec![super::resolve_user_book(h, book_name, "index").map_err(Error::Store)?],
None => h
.children_of(None)
.into_iter()
.filter(|n| n.kind == NodeKind::Book && n.system_tag.is_none())
.collect(),
};
let mut units = Vec::new();
for book in &books {
for chapter in h.children_of(Some(book.id)).into_iter().filter(|n| n.kind == NodeKind::Chapter) {
for id in h.collect_subtree(chapter.id) {
let Some(n) = h.get(id) else { continue };
if n.kind != NodeKind::Paragraph {
continue;
}
let Some(rel) = n.file.as_ref() else { continue };
let Ok(raw) = std::fs::read_to_string(layout.root.join(rel)) else { continue };
units.push(IndexUnit {
chapter: chapter.title.clone(),
anchor: chapter.title.clone(),
text: crate::audiobook::typst_to_plain(&raw),
});
}
}
}
Ok(units)
}
fn render_md(entries: &[IndexEntry]) -> String {
let mut s = String::from("# Index\n\n");
for e in entries {
match &e.see {
Some(canonical) => s.push_str(&format!("**{}** — *see* {}\n\n", e.term, canonical)),
None => {
let locs: Vec<&str> = e.locations.iter().map(|l| l.chapter.as_str()).collect();
s.push_str(&format!("**{}** — {}\n\n", e.term, locs.join(", ")));
}
}
}
s
}
fn render_typst(entries: &[IndexEntry]) -> String {
let mut s = String::from("= Index\n\n");
for e in entries {
match &e.see {
Some(canonical) => s.push_str(&format!("*{}* — _see_ {}\n\n", e.term, canonical)),
None => {
let locs: Vec<String> = e.locations.iter().map(|l| l.chapter.clone()).collect();
s.push_str(&format!("*{}* — {}\n\n", e.term, locs.join(", ")));
}
}
}
s
}
fn render_json(entries: &[IndexEntry]) -> String {
let arr: Vec<_> = entries
.iter()
.map(|e| {
serde_json::json!({
"term": e.term,
"see": e.see,
"locations": e.locations.iter()
.map(|l| serde_json::json!({ "chapter": l.chapter, "anchor": l.anchor }))
.collect::<Vec<_>>(),
})
})
.collect();
serde_json::to_string_pretty(&serde_json::json!({ "index": arr, "count": entries.len() }))
.unwrap_or_else(|_| "{}".into())
}