1use crate::TokuError;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum Isbn {
6 Isbn10(String),
7 Isbn13(String),
8}
9
10impl Isbn {
11 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 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 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 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}