use std::{
collections::{HashMap, HashSet},
error::Error,
fmt, fs,
str::FromStr,
};
use indexmap::IndexMap;
use phf::phf_map;
use serde::{de, Deserialize, Deserializer, Serialize};
use simd_json::serde::from_slice as simd_from_slice;
use crate::{
bible_books_enum::BibleBook, book::Book, chapter::Chapter, search_index::SearchIndex,
verse::Verse,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BibleError {
BookNotFound {
book_abbrev: String,
book_name: String,
translation: String,
},
ChapterOutOfBounds {
book_abbrev: String,
book_name: String,
chapter: usize,
max_chapter: usize,
},
VerseOutOfBounds {
book_abbrev: String,
book_name: String,
chapter: usize,
verse: usize,
max_verse: usize,
},
InvalidReference { input: String },
}
impl fmt::Display for BibleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BibleError::BookNotFound {
book_abbrev,
book_name,
translation,
} => {
write!(
f,
"Book {} ('{}') not found in the '{}' Bible translation",
book_name, book_abbrev, translation
)
}
BibleError::ChapterOutOfBounds {
book_abbrev,
book_name,
chapter,
max_chapter,
} => {
write!(
f,
"Chapter {} is out of bounds for book {} ('{}') (max {})",
chapter, book_name, book_abbrev, max_chapter
)
}
BibleError::VerseOutOfBounds {
book_abbrev,
book_name,
chapter,
verse,
max_verse,
} => {
write!(
f,
"Verse {} is out of bounds for book {} ('{}') chapter {} (max {})",
verse, book_name, book_abbrev, chapter, max_verse
)
}
BibleError::InvalidReference { input } => {
write!(f, "Invalid reference: '{}'", input)
}
}
}
}
impl Error for BibleError {}
#[derive(Deserialize, Debug)]
struct BibleFileRoot {
id: String,
name: String,
description: String,
language: String,
books: IndexMap<String, FileDataEntry>,
}
#[derive(Serialize, Deserialize, Debug)]
struct FileDataEntry {
#[serde(deserialize_with = "deserialize_chapters")]
chapters: Vec<Vec<String>>,
name: String,
}
#[derive(Debug)]
struct BibleInitializationData {
books: Vec<Book>,
search_index: Option<SearchIndex>,
id: String,
name: String,
description: String,
language: String,
}
fn deserialize_chapters<'de, D>(deserializer: D) -> Result<Vec<Vec<String>>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum ChaptersHelper {
Array(Vec<Vec<String>>),
Map(IndexMap<String, IndexMap<String, String>>),
}
let helper = ChaptersHelper::deserialize(deserializer)?;
match helper {
ChaptersHelper::Array(chapters) => Ok(chapters),
ChaptersHelper::Map(map) => map
.into_iter()
.map(|(chapter_key, verses)| {
let chapter_num = chapter_key.parse::<usize>().map_err(|_| {
de::Error::custom(format!(
"Invalid chapter key '{}': expected positive integer",
chapter_key
))
})?;
let mut verses_vec = verses
.into_iter()
.map(|(verse_key, text)| {
let verse_num = verse_key.parse::<usize>().map_err(|_| {
de::Error::custom(format!(
"Invalid verse key '{}': expected positive integer",
verse_key
))
})?;
Ok((verse_num, text))
})
.collect::<Result<Vec<_>, D::Error>>()?;
verses_vec.sort_by_key(|(verse_num, _)| *verse_num);
let verses = verses_vec
.into_iter()
.map(|(_, text)| text)
.collect::<Vec<_>>();
Ok((chapter_num, verses))
})
.collect::<Result<Vec<_>, D::Error>>()
.map(|mut chapters| {
chapters.sort_by_key(|(chapter_num, _)| *chapter_num);
chapters
.into_iter()
.map(|(_, verses)| verses)
.collect::<Vec<_>>()
}),
}
}
#[derive(Debug, Clone)]
pub struct Bible {
books: Vec<Book>,
index_by_abbrev: HashMap<String, usize>,
search_index: Option<SearchIndex>,
id: String,
name: String,
description: String,
language: String,
}
impl Bible {
fn from_initialization_data(data: BibleInitializationData) -> Self {
let BibleInitializationData {
books,
search_index,
id,
name,
description,
language,
} = data;
let mut index_by_abbrev = HashMap::with_capacity(books.len());
for (i, book) in books.iter().enumerate() {
index_by_abbrev.insert(book.abbrev().to_ascii_lowercase(), i);
}
Bible {
books,
index_by_abbrev,
search_index,
id,
name,
description,
language,
}
}
pub fn id(&self) -> &str {
&self.id
}
pub fn name(&self) -> &str {
&self.name
}
pub fn description(&self) -> &str {
&self.description
}
pub fn language(&self) -> &str {
&self.language
}
pub fn books(&self) -> &[Book] {
&self.books
}
pub fn get_book(&self, book: BibleBook) -> Result<&Book, BibleError> {
self.get_book_by_abbrev(book.as_str())
}
pub fn get_book_by_abbrev(&self, abbrev: &str) -> Result<&Book, BibleError> {
let key = abbrev.to_ascii_lowercase();
self.index_by_abbrev
.get(key.as_str())
.and_then(|&i| self.books.get(i))
.ok_or_else(|| {
let book_name = BibleBook::from_str(&key)
.map(|b| b.full_name().to_string())
.unwrap_or_else(|_| key.clone());
BibleError::BookNotFound {
book_abbrev: key.clone(),
book_name,
translation: self.name.clone(),
}
})
}
pub fn get_verses(
&self,
book: BibleBook,
chapter_number: usize,
) -> Result<&[Verse], BibleError> {
self.get_book(book)?.get_verses(chapter_number)
}
pub fn get_verse(
&self,
book: BibleBook,
chapter_number: usize,
verse_number: usize,
) -> Result<&Verse, BibleError> {
self.get_book(book)?.get_verse(chapter_number, verse_number)
}
pub fn get_verse_by_reference(&self, reference: &str) -> Result<&Verse, BibleError> {
let reference = reference.trim();
let (book_and_chapter, verse_str) =
reference
.rsplit_once(':')
.ok_or_else(|| BibleError::InvalidReference {
input: reference.to_string(),
})?;
let verse_number: usize =
verse_str
.trim()
.parse()
.map_err(|_| BibleError::InvalidReference {
input: reference.to_string(),
})?;
let (book_str, chapter_str) =
book_and_chapter
.rsplit_once(' ')
.ok_or_else(|| BibleError::InvalidReference {
input: reference.to_string(),
})?;
let chapter_number: usize =
chapter_str
.trim()
.parse()
.map_err(|_| BibleError::InvalidReference {
input: reference.to_string(),
})?;
let book = self
.resolve_book(book_str.trim())
.ok_or_else(|| BibleError::BookNotFound {
book_abbrev: book_str.trim().to_ascii_lowercase(),
book_name: book_str.trim().to_string(),
translation: self.name.clone(),
})?;
self.get_verse(book, chapter_number, verse_number)
}
pub fn search(&mut self, query: &str) -> Vec<Verse> {
if query.is_empty() {
return Vec::new();
}
if self.search_index.is_none() {
let index = self.build_search_index();
self.search_index = Some(index);
}
let matches = self.search_index.as_ref().unwrap().search(query);
matches
.into_iter()
.filter_map(|(book, chapter, verse)| self.get_verse(book, chapter, verse).ok().cloned())
.collect()
}
pub fn build_search_index(&self) -> SearchIndex {
let mut map: HashMap<String, Vec<(BibleBook, usize, usize)>> = HashMap::new();
for book in &self.books {
for chapter in book.chapters() {
for verse in chapter.get_verses() {
for term in SearchIndex::tokenize(verse.text()) {
let entry = map.entry(term).or_default();
let tuple = (verse.book(), verse.chapter(), verse.number());
if !entry.contains(&tuple) {
entry.push(tuple);
}
}
}
}
}
for values in map.values_mut() {
values.sort_by_key(|&(b, c, v)| (b as usize, c, v));
}
SearchIndex::new(map)
}
fn resolve_book(&self, input: &str) -> Option<BibleBook> {
let lower = input.to_ascii_lowercase();
static ALT_ABBREVS: phf::Map<&'static str, BibleBook> = phf_map! {
"gen" => BibleBook::Genesis,
"ge" => BibleBook::Genesis,
"exo" => BibleBook::Exodus,
"exod" => BibleBook::Exodus,
"lev" => BibleBook::Leviticus,
"le" => BibleBook::Leviticus,
"num" => BibleBook::Numbers,
"nu" => BibleBook::Numbers,
"deut" => BibleBook::Deuteronomy,
"deu" => BibleBook::Deuteronomy,
"jos" => BibleBook::Joshua,
"josh" => BibleBook::Joshua,
"jdg" => BibleBook::Judges,
"judg" => BibleBook::Judges,
"rut" => BibleBook::Ruth,
"ru" => BibleBook::Ruth,
"1sa" => BibleBook::FirstSamuel,
"1sam" => BibleBook::FirstSamuel,
"2sa" => BibleBook::SecondSamuel,
"2sam" => BibleBook::SecondSamuel,
"1ki" => BibleBook::FirstKings,
"1kings" => BibleBook::FirstKings,
"2ki" => BibleBook::SecondKings,
"2kings" => BibleBook::SecondKings,
"1ch" => BibleBook::FirstChronicles,
"1chr" => BibleBook::FirstChronicles,
"2ch" => BibleBook::SecondChronicles,
"2chr" => BibleBook::SecondChronicles,
"ezr" => BibleBook::Ezra,
"ezra" => BibleBook::Ezra,
"neh" => BibleBook::Nehemiah,
"ne" => BibleBook::Nehemiah,
"est" => BibleBook::Esther,
"esth" => BibleBook::Esther,
"job" => BibleBook::Job,
"jb" => BibleBook::Job,
"psa" => BibleBook::Psalms,
"psalm" => BibleBook::Psalms,
"psalms" => BibleBook::Psalms,
"pro" => BibleBook::Proverbs,
"prov" => BibleBook::Proverbs,
"ecc" => BibleBook::Ecclesiastes,
"eccl" => BibleBook::Ecclesiastes,
"sos" => BibleBook::SongOfSolomon,
"song" => BibleBook::SongOfSolomon,
"songofsongs" => BibleBook::SongOfSolomon,
"isa" => BibleBook::Isaiah,
"jer" => BibleBook::Jeremiah,
"lam" => BibleBook::Lamentations,
"ezek" => BibleBook::Ezekiel,
"eze" => BibleBook::Ezekiel,
"dan" => BibleBook::Daniel,
"da" => BibleBook::Daniel,
"hos" => BibleBook::Hosea,
"joe" => BibleBook::Joel,
"amo" => BibleBook::Amos,
"oba" => BibleBook::Obadiah,
"obad" => BibleBook::Obadiah,
"jon" => BibleBook::Jonah,
"jnh" => BibleBook::Jonah,
"mic" => BibleBook::Micah,
"nah" => BibleBook::Nahum,
"hab" => BibleBook::Habakkuk,
"zep" => BibleBook::Zephaniah,
"zeph" => BibleBook::Zephaniah,
"hag" => BibleBook::Haggai,
"zec" => BibleBook::Zechariah,
"zech" => BibleBook::Zechariah,
"mal" => BibleBook::Malachi,
"mat" => BibleBook::Matthew,
"matt" => BibleBook::Matthew,
"mar" => BibleBook::Mark,
"mrk" => BibleBook::Mark,
"luk" => BibleBook::Luke,
"luke" => BibleBook::Luke,
"john" => BibleBook::John,
"jhn" => BibleBook::John,
"jn" => BibleBook::John,
"acts" => BibleBook::Acts,
"ac" => BibleBook::Acts,
"rom" => BibleBook::Romans,
"1co" => BibleBook::FirstCorinthians,
"1cor" => BibleBook::FirstCorinthians,
"2co" => BibleBook::SecondCorinthians,
"2cor" => BibleBook::SecondCorinthians,
"gal" => BibleBook::Galatians,
"eph" => BibleBook::Ephesians,
"phil" => BibleBook::Philippians,
"php" => BibleBook::Philippians,
"col" => BibleBook::Colossians,
"1th" => BibleBook::FirstThessalonians,
"1thes" => BibleBook::FirstThessalonians,
"2th" => BibleBook::SecondThessalonians,
"2thes" => BibleBook::SecondThessalonians,
"1ti" => BibleBook::FirstTimothy,
"1tim" => BibleBook::FirstTimothy,
"2ti" => BibleBook::SecondTimothy,
"2tim" => BibleBook::SecondTimothy,
"tit" => BibleBook::Titus,
"phm" => BibleBook::Philemon,
"phlm" => BibleBook::Philemon,
"philemon" => BibleBook::Philemon,
"heb" => BibleBook::Hebrews,
"jas" => BibleBook::James,
"jam" => BibleBook::James,
"1pe" => BibleBook::FirstPeter,
"1pet" => BibleBook::FirstPeter,
"2pe" => BibleBook::SecondPeter,
"2pet" => BibleBook::SecondPeter,
"1jn" => BibleBook::FirstJohn,
"1joh" => BibleBook::FirstJohn,
"2jn" => BibleBook::SecondJohn,
"2joh" => BibleBook::SecondJohn,
"3jn" => BibleBook::ThirdJohn,
"3joh" => BibleBook::ThirdJohn,
"jud" => BibleBook::Jude,
"jude" => BibleBook::Jude,
"rev" => BibleBook::Revelation,
"revelation" => BibleBook::Revelation,
"tob" => BibleBook::Tobit,
"jdt" => BibleBook::Judith,
"wis" => BibleBook::Wisdom,
"sir" => BibleBook::Sirach,
"bar" => BibleBook::Baruch,
"1mac" => BibleBook::FirstMaccabees,
"2mac" => BibleBook::SecondMaccabees,
"estg" => BibleBook::EstherAdditions,
"addesth" => BibleBook::EstherAdditions,
"dan3" => BibleBook::DanielSongOfThree,
"sus" => BibleBook::DanielSusanna,
"bel" => BibleBook::DanielBelAndTheDragon,
"1esd" => BibleBook::FirstEsdras,
"2esd" => BibleBook::SecondEsdras,
"man" => BibleBook::PrayerOfManasseh,
"prman" => BibleBook::PrayerOfManasseh,
"ps151" => BibleBook::Psalm151,
"3mac" => BibleBook::ThirdMaccabees,
"4mac" => BibleBook::FourthMaccabees,
};
ALT_ABBREVS
.get(lower.as_str())
.copied()
.or_else(|| {
BibleBook::from_str(&lower).ok()
})
.or_else(|| {
self.books
.iter()
.find(|b| b.title().eq_ignore_ascii_case(input))
.and_then(|b| BibleBook::from_str(&b.abbrev().to_ascii_lowercase()).ok())
})
}
fn new_from_map_with_meta(
map: IndexMap<String, FileDataEntry>,
id: String,
name: String,
description: String,
language: String,
) -> BibleInitializationData {
let mut books = Vec::with_capacity(map.len());
let mut search_index_map: HashMap<String, Vec<(BibleBook, usize, usize)>> = HashMap::new();
for (abbrev, entry) in map.into_iter() {
let book_enum = BibleBook::from_str(&abbrev).unwrap_or_else(|_| {
panic!(
"Unknown book abbreviation '{}' encountered while building Bible data",
abbrev
)
});
let mut chapters = Vec::with_capacity(entry.chapters.len());
for (chapter_idx, verses) in entry.chapters.into_iter().enumerate() {
let chapter_number = chapter_idx + 1;
let mut verses_vec = Vec::with_capacity(verses.len());
for (verse_idx, verse_text) in verses.into_iter().enumerate() {
let verse_number = verse_idx + 1;
let tokens = SearchIndex::tokenize(&verse_text);
let mut seen_terms: HashSet<String> = HashSet::new();
for term in tokens {
if seen_terms.insert(term.clone()) {
let location = (book_enum, chapter_number, verse_number);
search_index_map.entry(term).or_default().push(location);
}
}
verses_vec.push(Verse::new(
book_enum,
chapter_number,
verse_number,
verse_text,
));
}
chapters.push(Chapter::new(verses_vec, chapter_number));
}
books.push(Book::new(abbrev, entry.name, chapters));
}
for values in search_index_map.values_mut() {
values.sort_by_key(|&(book, chapter, verse)| (book as usize, chapter, verse));
values.dedup();
}
let search_index = if search_index_map.is_empty() {
None
} else {
Some(SearchIndex::new(search_index_map))
};
BibleInitializationData {
books,
search_index,
id,
name,
description,
language,
}
}
fn load_from_json(json_path: &str) -> Result<BibleInitializationData, Box<dyn Error>> {
let mut file_content = fs::read(json_path)?;
let root: BibleFileRoot = simd_from_slice(&mut file_content)?;
Ok(Bible::new_from_map_with_meta(
root.books,
root.id,
root.name,
root.description,
root.language,
))
}
pub fn new(json_path: &str) -> Result<Self, Box<dyn Error>> {
let initialization_data = Bible::load_from_json(json_path)?;
Ok(Bible::from_initialization_data(initialization_data))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bible_books_enum::BibleBook;
use std::collections::HashMap;
fn create_test_bible() -> Bible {
let verse = Verse::new(BibleBook::Genesis, 1, 1, "In the beginning".to_string());
let chapter = Chapter::new(vec![verse], 1);
let book = Book::new("GN".to_string(), "Genesis".to_string(), vec![chapter]);
let mut index_by_abbrev = HashMap::new();
index_by_abbrev.insert("gn".to_string(), 0);
Bible {
books: vec![book],
index_by_abbrev,
search_index: None,
id: "id".to_string(),
name: "name".to_string(),
description: "desc".to_string(),
language: "lang".to_string(),
}
}
#[test]
fn test_get_book_and_verse() {
let bible = create_test_bible();
let book = bible.get_book(BibleBook::Genesis).unwrap();
assert_eq!(book.title(), "Genesis");
let verse = bible.get_verse(BibleBook::Genesis, 1, 1).unwrap();
assert_eq!(verse.number(), 1);
}
#[test]
fn test_clone_independence() {
let original = create_test_bible();
let cloned = original.clone();
assert_eq!(original.id(), cloned.id());
assert_eq!(original.name(), cloned.name());
assert_eq!(original.description(), cloned.description());
assert_eq!(original.language(), cloned.language());
assert_eq!(original.books().len(), cloned.books().len());
assert_eq!(original.books()[0].title(), cloned.books()[0].title());
assert_ne!(original.books().as_ptr(), cloned.books().as_ptr());
assert_ne!(original.name().as_ptr(), cloned.name().as_ptr());
}
#[test]
fn test_resolve_book_abbreviations() {
let bible = create_test_bible();
assert_eq!(bible.resolve_book("Gen"), Some(BibleBook::Genesis));
assert_eq!(bible.resolve_book("Ge"), Some(BibleBook::Genesis));
assert_eq!(bible.resolve_book("Jn"), Some(BibleBook::John));
assert_eq!(bible.resolve_book("Rev"), Some(BibleBook::Revelation));
}
}