1use core::time::Duration;
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22
23use blockworx_doc::rev::Rev;
24
25use crate::tools::names::ToolName;
26use crate::tutorial::reader::{Chapter, Tutorial};
27
28#[derive(Default)]
31pub struct Library {
32 lessons: Vec<Lesson>,
33}
34
35pub struct Lesson {
38 pub root: PathBuf,
40 pub title: String,
42 pub chapters: Vec<Chapter>,
43 pub duration: Duration,
47 pub teaches: Vec<ToolName>,
48}
49
50impl Library {
51 pub fn shipped() -> Self {
57 Self::under(&shipped_root())
58 }
59
60 pub fn under(dir: &Path) -> Self {
63 let Ok(entries) = std::fs::read_dir(dir) else {
64 return Library::default();
65 };
66 let mut roots: Vec<PathBuf> = entries
67 .filter_map(Result::ok)
68 .map(|entry| entry.path())
69 .filter(|path| path.extension().is_some_and(|ext| ext == "bwx"))
70 .collect();
71 roots.sort();
72 Library {
73 lessons: roots.iter().filter_map(|root| Lesson::open(root)).collect(),
74 }
75 }
76
77 pub fn lessons(&self) -> &[Lesson] {
78 &self.lessons
79 }
80
81 pub fn is_empty(&self) -> bool {
82 self.lessons.is_empty()
83 }
84
85 pub fn matching<'a>(&'a self, query: &str) -> Vec<(&'a Lesson, Vec<&'a Chapter>)> {
92 let query = query.trim().to_lowercase();
93 self.lessons
94 .iter()
95 .filter_map(|lesson| {
96 if query.is_empty() || lesson.title.to_lowercase().contains(&query) {
97 return Some((lesson, lesson.chapters.iter().collect()));
98 }
99 let hits: Vec<&Chapter> = lesson
100 .chapters
101 .iter()
102 .filter(|chapter| chapter.title.to_lowercase().contains(&query))
103 .collect();
104 (!hits.is_empty()).then_some((lesson, hits))
105 })
106 .collect()
107 }
108
109 pub fn teaching(&self, tool: ToolName) -> impl Iterator<Item = &Lesson> {
112 self.lessons
113 .iter()
114 .filter(move |lesson| lesson.teaches.contains(&tool))
115 }
116
117 pub fn at(&self, root: &Path) -> Option<&Lesson> {
119 self.lessons.iter().find(|lesson| lesson.root == root)
120 }
121}
122
123impl Lesson {
124 fn open(root: &Path) -> Option<Self> {
125 let tutorial = Tutorial::open(root)
126 .inspect_err(|error| tracing::warn!("{}: {error}", root.display()))
127 .ok()?;
128 let chapters = tutorial.chapters().to_vec();
129 Some(Lesson {
130 root: root.to_path_buf(),
131 title: tutorial.name().to_owned(),
132 duration: chapters.iter().map(|chapter| chapter.duration).sum(),
133 teaches: tutorial.teaches(),
134 chapters,
135 })
136 }
137
138 pub fn chapter_at(&self, nth: usize) -> Option<Rev> {
140 self.chapters.get(nth).map(|chapter| chapter.at)
141 }
142
143 pub fn chapter_of(&self, rev: Rev) -> Option<usize> {
147 self.chapters.iter().rposition(|chapter| chapter.at <= rev)
148 }
149}
150
151#[derive(Clone, Default, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
158pub struct Watched(BTreeMap<String, usize>);
159
160#[derive(Clone, Copy, PartialEq, Eq, Debug)]
162pub enum Progress {
163 Fresh,
165 Resume(usize),
167 Watched,
169}
170
171impl Watched {
172 pub fn reached(&mut self, title: &str, nth: usize) {
175 let furthest = self.0.entry(title.to_owned()).or_default();
176 *furthest = (*furthest).max(nth);
177 }
178
179 pub fn of(&self, title: &str, chapters: usize) -> Progress {
181 match self.0.get(title) {
182 None => Progress::Fresh,
183 Some(&nth) if nth + 1 >= chapters => Progress::Watched,
184 Some(&nth) => Progress::Resume(nth),
185 }
186 }
187
188 pub fn clear(&mut self) {
191 self.0.clear();
192 }
193}
194
195pub fn clock(of: Duration) -> String {
200 let seconds = of.as_secs();
201 format!("{}:{:02}", seconds / 60, seconds % 60)
202}
203
204fn shipped_root() -> PathBuf {
210 let beside_the_binary = std::env::current_exe()
211 .ok()
212 .and_then(|exe| exe.parent().map(|dir| dir.join("tutorials")))
213 .filter(|dir| dir.is_dir());
214 beside_the_binary
215 .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/tutorials"))
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 fn library() -> Library {
223 let library = Library::shipped();
224 assert_eq!(
225 library.lessons().len(),
226 3,
227 "precondition: the build ships three tutorials",
228 );
229 library
230 }
231
232 #[test]
235 fn the_library_reads_every_shipped_tutorial() {
236 for lesson in library().lessons() {
237 assert!(!lesson.title.is_empty(), "a lesson with no name");
238 assert!(
239 !lesson.chapters.is_empty(),
240 "{} has no chapter to navigate by",
241 lesson.title,
242 );
243 assert_eq!(
244 lesson.duration,
245 lesson.chapters.iter().map(|c| c.duration).sum::<Duration>(),
246 "{}'s length is not the sum of its chapters",
247 lesson.title,
248 );
249 assert!(
250 !lesson.teaches.is_empty(),
251 "{} does not say what it teaches",
252 lesson.title,
253 );
254 }
255 }
256
257 #[test]
261 fn the_search_matches_chapter_titles() {
262 let library = library();
263 let hits = library.matching("opposite");
264 let [(lesson, chapters)] = hits.as_slice() else {
265 panic!("\"opposite\" reached {} lessons, not one", hits.len());
266 };
267 assert_eq!(lesson.title, "first-block");
268 assert!(
269 chapters
270 .iter()
271 .all(|chapter| chapter.title.to_lowercase().contains("opposite")),
272 "the search offered chapters that do not match: {:?}",
273 chapters.iter().map(|c| &c.title).collect::<Vec<_>>(),
274 );
275 assert!(
276 chapters.len() < lesson.chapters.len(),
277 "precondition: the lesson has chapters the query does not reach",
278 );
279 }
280
281 #[test]
284 fn a_lesson_found_by_name_keeps_all_its_chapters() {
285 let library = library();
286 let hits = library.matching("first-route");
287 let [(lesson, chapters)] = hits.as_slice() else {
288 panic!("\"first-route\" reached {} lessons, not one", hits.len());
289 };
290 assert_eq!(chapters.len(), lesson.chapters.len());
291 assert_eq!(library.matching("").len(), 3, "an empty query is the shelf");
292 assert!(library.matching("zzzz").is_empty());
293 }
294
295 #[test]
297 fn a_lesson_is_found_by_the_tool_it_teaches() {
298 let library = library();
299 let taught: Vec<&str> = library
300 .teaching(ToolName::NewBlock)
301 .map(|lesson| lesson.title.as_str())
302 .collect();
303 assert_eq!(taught, ["first-block"]);
304 assert_eq!(library.teaching(ToolName::AddText).count(), 0);
305 }
306
307 #[test]
310 fn the_watched_list_offers_a_resume_until_the_end() {
311 let mut watched = Watched::default();
312 assert_eq!(watched.of("first-block", 4), Progress::Fresh);
313
314 watched.reached("first-block", 2);
315 assert_eq!(watched.of("first-block", 4), Progress::Resume(2));
316
317 watched.reached("first-block", 1);
318 assert_eq!(
319 watched.of("first-block", 4),
320 Progress::Resume(2),
321 "going back a chapter lost the viewer's place",
322 );
323
324 watched.reached("first-block", 3);
325 assert_eq!(watched.of("first-block", 4), Progress::Watched);
326 assert_eq!(watched.of("first-route", 5), Progress::Fresh);
327
328 watched.clear();
329 assert_eq!(watched.of("first-block", 4), Progress::Fresh);
330 }
331
332 #[test]
335 fn a_length_reads_as_minutes_and_seconds() {
336 assert_eq!(clock(Duration::ZERO), "0:00");
337 assert_eq!(clock(Duration::from_millis(9_900)), "0:09");
338 assert_eq!(clock(Duration::from_secs(65)), "1:05");
339 assert_eq!(clock(Duration::from_mins(10)), "10:00");
340 }
341
342 #[test]
347 fn a_rev_belongs_to_the_chapter_that_opened_it() {
348 let library = library();
349 let lesson = library
350 .lessons()
351 .iter()
352 .find(|lesson| lesson.title == "first-block")
353 .expect("the first-block lesson");
354 assert!(
355 lesson.chapters.len() > 1,
356 "precondition: there is a second chapter to fall into",
357 );
358 assert_eq!(lesson.chapter_of(Rev::ZERO), None);
359 assert_eq!(lesson.chapter_of(lesson.chapters[0].at), Some(0));
360 assert_eq!(
361 lesson.chapter_of(lesson.chapters[1].at),
362 Some(1),
363 "a chapter's own opening rev belongs to it",
364 );
365 assert_eq!(
366 lesson.chapter_of(lesson.chapters[1].at.prev().expect("a rev before it")),
367 Some(0),
368 "the rev before a chapter opens still belongs to the one above it",
369 );
370 assert_eq!(lesson.chapter_at(1), Some(lesson.chapters[1].at));
371 assert_eq!(lesson.chapter_at(99), None);
372 }
373}