Skip to main content

toku_core/
book.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// A book in the user's library. Each row represents an edition (Book = Edition).
6/// Set `work_id` to link this edition to a `Work` for grouping.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Book {
9    pub id: Uuid,
10    pub title: String,
11    pub subtitle: Option<String>,
12    pub description: Option<String>,
13    pub page_count: Option<i32>,
14    pub pub_date: Option<String>,
15    pub language: Option<String>,
16    pub format: BookFormat,
17    /// Duration in minutes — only meaningful for audiobooks.
18    pub duration_minutes: Option<i32>,
19    pub cover_hash: Option<String>,
20    /// Links this edition to a Work (for grouping editions).
21    pub work_id: Option<Uuid>,
22    pub status: ReadingStatus,
23    pub rating: Option<i32>,
24    pub created_at: DateTime<Utc>,
25    pub updated_at: DateTime<Utc>,
26}
27
28impl Book {
29    pub fn new(title: impl Into<String>) -> Self {
30        let now = Utc::now();
31        Self {
32            id: Uuid::now_v7(),
33            title: title.into(),
34            subtitle: None,
35            description: None,
36            page_count: None,
37            pub_date: None,
38            language: None,
39            format: BookFormat::Physical,
40            duration_minutes: None,
41            cover_hash: None,
42            work_id: None,
43            status: ReadingStatus::WantToRead,
44            rating: None,
45            created_at: now,
46            updated_at: now,
47        }
48    }
49}
50
51/// Physical book, ebook, or audiobook.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53pub enum BookFormat {
54    Physical,
55    Ebook,
56    Audiobook,
57}
58
59impl BookFormat {
60    pub fn as_str(&self) -> &'static str {
61        match self {
62            Self::Physical => "physical",
63            Self::Ebook => "ebook",
64            Self::Audiobook => "audiobook",
65        }
66    }
67}
68
69impl std::fmt::Display for BookFormat {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.write_str(self.as_str())
72    }
73}
74
75impl std::str::FromStr for BookFormat {
76    type Err = crate::TokuError;
77
78    fn from_str(s: &str) -> Result<Self, Self::Err> {
79        match s.to_lowercase().as_str() {
80            "physical" => Ok(Self::Physical),
81            "ebook" => Ok(Self::Ebook),
82            "audiobook" => Ok(Self::Audiobook),
83            _ => Err(crate::TokuError::InvalidFormat(s.to_string())),
84        }
85    }
86}
87
88/// A reading session tracks a single reading attempt of a book.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ReadingSession {
91    pub id: Uuid,
92    pub book_id: Uuid,
93    pub started_at: DateTime<Utc>,
94    pub finished_at: Option<DateTime<Utc>>,
95    pub start_page: Option<i32>,
96    pub end_page: Option<i32>,
97    pub rating: Option<i32>,
98    pub notes: Option<String>,
99    pub created_at: DateTime<Utc>,
100}
101
102impl ReadingSession {
103    pub fn new(book_id: Uuid) -> Self {
104        let now = Utc::now();
105        Self {
106            id: Uuid::now_v7(),
107            book_id,
108            started_at: now,
109            finished_at: None,
110            start_page: None,
111            end_page: None,
112            rating: None,
113            notes: None,
114            created_at: now,
115        }
116    }
117}
118
119/// Type of reading progress entry.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121pub enum ProgressType {
122    Page,
123    Percent,
124    Chapter,
125    /// Duration in minutes (for audiobooks).
126    Duration,
127}
128
129impl ProgressType {
130    pub fn as_str(&self) -> &'static str {
131        match self {
132            Self::Page => "page",
133            Self::Percent => "percent",
134            Self::Chapter => "chapter",
135            Self::Duration => "duration",
136        }
137    }
138}
139
140impl std::fmt::Display for ProgressType {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        f.write_str(self.as_str())
143    }
144}
145
146impl std::str::FromStr for ProgressType {
147    type Err = crate::TokuError;
148
149    fn from_str(s: &str) -> Result<Self, Self::Err> {
150        match s.to_lowercase().as_str() {
151            "page" => Ok(Self::Page),
152            "percent" => Ok(Self::Percent),
153            "chapter" => Ok(Self::Chapter),
154            "duration" => Ok(Self::Duration),
155            _ => Err(crate::TokuError::InvalidProgressType(s.to_string())),
156        }
157    }
158}
159
160/// A timestamped reading progress entry.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ReadingProgress {
163    pub id: Uuid,
164    pub book_id: Uuid,
165    pub session_id: Option<Uuid>,
166    pub progress_type: ProgressType,
167    pub value: i32,
168    pub note: Option<String>,
169    pub logged_at: DateTime<Utc>,
170    pub created_at: DateTime<Utc>,
171}
172
173impl ReadingProgress {
174    pub fn new(book_id: Uuid, progress_type: ProgressType, value: i32) -> Self {
175        let now = Utc::now();
176        Self {
177            id: Uuid::now_v7(),
178            book_id,
179            session_id: None,
180            progress_type,
181            value,
182            note: None,
183            logged_at: now,
184            created_at: now,
185        }
186    }
187}
188
189/// Parse a human-friendly duration string into total minutes.
190///
191/// Accepted formats: `5h30m`, `330m`, `5.5h`, `5h`, `90`.
192pub fn parse_duration_to_minutes(s: &str) -> Result<i32, crate::TokuError> {
193    let s = s.trim();
194
195    // Try `Xh Ym` or `XhYm`
196    if let Some(h_pos) = s.find('h') {
197        let hours_str = &s[..h_pos];
198        let rest = s[h_pos + 1..].trim();
199
200        if rest.is_empty() {
201            // Could be fractional hours like "5.5h"
202            let hours: f64 = hours_str
203                .parse()
204                .map_err(|_| crate::TokuError::InvalidDuration(s.to_string()))?;
205            return Ok((hours * 60.0).round() as i32);
206        }
207
208        let mins_str = rest.trim_end_matches('m');
209        let hours: f64 = hours_str
210            .parse()
211            .map_err(|_| crate::TokuError::InvalidDuration(s.to_string()))?;
212        let mins: f64 = mins_str
213            .parse()
214            .map_err(|_| crate::TokuError::InvalidDuration(s.to_string()))?;
215        return Ok((hours * 60.0 + mins).round() as i32);
216    }
217
218    // Try `Xm`
219    if let Some(m_str) = s.strip_suffix('m') {
220        let mins: f64 = m_str
221            .parse()
222            .map_err(|_| crate::TokuError::InvalidDuration(s.to_string()))?;
223        return Ok(mins.round() as i32);
224    }
225
226    // Plain number = minutes
227    let mins: f64 = s
228        .parse()
229        .map_err(|_| crate::TokuError::InvalidDuration(s.to_string()))?;
230    Ok(mins.round() as i32)
231}
232
233/// Reading lifecycle states.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
235pub enum ReadingStatus {
236    WantToRead,
237    Reading,
238    Read,
239    Abandoned,
240    OnHold,
241}
242
243impl ReadingStatus {
244    pub fn as_str(&self) -> &'static str {
245        match self {
246            Self::WantToRead => "want-to-read",
247            Self::Reading => "reading",
248            Self::Read => "read",
249            Self::Abandoned => "abandoned",
250            Self::OnHold => "on-hold",
251        }
252    }
253
254    /// Returns whether a transition from this status to `target` is valid.
255    pub fn can_transition_to(&self, target: &ReadingStatus) -> bool {
256        matches!(
257            (self, target),
258            (ReadingStatus::WantToRead, ReadingStatus::Reading)
259                | (ReadingStatus::Reading, ReadingStatus::Read)
260                | (ReadingStatus::Reading, ReadingStatus::Abandoned)
261                | (ReadingStatus::Reading, ReadingStatus::OnHold)
262                | (ReadingStatus::OnHold, ReadingStatus::Reading)
263                | (ReadingStatus::Abandoned, ReadingStatus::Reading)
264                | (ReadingStatus::Read, ReadingStatus::Reading) // re-read
265        )
266    }
267}
268
269impl std::fmt::Display for ReadingStatus {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        f.write_str(self.as_str())
272    }
273}
274
275impl std::str::FromStr for ReadingStatus {
276    type Err = crate::TokuError;
277
278    fn from_str(s: &str) -> Result<Self, Self::Err> {
279        match s.to_lowercase().as_str() {
280            "want-to-read" | "to-read" => Ok(Self::WantToRead),
281            "reading" | "currently-reading" => Ok(Self::Reading),
282            "read" => Ok(Self::Read),
283            "abandoned" | "dnf" => Ok(Self::Abandoned),
284            "on-hold" | "paused" => Ok(Self::OnHold),
285            _ => Err(crate::TokuError::InvalidStatus(s.to_string())),
286        }
287    }
288}
289
290/// Contributor role for a book.
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
292pub enum ContributorRole {
293    Author,
294    Editor,
295    Translator,
296    Illustrator,
297    Narrator,
298}
299
300impl ContributorRole {
301    pub fn as_str(&self) -> &'static str {
302        match self {
303            Self::Author => "author",
304            Self::Editor => "editor",
305            Self::Translator => "translator",
306            Self::Illustrator => "illustrator",
307            Self::Narrator => "narrator",
308        }
309    }
310}
311
312impl std::fmt::Display for ContributorRole {
313    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        f.write_str(self.as_str())
315    }
316}
317
318impl std::str::FromStr for ContributorRole {
319    type Err = crate::TokuError;
320
321    fn from_str(s: &str) -> Result<Self, Self::Err> {
322        match s.to_lowercase().as_str() {
323            "author" => Ok(Self::Author),
324            "editor" => Ok(Self::Editor),
325            "translator" => Ok(Self::Translator),
326            "illustrator" => Ok(Self::Illustrator),
327            "narrator" => Ok(Self::Narrator),
328            _ => Err(crate::TokuError::InvalidRole(s.to_string())),
329        }
330    }
331}
332
333/// An author or other contributor.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct Author {
336    pub id: Uuid,
337    pub name: String,
338    pub sort_name: Option<String>,
339}
340
341impl Author {
342    pub fn new(name: impl Into<String>) -> Self {
343        let name = name.into();
344        let sort_name = guess_sort_name(&name);
345        Self {
346            id: Uuid::now_v7(),
347            name,
348            sort_name: Some(sort_name),
349        }
350    }
351}
352
353/// Guess a sort name from a display name: "Ursula K. Le Guin" → "Le Guin, Ursula K."
354fn guess_sort_name(name: &str) -> String {
355    let parts: Vec<&str> = name.split_whitespace().collect();
356    if parts.len() <= 1 {
357        return name.to_string();
358    }
359    let last = parts.last().unwrap();
360    let rest: Vec<&str> = parts[..parts.len() - 1].to_vec();
361    format!("{}, {}", last, rest.join(" "))
362}
363
364/// A user-defined shelf for organizing books (e.g. "Favorites", "To Re-read").
365/// Smart shelves have `is_smart = true` and a `smart_filter` that dynamically matches books.
366#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct Shelf {
368    pub id: Uuid,
369    pub name: String,
370    pub is_smart: bool,
371    pub smart_filter: Option<String>,
372    pub created_at: DateTime<Utc>,
373}
374
375impl Shelf {
376    pub fn new(name: impl Into<String>) -> Self {
377        Self {
378            id: Uuid::now_v7(),
379            name: name.into(),
380            is_smart: false,
381            smart_filter: None,
382            created_at: Utc::now(),
383        }
384    }
385
386    pub fn new_smart(name: impl Into<String>, filter_json: String) -> Self {
387        Self {
388            id: Uuid::now_v7(),
389            name: name.into(),
390            is_smart: true,
391            smart_filter: Some(filter_json),
392            created_at: Utc::now(),
393        }
394    }
395}
396
397/// Type of tag: general, mood, pace, or content warning.
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
399pub enum TagType {
400    General,
401    Mood,
402    Pace,
403    ContentWarning,
404}
405
406impl TagType {
407    pub fn as_str(&self) -> &'static str {
408        match self {
409            Self::General => "general",
410            Self::Mood => "mood",
411            Self::Pace => "pace",
412            Self::ContentWarning => "content_warning",
413        }
414    }
415}
416
417impl std::fmt::Display for TagType {
418    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
419        f.write_str(self.as_str())
420    }
421}
422
423impl std::str::FromStr for TagType {
424    type Err = crate::TokuError;
425
426    fn from_str(s: &str) -> Result<Self, Self::Err> {
427        match s.to_lowercase().as_str() {
428            "general" => Ok(Self::General),
429            "mood" => Ok(Self::Mood),
430            "pace" => Ok(Self::Pace),
431            "content_warning" | "content-warning" | "cw" => Ok(Self::ContentWarning),
432            _ => Err(crate::TokuError::InvalidTagType(s.to_string())),
433        }
434    }
435}
436
437/// Pace rating for a book: fast, medium, or slow.
438#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
439pub enum PaceRating {
440    Fast,
441    Medium,
442    Slow,
443}
444
445impl PaceRating {
446    pub fn as_str(&self) -> &'static str {
447        match self {
448            Self::Fast => "fast",
449            Self::Medium => "medium",
450            Self::Slow => "slow",
451        }
452    }
453}
454
455impl std::fmt::Display for PaceRating {
456    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457        f.write_str(self.as_str())
458    }
459}
460
461impl std::str::FromStr for PaceRating {
462    type Err = crate::TokuError;
463
464    fn from_str(s: &str) -> Result<Self, Self::Err> {
465        match s.to_lowercase().as_str() {
466            "fast" => Ok(Self::Fast),
467            "medium" | "med" => Ok(Self::Medium),
468            "slow" => Ok(Self::Slow),
469            _ => Err(crate::TokuError::InvalidPaceRating(s.to_string())),
470        }
471    }
472}
473
474/// A user-defined tag for categorizing books (e.g. "sci-fi", "Hugo winner").
475/// Tag names are case-insensitive. Tags are unique by `(name, tag_type)`.
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct Tag {
478    pub id: Uuid,
479    pub name: String,
480    pub tag_type: TagType,
481    pub created_at: DateTime<Utc>,
482}
483
484impl Tag {
485    pub fn new(name: impl Into<String>) -> Self {
486        Self {
487            id: Uuid::now_v7(),
488            name: name.into(),
489            tag_type: TagType::General,
490            created_at: Utc::now(),
491        }
492    }
493
494    pub fn with_type(name: impl Into<String>, tag_type: TagType) -> Self {
495        Self {
496            id: Uuid::now_v7(),
497            name: name.into(),
498            tag_type,
499            created_at: Utc::now(),
500        }
501    }
502}
503
504/// A book-to-author relationship with role and ordering.
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct BookAuthor {
507    pub book_id: Uuid,
508    pub author_id: Uuid,
509    pub role: ContributorRole,
510    pub position: i32,
511}
512
513/// A named series of books.
514#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct Series {
516    pub id: Uuid,
517    pub name: String,
518    pub total_books: Option<i32>,
519}
520
521/// A book's position within a series. Position is TEXT to handle "1.5", "2a", etc.
522#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct BookSeries {
524    pub book_id: Uuid,
525    pub series_id: Uuid,
526    pub position: Option<String>,
527}
528
529/// A Work groups multiple editions (Books) of the same creative work.
530/// E.g. "Dune" hardcover, paperback, and Kindle editions share one Work.
531#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct Work {
533    pub id: Uuid,
534    pub title: String,
535    pub original_language: Option<String>,
536    pub first_published: Option<String>,
537    pub created_at: DateTime<Utc>,
538}
539
540impl Work {
541    pub fn new(title: impl Into<String>) -> Self {
542        Self {
543            id: Uuid::now_v7(),
544            title: title.into(),
545            original_language: None,
546            first_published: None,
547            created_at: Utc::now(),
548        }
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    #[test]
557    fn book_new_has_defaults() {
558        let book = Book::new("Dune");
559        assert_eq!(book.title, "Dune");
560        assert_eq!(book.status, ReadingStatus::WantToRead);
561        assert_eq!(book.format, BookFormat::Physical);
562        assert!(book.subtitle.is_none());
563        assert!(book.rating.is_none());
564    }
565
566    #[test]
567    fn work_new_has_defaults() {
568        let work = Work::new("Dune");
569        assert_eq!(work.title, "Dune");
570        assert!(work.original_language.is_none());
571        assert!(work.first_published.is_none());
572    }
573
574    #[test]
575    fn author_sort_name() {
576        // Naive heuristic: last word becomes sort key.
577        // "Le Guin" is a known limitation — users can manually set sort_name.
578        let author = Author::new("Frank Herbert");
579        assert_eq!(author.sort_name.as_deref(), Some("Herbert, Frank"));
580    }
581
582    #[test]
583    fn author_single_name() {
584        let author = Author::new("Voltaire");
585        assert_eq!(author.sort_name.as_deref(), Some("Voltaire"));
586    }
587
588    #[test]
589    fn reading_status_roundtrip() {
590        for status in [
591            ReadingStatus::WantToRead,
592            ReadingStatus::Reading,
593            ReadingStatus::Read,
594            ReadingStatus::Abandoned,
595            ReadingStatus::OnHold,
596        ] {
597            let parsed: ReadingStatus = status.as_str().parse().unwrap();
598            assert_eq!(parsed, status);
599        }
600    }
601
602    #[test]
603    fn book_format_roundtrip() {
604        for fmt in [
605            BookFormat::Physical,
606            BookFormat::Ebook,
607            BookFormat::Audiobook,
608        ] {
609            let parsed: BookFormat = fmt.as_str().parse().unwrap();
610            assert_eq!(parsed, fmt);
611        }
612    }
613
614    #[test]
615    fn reading_status_goodreads_aliases() {
616        assert_eq!(
617            "currently-reading".parse::<ReadingStatus>().unwrap(),
618            ReadingStatus::Reading
619        );
620        assert_eq!(
621            "to-read".parse::<ReadingStatus>().unwrap(),
622            ReadingStatus::WantToRead
623        );
624        assert_eq!(
625            "dnf".parse::<ReadingStatus>().unwrap(),
626            ReadingStatus::Abandoned
627        );
628    }
629
630    // --- State machine tests ---
631
632    #[test]
633    fn valid_transitions() {
634        let valid = [
635            (ReadingStatus::WantToRead, ReadingStatus::Reading),
636            (ReadingStatus::Reading, ReadingStatus::Read),
637            (ReadingStatus::Reading, ReadingStatus::Abandoned),
638            (ReadingStatus::Reading, ReadingStatus::OnHold),
639            (ReadingStatus::OnHold, ReadingStatus::Reading),
640            (ReadingStatus::Abandoned, ReadingStatus::Reading),
641            (ReadingStatus::Read, ReadingStatus::Reading), // re-read
642        ];
643
644        for (from, to) in &valid {
645            assert!(from.can_transition_to(to), "{from} → {to} should be valid");
646        }
647    }
648
649    #[test]
650    fn invalid_transitions() {
651        let invalid = [
652            (ReadingStatus::WantToRead, ReadingStatus::Read),
653            (ReadingStatus::WantToRead, ReadingStatus::Abandoned),
654            (ReadingStatus::WantToRead, ReadingStatus::OnHold),
655            (ReadingStatus::Read, ReadingStatus::Abandoned),
656            (ReadingStatus::Read, ReadingStatus::OnHold),
657            (ReadingStatus::Read, ReadingStatus::WantToRead),
658            (ReadingStatus::Abandoned, ReadingStatus::Read),
659            (ReadingStatus::Abandoned, ReadingStatus::OnHold),
660            (ReadingStatus::OnHold, ReadingStatus::Read),
661            (ReadingStatus::OnHold, ReadingStatus::Abandoned),
662            // Self-transitions
663            (ReadingStatus::Reading, ReadingStatus::Reading),
664            (ReadingStatus::WantToRead, ReadingStatus::WantToRead),
665        ];
666
667        for (from, to) in &invalid {
668            assert!(
669                !from.can_transition_to(to),
670                "{from} → {to} should be invalid"
671            );
672        }
673    }
674
675    #[test]
676    fn reading_session_new_defaults() {
677        let book_id = Uuid::now_v7();
678        let session = ReadingSession::new(book_id);
679        assert_eq!(session.book_id, book_id);
680        assert!(session.finished_at.is_none());
681        assert!(session.rating.is_none());
682        assert!(session.notes.is_none());
683    }
684
685    #[test]
686    fn progress_type_roundtrip() {
687        for pt in [
688            ProgressType::Page,
689            ProgressType::Percent,
690            ProgressType::Chapter,
691            ProgressType::Duration,
692        ] {
693            let parsed: ProgressType = pt.as_str().parse().unwrap();
694            assert_eq!(parsed, pt);
695        }
696    }
697
698    #[test]
699    fn progress_type_display() {
700        assert_eq!(ProgressType::Page.to_string(), "page");
701        assert_eq!(ProgressType::Duration.to_string(), "duration");
702    }
703
704    #[test]
705    fn progress_type_invalid() {
706        assert!("invalid".parse::<ProgressType>().is_err());
707    }
708
709    #[test]
710    fn reading_progress_new_defaults() {
711        let book_id = Uuid::now_v7();
712        let progress = ReadingProgress::new(book_id, ProgressType::Page, 42);
713        assert_eq!(progress.book_id, book_id);
714        assert_eq!(progress.progress_type, ProgressType::Page);
715        assert_eq!(progress.value, 42);
716        assert!(progress.session_id.is_none());
717        assert!(progress.note.is_none());
718    }
719
720    #[test]
721    fn parse_duration_hours_minutes() {
722        assert_eq!(parse_duration_to_minutes("5h30m").unwrap(), 330);
723        assert_eq!(parse_duration_to_minutes("1h0m").unwrap(), 60);
724        assert_eq!(parse_duration_to_minutes("0h45m").unwrap(), 45);
725    }
726
727    #[test]
728    fn parse_duration_minutes_only() {
729        assert_eq!(parse_duration_to_minutes("330m").unwrap(), 330);
730        assert_eq!(parse_duration_to_minutes("90m").unwrap(), 90);
731    }
732
733    #[test]
734    fn parse_duration_hours_only() {
735        assert_eq!(parse_duration_to_minutes("5h").unwrap(), 300);
736        assert_eq!(parse_duration_to_minutes("5.5h").unwrap(), 330);
737        assert_eq!(parse_duration_to_minutes("2.25h").unwrap(), 135);
738    }
739
740    #[test]
741    fn parse_duration_plain_number() {
742        assert_eq!(parse_duration_to_minutes("90").unwrap(), 90);
743    }
744
745    #[test]
746    fn parse_duration_invalid() {
747        assert!(parse_duration_to_minutes("abc").is_err());
748        assert!(parse_duration_to_minutes("").is_err());
749    }
750
751    #[test]
752    fn tag_type_roundtrip() {
753        for tt in [
754            TagType::General,
755            TagType::Mood,
756            TagType::Pace,
757            TagType::ContentWarning,
758        ] {
759            let parsed: TagType = tt.as_str().parse().unwrap();
760            assert_eq!(parsed, tt);
761        }
762    }
763
764    #[test]
765    fn tag_type_aliases() {
766        assert_eq!(
767            "content-warning".parse::<TagType>().unwrap(),
768            TagType::ContentWarning
769        );
770        assert_eq!("cw".parse::<TagType>().unwrap(), TagType::ContentWarning);
771    }
772
773    #[test]
774    fn tag_type_invalid() {
775        assert!("unknown".parse::<TagType>().is_err());
776    }
777
778    #[test]
779    fn pace_rating_roundtrip() {
780        for pr in [PaceRating::Fast, PaceRating::Medium, PaceRating::Slow] {
781            let parsed: PaceRating = pr.as_str().parse().unwrap();
782            assert_eq!(parsed, pr);
783        }
784    }
785
786    #[test]
787    fn pace_rating_alias_med() {
788        assert_eq!("med".parse::<PaceRating>().unwrap(), PaceRating::Medium);
789    }
790
791    #[test]
792    fn pace_rating_invalid() {
793        assert!("very-fast".parse::<PaceRating>().is_err());
794    }
795
796    #[test]
797    fn tag_with_type() {
798        let tag = Tag::with_type("adventurous", TagType::Mood);
799        assert_eq!(tag.name, "adventurous");
800        assert_eq!(tag.tag_type, TagType::Mood);
801    }
802
803    #[test]
804    fn tag_new_defaults_to_general() {
805        let tag = Tag::new("sci-fi");
806        assert_eq!(tag.tag_type, TagType::General);
807    }
808}