use std::{
fmt,
hash::{Hash, Hasher},
};
use bible_io_references::VerseRef;
use serde_json::Value;
use crate::bible_books_enum::BibleBook;
use crate::{
errors::ModelError,
json_value::{hash_json_map, validate_annotations, JsonMap},
location::BibleLocation,
text_search::{contains_normalized_text, tokenize_search_text},
};
#[derive(Debug, Clone, PartialEq)]
pub struct Verse {
book: BibleBook,
chapter_number: usize,
verse_text: String,
verse_number: usize,
annotations: JsonMap,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VerseStats {
pub word_count: usize,
pub character_count: usize,
pub average_word_length: f64,
}
impl Verse {
pub fn new(
book: BibleBook,
chapter_number: usize,
verse_number: usize,
verse_text: String,
) -> Self {
assert!(chapter_number > 0, "chapter_number must be positive");
assert!(verse_number > 0, "verse_number must be positive");
Verse {
book,
chapter_number,
verse_text: sanitize_verse_text(verse_text),
verse_number,
annotations: JsonMap::new(),
}
}
pub fn checked(
book: BibleBook,
chapter_number: usize,
verse_number: usize,
verse_text: impl Into<String>,
annotations: JsonMap,
) -> Result<Self, ModelError> {
if chapter_number == 0 {
return Err(ModelError::new("chapter_number", "must be positive"));
}
if verse_number == 0 {
return Err(ModelError::new("verse_number", "must be positive"));
}
validate_annotations(&annotations, &["text"])?;
Ok(Self {
book,
chapter_number,
verse_number,
verse_text: verse_text.into(),
annotations,
})
}
pub fn book(&self) -> BibleBook {
self.book
}
pub fn chapter(&self) -> usize {
self.chapter_number
}
pub fn text(&self) -> &str {
&self.verse_text
}
pub fn number(&self) -> usize {
self.verse_number
}
#[must_use]
pub fn location(&self) -> BibleLocation {
BibleLocation::new(self.book, self.chapter_number, Some(self.verse_number))
.expect("Verse coordinates are validated when constructed")
}
pub fn to_verse_ref(&self) -> Result<VerseRef, ModelError> {
let chapter = u16::try_from(self.chapter_number)
.map_err(|_| ModelError::new("chapter_number", "exceeds the reference limit"))?;
let verse = u16::try_from(self.verse_number)
.map_err(|_| ModelError::new("verse_number", "exceeds the reference limit"))?;
VerseRef::new(self.book, chapter, verse)
.map_err(|error| ModelError::new("location", error.to_string()))
}
#[must_use]
pub fn annotations(&self) -> &JsonMap {
&self.annotations
}
#[must_use]
pub fn contains_word(&self, word: &str) -> bool {
let query = tokenize_search_text(word, false, true, false);
query.len() == 1
&& tokenize_search_text(&self.verse_text, false, true, false).contains(&query[0])
}
#[must_use]
pub fn contains_text(&self, query: &str) -> bool {
contains_normalized_text(&self.verse_text, query, false, true, false)
}
#[must_use]
pub fn contains_any<'a>(&self, words: impl IntoIterator<Item = &'a str>) -> bool {
words.into_iter().any(|word| self.contains_word(word))
}
#[must_use]
pub fn contains_all<'a>(&self, words: impl IntoIterator<Item = &'a str>) -> bool {
words.into_iter().all(|word| self.contains_word(word))
}
#[must_use]
pub fn words(&self) -> Vec<String> {
crate::text_search::extract_unicode_words(&self.verse_text)
.into_iter()
.map(str::to_owned)
.collect()
}
#[must_use]
pub fn len(&self) -> usize {
self.verse_text.chars().count()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.verse_text.is_empty()
}
#[must_use]
pub fn reference(&self) -> String {
format!(
"{} {}:{}",
self.book.full_name(),
self.chapter_number,
self.verse_number
)
}
#[must_use]
pub fn short_reference(&self) -> String {
format!(
"{}{}:{}",
self.book.abbreviation(),
self.chapter_number,
self.verse_number
)
}
#[must_use]
pub fn stats(&self) -> VerseStats {
let words = self.words();
let word_characters = words.iter().map(|word| word.chars().count()).sum::<usize>();
VerseStats {
word_count: words.len(),
character_count: self.len(),
average_word_length: if words.is_empty() {
0.0
} else {
word_characters as f64 / words.len() as f64
},
}
}
pub fn copy_with(
&self,
book: Option<BibleBook>,
chapter_number: Option<usize>,
verse_number: Option<usize>,
verse_text: Option<String>,
annotations: Option<JsonMap>,
) -> Result<Self, ModelError> {
Self::checked(
book.unwrap_or(self.book),
chapter_number.unwrap_or(self.chapter_number),
verse_number.unwrap_or(self.verse_number),
verse_text.unwrap_or_else(|| self.verse_text.clone()),
annotations.unwrap_or_else(|| self.annotations.clone()),
)
}
pub fn with_text(&self, verse_text: impl Into<String>) -> Self {
Self {
verse_text: verse_text.into(),
..self.clone()
}
}
pub fn with_annotations(&self, annotations: JsonMap) -> Result<Self, ModelError> {
self.copy_with(None, None, None, None, Some(annotations))
}
#[must_use]
pub fn to_json_value(&self) -> Value {
if self.annotations.is_empty() {
return Value::String(self.verse_text.clone());
}
let mut value = JsonMap::new();
value.insert("text".to_string(), Value::String(self.verse_text.clone()));
value.extend(self.annotations.clone());
Value::Object(value)
}
}
impl Eq for Verse {}
impl Hash for Verse {
fn hash<H: Hasher>(&self, state: &mut H) {
self.book.hash(state);
self.chapter_number.hash(state);
self.verse_number.hash(state);
self.verse_text.hash(state);
hash_json_map(&self.annotations, state);
}
}
fn sanitize_verse_text(verse_text: String) -> String {
verse_text
}
impl fmt::Display for Verse {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.verse_number, self.verse_text)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_and_accessors() {
let verse = Verse::new(BibleBook::Genesis, 1, 1, "Test".to_string());
assert_eq!(verse.book(), BibleBook::Genesis);
assert_eq!(verse.chapter(), 1);
assert_eq!(verse.text(), "Test");
assert_eq!(verse.number(), 1);
assert_eq!(format!("{}", verse), "1: Test");
}
#[test]
fn test_sanitize_verse_text() {
let verse = Verse::new(BibleBook::Genesis, 1, 1, "In {the} beginning".to_string());
assert_eq!(verse.text(), "In {the} beginning");
}
#[test]
fn test_clone_independence() {
let original = Verse::new(BibleBook::Genesis, 1, 42, "Clone me".to_string());
let cloned = original.clone();
assert_eq!(original.book(), cloned.book());
assert_eq!(original.chapter(), cloned.chapter());
assert_eq!(original.text(), cloned.text());
assert_eq!(original.number(), cloned.number());
assert_ne!(original.text().as_ptr(), cloned.text().as_ptr());
}
}