use std::fs::File;
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use bzip2::read::MultiBzDecoder;
use fst::{Map, MapBuilder};
use memmap2::Mmap;
use crate::dump::multistream;
use crate::dump::xml::DumpParser;
use crate::dump::{Page, SiteInfo};
use crate::error::{Error, Result};
pub fn normalize_title(title: &str) -> String {
title.replace('_', " ").trim().to_string()
}
pub struct TitleIndex {
map: Map<Mmap>,
dump_path: PathBuf,
}
impl TitleIndex {
pub fn index_path_for(dump_path: &Path) -> PathBuf {
let name = dump_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("dump");
let base = name.strip_suffix(".bz2").unwrap_or(name);
dump_path.with_file_name(format!("{base}.title.fst"))
}
pub fn open_or_build(dump_path: &Path) -> Result<Self> {
let fst_path = Self::index_path_for(dump_path);
if !index_is_fresh(&fst_path, dump_path) {
build(dump_path, &fst_path)?;
}
let file = File::open(&fst_path)?;
let mmap = unsafe { Mmap::map(&file)? };
let map = Map::new(mmap).map_err(fst_error)?;
Ok(Self {
map,
dump_path: dump_path.to_path_buf(),
})
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
pub fn find_page(&self, title: &str) -> Result<Option<Page>> {
let title = normalize_title(title);
let Some(offset) = self.offset(&title) else {
return Ok(None);
};
Ok(self
.read_block(offset)?
.into_iter()
.find(|page| page.title == title))
}
pub fn offset(&self, title: &str) -> Option<u64> {
self.map.get(title.as_bytes())
}
pub fn read_block(&self, offset: u64) -> Result<Vec<Page>> {
let mut file = File::open(&self.dump_path)?;
let bytes = multistream::read_stream(&mut file, offset)?;
let mut parser = DumpParser::new(bytes.as_slice());
let mut pages = Vec::new();
while let Some(page) = parser.next_page()? {
pages.push(page);
}
Ok(pages)
}
pub fn site_info(&self) -> Result<SiteInfo> {
let mut file = File::open(&self.dump_path)?;
let header = multistream::read_stream(&mut file, 0)?;
Ok(DumpParser::new(header.as_slice())
.site_info()?
.cloned()
.unwrap_or_default())
}
}
fn index_is_fresh(fst_path: &Path, dump_path: &Path) -> bool {
let (Ok(fst_meta), Ok(dump_meta)) = (fst_path.metadata(), dump_path.metadata()) else {
return false; };
match (fst_meta.modified(), dump_meta.modified()) {
(Ok(fst_time), Ok(dump_time)) => fst_time >= dump_time,
_ => true, }
}
fn build(dump_path: &Path, fst_path: &Path) -> Result<()> {
let index_path = multistream::index_path_for(dump_path).ok_or_else(|| {
Error::TitleIndex(
"--title requires a multistream dump with its *-index.txt.bz2 file alongside"
.to_string(),
)
})?;
log::info!(
"building title index from {} (first use; one-time step)",
index_path.display()
);
let mut entries = read_title_offsets(&index_path)?;
entries.sort_unstable_by(|a, b| a.0.cmp(&b.0));
entries.dedup_by(|a, b| a.0 == b.0);
let tmp_path = fst_path.with_extension("fst.tmp");
let writer = BufWriter::new(File::create(&tmp_path)?);
let mut builder = MapBuilder::new(writer).map_err(fst_error)?;
for (title, offset) in &entries {
builder
.insert(title.as_bytes(), *offset)
.map_err(fst_error)?;
}
let mut writer = builder.into_inner().map_err(fst_error)?;
writer.flush()?;
drop(writer);
std::fs::rename(&tmp_path, fst_path)?;
log::info!(
"title index built: {} titles -> {}",
entries.len(),
fst_path.display()
);
Ok(())
}
fn read_title_offsets(index_path: &Path) -> Result<Vec<(String, u64)>> {
let file = File::open(index_path)?;
let mut reader: Box<dyn BufRead> = if index_path.extension().is_some_and(|e| e == "bz2") {
Box::new(BufReader::with_capacity(
256 * 1024,
MultiBzDecoder::new(BufReader::with_capacity(256 * 1024, file)),
))
} else {
Box::new(BufReader::with_capacity(256 * 1024, file))
};
let mut entries = Vec::new();
let mut line = Vec::with_capacity(128);
loop {
line.clear();
if reader.read_until(b'\n', &mut line)? == 0 {
break;
}
let bytes = line.strip_suffix(b"\n").unwrap_or(&line);
let Some((offset, title)) = parse_index_line(bytes) else {
continue; };
entries.push((title.to_string(), offset));
}
if entries.is_empty() {
return Err(Error::TitleIndex(
"multistream index has no usable entries".to_string(),
));
}
Ok(entries)
}
fn parse_index_line(bytes: &[u8]) -> Option<(u64, &str)> {
let mut i = 0;
let mut offset: u64 = 0;
let mut digits = 0;
while i < bytes.len() && bytes[i].is_ascii_digit() {
offset = offset
.checked_mul(10)?
.checked_add(u64::from(bytes[i] - b'0'))?;
i += 1;
digits += 1;
}
if digits == 0 || bytes.get(i) != Some(&b':') {
return None;
}
i += 1; while i < bytes.len() && bytes[i] != b':' {
i += 1;
}
if bytes.get(i) != Some(&b':') {
return None;
}
i += 1; let title = std::str::from_utf8(&bytes[i..]).ok()?;
(!title.is_empty()).then_some((offset, title))
}
fn fst_error(error: fst::Error) -> Error {
Error::TitleIndex(error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_index_lines_including_colons_in_titles() {
assert_eq!(
parse_index_line(b"570:12:Anarchism"),
Some((570, "Anarchism"))
);
assert_eq!(
parse_index_line(b"600:5:Talk:Foo: bar"),
Some((600, "Talk:Foo: bar"))
);
assert_eq!(parse_index_line(b""), None);
assert_eq!(parse_index_line(b"notanumber:1:X"), None);
assert_eq!(parse_index_line(b"570:12:"), None);
}
#[test]
fn normalizes_underscores_and_trims() {
assert_eq!(normalize_title("Richard_Dawkins"), "Richard Dawkins");
assert_eq!(normalize_title(" Richard Dawkins "), "Richard Dawkins");
assert_eq!(normalize_title("_Alpha_"), "Alpha");
assert_eq!(normalize_title("\tTab Separated\t"), "Tab Separated");
assert_eq!(normalize_title(""), "");
assert_eq!(normalize_title("___"), "");
assert_eq!(normalize_title("Foo__Bar"), "Foo Bar");
assert_eq!(normalize_title("Foo Bar"), "Foo Bar");
assert_eq!(normalize_title(&normalize_title("a__b c")), "a b c");
}
#[test]
fn index_path_derives_from_dump_name() {
let p = TitleIndex::index_path_for(Path::new("/data/enwiki-multistream.xml.bz2"));
assert_eq!(p, Path::new("/data/enwiki-multistream.xml.title.fst"));
let q = TitleIndex::index_path_for(Path::new("dump.xml"));
assert_eq!(q, Path::new("dump.xml.title.fst"));
}
}