use std::{fmt, str::FromStr};
use bible_io_references::{
Book as BibleBook, ChapterPassage, Passage, Reference, VersePassage, VerseRef,
};
use serde::{Deserialize, Serialize};
use crate::{errors::ModelError, verse::Verse};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct BibleLocation {
book: BibleBook,
chapter: usize,
verse: Option<usize>,
}
impl BibleLocation {
pub fn new(book: BibleBook, chapter: usize, verse: Option<usize>) -> Result<Self, ModelError> {
if chapter == 0 {
return Err(ModelError::new("chapter", "must be positive"));
}
if verse == Some(0) {
return Err(ModelError::new("verse", "must be positive"));
}
Ok(Self {
book,
chapter,
verse,
})
}
#[must_use]
pub fn from_verse(verse: &Verse) -> Self {
Self {
book: verse.book(),
chapter: verse.chapter(),
verse: Some(verse.number()),
}
}
#[must_use]
pub fn from_verse_ref(reference: VerseRef) -> Self {
Self {
book: reference.book(),
chapter: usize::from(reference.chapter()),
verse: Some(usize::from(reference.verse())),
}
}
#[must_use]
pub const fn book(self) -> BibleBook {
self.book
}
#[must_use]
pub const fn chapter(self) -> usize {
self.chapter
}
#[must_use]
pub const fn verse(self) -> Option<usize> {
self.verse
}
#[must_use]
pub const fn has_verse(self) -> bool {
self.verse.is_some()
}
pub fn with_book(self, book: BibleBook) -> Result<Self, ModelError> {
Self::new(book, self.chapter, self.verse)
}
pub fn with_chapter(self, chapter: usize) -> Result<Self, ModelError> {
Self::new(self.book, chapter, self.verse)
}
pub fn with_verse(self, verse: Option<usize>) -> Result<Self, ModelError> {
Self::new(self.book, self.chapter, verse)
}
#[must_use]
pub fn reference(self) -> String {
self.to_string()
}
pub fn to_passage(self) -> Result<Passage, ModelError> {
if self.verse.is_some() {
return VersePassage::new([Reference::Verse(self.to_verse_ref()?)])
.map(Passage::from)
.map_err(|error| ModelError::new("verse", error.to_string()));
}
let chapter = u16::try_from(self.chapter)
.map_err(|_| ModelError::new("chapter", "exceeds the reference limit"))?;
ChapterPassage::single(self.book, chapter)
.map(Passage::from)
.map_err(|error| ModelError::new("chapter", error.to_string()))
}
pub fn to_verse_ref(self) -> Result<VerseRef, ModelError> {
let verse = self
.verse
.ok_or_else(|| ModelError::new("verse", "a verse number is required"))?;
let chapter = u16::try_from(self.chapter)
.map_err(|_| ModelError::new("chapter", "exceeds the reference limit"))?;
let verse = u16::try_from(verse)
.map_err(|_| ModelError::new("verse", "exceeds the reference limit"))?;
VerseRef::new(self.book, chapter, verse)
.map_err(|error| ModelError::new("location", error.to_string()))
}
}
#[derive(Serialize, Deserialize)]
struct BibleLocationJson {
book: String,
chapter: usize,
#[serde(skip_serializing_if = "Option::is_none")]
verse: Option<usize>,
}
impl Serialize for BibleLocation {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
BibleLocationJson {
book: self.book.abbreviation().to_string(),
chapter: self.chapter,
verse: self.verse,
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for BibleLocation {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = BibleLocationJson::deserialize(deserializer)?;
let book = parse_book_identifier(&value.book)
.ok_or_else(|| serde::de::Error::custom("unknown Bible book"))?;
Self::new(book, value.chapter, value.verse).map_err(serde::de::Error::custom)
}
}
impl fmt::Display for BibleLocation {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{} {}", self.book.full_name(), self.chapter)?;
if let Some(verse) = self.verse {
write!(formatter, ":{verse}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BibleVerseKey {
edition_id: String,
location: BibleLocation,
}
impl BibleVerseKey {
pub fn new(edition_id: impl Into<String>, location: BibleLocation) -> Result<Self, ModelError> {
let edition_id = edition_id.into();
if edition_id.trim().is_empty() || edition_id.trim() != edition_id {
return Err(ModelError::new(
"edition_id",
"must be non-blank and have no surrounding whitespace",
));
}
if !location.has_verse() {
return Err(ModelError::new("location", "must identify a verse"));
}
Ok(Self {
edition_id,
location,
})
}
pub fn from_verse(edition_id: impl Into<String>, verse: &Verse) -> Result<Self, ModelError> {
Self::new(edition_id, BibleLocation::from_verse(verse))
}
#[must_use]
pub fn edition_id(&self) -> &str {
&self.edition_id
}
#[must_use]
pub const fn location(&self) -> BibleLocation {
self.location
}
pub fn to_verse_ref(&self) -> Result<VerseRef, ModelError> {
self.location.to_verse_ref()
}
pub fn with_edition_id(&self, edition_id: impl Into<String>) -> Result<Self, ModelError> {
Self::new(edition_id, self.location)
}
pub fn with_location(&self, location: BibleLocation) -> Result<Self, ModelError> {
Self::new(self.edition_id.clone(), location)
}
}
impl fmt::Display for BibleVerseKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}:{}", self.edition_id, self.location)
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct BibleVerseKeyJson {
edition_id: String,
location: BibleLocation,
}
impl<'de> Deserialize<'de> for BibleVerseKey {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = BibleVerseKeyJson::deserialize(deserializer)?;
Self::new(value.edition_id, value.location).map_err(serde::de::Error::custom)
}
}
pub(crate) fn parse_book_identifier(value: &str) -> Option<BibleBook> {
let trimmed = value.trim();
BibleBook::from_abbreviation(trimmed)
.or_else(|| BibleBook::from_osis(trimmed))
.or_else(|| BibleBook::from_usfm(trimmed))
.or_else(|| BibleBook::from_str(trimmed).ok())
.or_else(|| {
BibleBook::ALL
.iter()
.copied()
.find(|book| book.full_name().eq_ignore_ascii_case(trimmed))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn location_and_key_round_trip() {
let location = BibleLocation::new(BibleBook::John, 3, Some(16)).unwrap();
let key = BibleVerseKey::new("eng-test", location).unwrap();
let json = serde_json::to_string(&key).unwrap();
assert_eq!(serde_json::from_str::<BibleVerseKey>(&json).unwrap(), key);
assert_eq!(key.to_verse_ref().unwrap().book(), BibleBook::John);
}
}