Skip to main content

bible_io/
chapter.rs

1//! A numbered collection of verses.
2
3use std::{
4    fmt,
5    hash::{Hash, Hasher},
6};
7
8use serde_json::Value;
9
10use crate::{
11    bible_books_enum::BibleBook,
12    errors::ModelError,
13    json_value::{hash_json_map, validate_annotations, JsonMap},
14    verse::Verse,
15};
16
17/// Represents a chapter from a Bible book.
18#[derive(Debug, Clone, PartialEq)]
19pub struct Chapter {
20    book: BibleBook,
21    verses: Vec<Verse>,
22    chapter_number: usize,
23    annotations: JsonMap,
24}
25
26/// Aggregate statistics for one chapter.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub struct ChapterStats {
29    /// Number of verses.
30    pub verse_count: usize,
31    /// Number of Unicode word tokens.
32    pub total_words: usize,
33    /// Mean verse length in Unicode scalar values, rounded to the nearest integer.
34    pub average_verse_length: usize,
35}
36
37impl Chapter {
38    /// Creates a chapter using the first verse's book for compatibility.
39    ///
40    /// For an empty chapter, Genesis is used as the compatibility book. New
41    /// code should prefer [`Chapter::checked`].
42    pub fn new(verses: Vec<Verse>, chapter_number: usize) -> Self {
43        let book = verses.first().map_or(BibleBook::Genesis, Verse::book);
44        Self::checked(book, chapter_number, verses, JsonMap::new())
45            .expect("Chapter::new requires positive, unique, matching verse locations")
46    }
47
48    /// Creates a sorted chapter and validates every verse relationship.
49    pub fn checked(
50        book: BibleBook,
51        chapter_number: usize,
52        mut verses: Vec<Verse>,
53        annotations: JsonMap,
54    ) -> Result<Self, ModelError> {
55        if chapter_number == 0 {
56            return Err(ModelError::new("chapter_number", "must be positive"));
57        }
58        validate_annotations(&annotations, &["verses"])?;
59        verses.sort_by_key(Verse::number);
60        let mut previous = None;
61        for verse in &verses {
62            if verse.number() == 0 {
63                return Err(ModelError::new("verses", "verse numbers must be positive"));
64            }
65            if verse.book() != book {
66                return Err(ModelError::new(
67                    "verses",
68                    format!("verse {} belongs to another book", verse.number()),
69                ));
70            }
71            if verse.chapter() != chapter_number {
72                return Err(ModelError::new(
73                    "verses",
74                    format!("verse {} belongs to another chapter", verse.number()),
75                ));
76            }
77            if previous == Some(verse.number()) {
78                return Err(ModelError::new(
79                    "verses",
80                    format!("duplicate verse number {}", verse.number()),
81                ));
82            }
83            previous = Some(verse.number());
84        }
85        Ok(Self {
86            book,
87            verses,
88            chapter_number,
89            annotations,
90        })
91    }
92
93    /// Returns the book containing this chapter.
94    #[must_use]
95    pub const fn book(&self) -> BibleBook {
96        self.book
97    }
98
99    /// Returns this chapter's declared number within its book.
100    #[must_use]
101    pub const fn number(&self) -> usize {
102        self.chapter_number
103    }
104
105    /// Returns all verses in declared numeric order.
106    #[must_use]
107    pub fn get_verses(&self) -> &[Verse] {
108        &self.verses
109    }
110
111    /// Return all verses in declared numeric order.
112    #[must_use]
113    pub fn verses(&self) -> &[Verse] {
114        &self.verses
115    }
116
117    /// Returns a verse by its declared number, including in sparse chapters.
118    #[must_use]
119    pub fn get_verse(&self, verse_number: usize) -> Option<&Verse> {
120        self.verses
121            .binary_search_by_key(&verse_number, Verse::number)
122            .ok()
123            .map(|index| &self.verses[index])
124    }
125
126    /// Returns the immutable chapter annotations.
127    #[must_use]
128    pub fn annotations(&self) -> &JsonMap {
129        &self.annotations
130    }
131
132    /// Returns verses containing one complete normalized Unicode word.
133    #[must_use]
134    pub fn search(&self, word: &str) -> Vec<Verse> {
135        self.verses
136            .iter()
137            .filter(|verse| verse.contains_word(word))
138            .cloned()
139            .collect()
140    }
141
142    /// Return whether any verse contains one complete normalized word.
143    #[must_use]
144    pub fn contains_word(&self, word: &str) -> bool {
145        self.verses.iter().any(|verse| verse.contains_word(word))
146    }
147
148    /// Return borrowed verses containing one complete normalized word.
149    #[must_use]
150    pub fn verses_containing(&self, word: &str) -> Vec<&Verse> {
151        self.verses
152            .iter()
153            .filter(|verse| verse.contains_word(word))
154            .collect()
155    }
156
157    /// Return the canonical chapter reference.
158    #[must_use]
159    pub fn reference(&self) -> String {
160        format!("{} {}", self.book.full_name(), self.chapter_number)
161    }
162
163    /// Derive aggregate text statistics.
164    #[must_use]
165    pub fn stats(&self) -> ChapterStats {
166        let character_count = self.verses.iter().map(Verse::len).sum::<usize>();
167        ChapterStats {
168            verse_count: self.verses.len(),
169            total_words: self.verses.iter().map(|verse| verse.words().len()).sum(),
170            average_verse_length: if self.verses.is_empty() {
171                0
172            } else {
173                (character_count as f64 / self.verses.len() as f64).round() as usize
174            },
175        }
176    }
177
178    /// Return a validated copy with replacement values.
179    pub fn copy_with(
180        &self,
181        book: Option<BibleBook>,
182        chapter_number: Option<usize>,
183        verses: Option<Vec<Verse>>,
184        annotations: Option<JsonMap>,
185    ) -> Result<Self, ModelError> {
186        Self::checked(
187            book.unwrap_or(self.book),
188            chapter_number.unwrap_or(self.chapter_number),
189            verses.unwrap_or_else(|| self.verses.clone()),
190            annotations.unwrap_or_else(|| self.annotations.clone()),
191        )
192    }
193
194    /// Return a validated copy with replacement annotations.
195    pub fn with_annotations(&self, annotations: JsonMap) -> Result<Self, ModelError> {
196        self.copy_with(None, None, None, Some(annotations))
197    }
198
199    /// Encodes this chapter in the schema's compatible JSON shape.
200    #[must_use]
201    pub fn to_json_value(&self) -> Value {
202        let verses = self
203            .verses
204            .iter()
205            .map(|verse| (verse.number().to_string(), verse.to_json_value()))
206            .collect();
207        if self.annotations.is_empty() {
208            return Value::Object(verses);
209        }
210        let mut object = self.annotations.clone();
211        object.insert("verses".to_string(), Value::Object(verses));
212        Value::Object(object)
213    }
214}
215
216impl Eq for Chapter {}
217
218impl Hash for Chapter {
219    fn hash<H: Hasher>(&self, state: &mut H) {
220        self.book.hash(state);
221        self.chapter_number.hash(state);
222        self.verses.hash(state);
223        hash_json_map(&self.annotations, state);
224    }
225}
226
227impl fmt::Display for Chapter {
228    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
229        let verses = self
230            .verses
231            .iter()
232            .map(ToString::to_string)
233            .collect::<Vec<_>>()
234            .join("\n");
235        write!(formatter, "Chapter {}:\n{verses}", self.chapter_number)
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn sparse_lookup_uses_declared_numbers() {
245        let verses = vec![
246            Verse::checked(BibleBook::Genesis, 3, 9, "Nine", JsonMap::new()).unwrap(),
247            Verse::checked(BibleBook::Genesis, 3, 2, "Two", JsonMap::new()).unwrap(),
248        ];
249        let chapter = Chapter::checked(BibleBook::Genesis, 3, verses, JsonMap::new()).unwrap();
250        assert_eq!(chapter.get_verse(2).unwrap().text(), "Two");
251        assert_eq!(chapter.get_verse(9).unwrap().text(), "Nine");
252        assert!(chapter.get_verse(1).is_none());
253    }
254}