1use 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#[derive(Debug, Clone, PartialEq)]
19pub struct Chapter {
20 book: BibleBook,
21 verses: Vec<Verse>,
22 chapter_number: usize,
23 annotations: JsonMap,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub struct ChapterStats {
29 pub verse_count: usize,
31 pub total_words: usize,
33 pub average_verse_length: usize,
35}
36
37impl Chapter {
38 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 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 #[must_use]
95 pub const fn book(&self) -> BibleBook {
96 self.book
97 }
98
99 #[must_use]
101 pub const fn number(&self) -> usize {
102 self.chapter_number
103 }
104
105 #[must_use]
107 pub fn get_verses(&self) -> &[Verse] {
108 &self.verses
109 }
110
111 #[must_use]
113 pub fn verses(&self) -> &[Verse] {
114 &self.verses
115 }
116
117 #[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 #[must_use]
128 pub fn annotations(&self) -> &JsonMap {
129 &self.annotations
130 }
131
132 #[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 #[must_use]
144 pub fn contains_word(&self, word: &str) -> bool {
145 self.verses.iter().any(|verse| verse.contains_word(word))
146 }
147
148 #[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 #[must_use]
159 pub fn reference(&self) -> String {
160 format!("{} {}", self.book.full_name(), self.chapter_number)
161 }
162
163 #[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 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 pub fn with_annotations(&self, annotations: JsonMap) -> Result<Self, ModelError> {
196 self.copy_with(None, None, None, Some(annotations))
197 }
198
199 #[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}