Skip to main content

toku_core/
isbn.rs

1use crate::TokuError;
2
3/// A validated ISBN (either ISBN-10 or ISBN-13).
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum Isbn {
6    Isbn10(String),
7    Isbn13(String),
8}
9
10impl Isbn {
11    /// Parse and validate an ISBN string (10 or 13 digits).
12    pub fn parse(input: &str) -> Result<Self, TokuError> {
13        let cleaned: String = input
14            .chars()
15            .filter(|c| c.is_ascii_digit() || *c == 'X')
16            .collect();
17
18        match cleaned.len() {
19            10 => {
20                if validate_isbn10(&cleaned) {
21                    Ok(Isbn::Isbn10(cleaned))
22                } else {
23                    Err(TokuError::InvalidIsbn(format!(
24                        "invalid ISBN-10 check digit: {input}"
25                    )))
26                }
27            }
28            13 => {
29                if validate_isbn13(&cleaned) {
30                    Ok(Isbn::Isbn13(cleaned))
31                } else {
32                    Err(TokuError::InvalidIsbn(format!(
33                        "invalid ISBN-13 check digit: {input}"
34                    )))
35                }
36            }
37            _ => Err(TokuError::InvalidIsbn(format!(
38                "ISBN must be 10 or 13 digits, got {}: {input}",
39                cleaned.len()
40            ))),
41        }
42    }
43
44    /// Convert this ISBN to ISBN-13. ISBN-13 returns itself.
45    /// Only 978-prefixed ISBN-10s can be converted.
46    pub fn to_isbn13(&self) -> String {
47        match self {
48            Isbn::Isbn13(s) => s.clone(),
49            Isbn::Isbn10(s) => {
50                let without_check = &s[..9];
51                let base = format!("978{without_check}");
52                let check = compute_isbn13_check(&base);
53                format!("{base}{check}")
54            }
55        }
56    }
57
58    /// Convert this ISBN to ISBN-10 if possible. Only 978-prefixed ISBN-13s convert.
59    pub fn to_isbn10(&self) -> Option<String> {
60        match self {
61            Isbn::Isbn10(s) => Some(s.clone()),
62            Isbn::Isbn13(s) => {
63                if !s.starts_with("978") {
64                    return None;
65                }
66                let core = &s[3..12];
67                let check = compute_isbn10_check(core);
68                Some(format!("{core}{check}"))
69            }
70        }
71    }
72
73    pub fn as_str(&self) -> &str {
74        match self {
75            Isbn::Isbn10(s) | Isbn::Isbn13(s) => s,
76        }
77    }
78}
79
80impl std::fmt::Display for Isbn {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.write_str(self.as_str())
83    }
84}
85
86fn validate_isbn10(digits: &str) -> bool {
87    if digits.len() != 10 {
88        return false;
89    }
90    let sum: u32 = digits
91        .chars()
92        .enumerate()
93        .map(|(i, c)| {
94            let val = if c == 'X' {
95                10
96            } else {
97                c.to_digit(10).unwrap_or(0)
98            };
99            val * (10 - i as u32)
100        })
101        .sum();
102    sum.is_multiple_of(11)
103}
104
105fn validate_isbn13(digits: &str) -> bool {
106    if digits.len() != 13 || !digits.chars().all(|c| c.is_ascii_digit()) {
107        return false;
108    }
109    let sum: u32 = digits
110        .chars()
111        .enumerate()
112        .map(|(i, c)| {
113            let val = c.to_digit(10).unwrap_or(0);
114            if i % 2 == 0 { val } else { val * 3 }
115        })
116        .sum();
117    sum.is_multiple_of(10)
118}
119
120fn compute_isbn13_check(first_12: &str) -> char {
121    let sum: u32 = first_12
122        .chars()
123        .enumerate()
124        .map(|(i, c)| {
125            let val = c.to_digit(10).unwrap_or(0);
126            if i % 2 == 0 { val } else { val * 3 }
127        })
128        .sum();
129    let check = (10 - (sum % 10)) % 10;
130    char::from_digit(check, 10).unwrap()
131}
132
133fn compute_isbn10_check(first_9: &str) -> char {
134    let sum: u32 = first_9
135        .chars()
136        .enumerate()
137        .map(|(i, c)| {
138            let val = c.to_digit(10).unwrap_or(0);
139            val * (10 - i as u32)
140        })
141        .sum();
142    let check = (11 - (sum % 11)) % 11;
143    if check == 10 {
144        'X'
145    } else {
146        char::from_digit(check, 10).unwrap()
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn valid_isbn13() {
156        let isbn = Isbn::parse("9780441013593").unwrap();
157        assert!(matches!(isbn, Isbn::Isbn13(_)));
158        assert_eq!(isbn.as_str(), "9780441013593");
159    }
160
161    #[test]
162    fn valid_isbn10() {
163        let isbn = Isbn::parse("0441013597").unwrap();
164        assert!(matches!(isbn, Isbn::Isbn10(_)));
165    }
166
167    #[test]
168    fn isbn10_with_x_check() {
169        let isbn = Isbn::parse("080442957X").unwrap();
170        assert!(matches!(isbn, Isbn::Isbn10(_)));
171    }
172
173    #[test]
174    fn isbn_strips_hyphens() {
175        let isbn = Isbn::parse("978-0-441-01359-3").unwrap();
176        assert_eq!(isbn.as_str(), "9780441013593");
177    }
178
179    #[test]
180    fn isbn10_to_isbn13() {
181        let isbn = Isbn::parse("0441013597").unwrap();
182        assert_eq!(isbn.to_isbn13(), "9780441013593");
183    }
184
185    #[test]
186    fn isbn13_to_isbn10() {
187        let isbn = Isbn::parse("9780441013593").unwrap();
188        assert_eq!(isbn.to_isbn10(), Some("0441013597".to_string()));
189    }
190
191    #[test]
192    fn isbn13_979_no_isbn10() {
193        // 979-prefix ISBN-13s cannot be converted to ISBN-10
194        let isbn = Isbn::parse("9791032305690").unwrap();
195        assert!(isbn.to_isbn10().is_none());
196    }
197
198    #[test]
199    fn invalid_isbn_check_digit() {
200        assert!(Isbn::parse("9780441013590").is_err());
201    }
202
203    #[test]
204    fn invalid_isbn_length() {
205        assert!(Isbn::parse("12345").is_err());
206    }
207}