1use std::borrow::Cow;
2use std::fmt;
3
4use super::IdKind;
5use super::syntax::{IdSyntax, impl_id_traits, strip_prefix_ci};
6use super::url::Url;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
15pub struct Isbn(String);
16
17impl Isbn {
18 pub fn as_str(&self) -> &str {
19 &self.0
20 }
21}
22
23fn compact(s: &str) -> String {
25 s.chars()
26 .filter(|c| !matches!(c, '-' | ' ' | '\u{2010}' | '\u{2011}'))
27 .collect()
28}
29
30fn digit(c: u8) -> u32 {
31 u32::from(c - b'0')
32}
33
34fn isbn13_check(first12: &[u8]) -> u8 {
35 let sum: u32 = first12
36 .iter()
37 .enumerate()
38 .map(|(i, &c)| digit(c) * if i % 2 == 0 { 1 } else { 3 })
39 .sum();
40 b'0' + ((10 - sum % 10) % 10) as u8
41}
42
43pub(crate) fn looks_like_isbn(s: &str) -> bool {
45 Isbn::parse_bare(&compact(s)).is_ok()
46}
47
48impl IdSyntax for Isbn {
49 const KIND: IdKind = IdKind::Isbn;
50
51 fn parse_bare(s: &str) -> Result<Self, &'static str> {
52 let s = compact(s).to_ascii_uppercase();
53 let b = s.as_bytes();
54 match b.len() {
55 13 => {
56 if !b.iter().all(u8::is_ascii_digit) {
57 return Err("ISBN-13 must be 13 digits");
58 }
59 if !(s.starts_with("978") || s.starts_with("979")) {
60 return Err("ISBN-13 must start with 978 or 979");
61 }
62 if isbn13_check(&b[..12]) != b[12] {
63 return Err("ISBN-13 check digit is wrong");
64 }
65 Ok(Isbn(s))
66 }
67 10 => {
68 let (body, check) = b.split_at(9);
69 if !body.iter().all(u8::is_ascii_digit)
70 || !(check[0].is_ascii_digit() || check[0] == b'X')
71 {
72 return Err("ISBN-10 must be 9 digits and a digit or X");
73 }
74 let sum: u32 = body
75 .iter()
76 .enumerate()
77 .map(|(i, &c)| digit(c) * (10 - i as u32))
78 .sum::<u32>()
79 + if check[0] == b'X' {
80 10
81 } else {
82 digit(check[0])
83 };
84 if !sum.is_multiple_of(11) {
85 return Err("ISBN-10 check digit is wrong");
86 }
87 let mut isbn13 = format!("978{}", &s[..9]).into_bytes();
88 isbn13.push(isbn13_check(&isbn13));
89 Ok(Isbn(String::from_utf8(isbn13).expect("ASCII digits")))
90 }
91 _ => Err("expected 10 or 13 digits"),
92 }
93 }
94
95 fn from_url<'a>(url: &Url<'a>) -> Option<Result<Cow<'a, str>, &'static str>> {
97 if !url.is_host(&["openlibrary.org"]) {
98 return None;
99 }
100 let mut segs = url.segments();
101 (segs.next()? == "isbn").then(|| segs.next().map(Cow::Borrowed).ok_or("missing ISBN"))
102 }
103
104 fn strip_prefix(s: &str) -> &str {
106 for label in ["isbn-13", "isbn-10", "isbn13", "isbn10", "isbn"] {
107 if let Some(rest) = strip_prefix_ci(s, label) {
108 return rest.trim_start_matches([':', ' ']).trim_start();
109 }
110 }
111 s
112 }
113}
114
115impl_id_traits!(Isbn);
116
117impl fmt::Display for Isbn {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 f.write_str(&self.0)
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use crate::id::IdError;
127
128 fn isbn(s: &str) -> Result<Isbn, IdError> {
129 s.parse()
130 }
131
132 #[test]
133 fn isbn_10_and_13_forms_are_the_same_book() {
134 for input in [
135 "9780262035613",
136 "978-0-262-03561-3",
137 "ISBN 978-0-262-03561-3",
138 "ISBN-13: 9780262035613",
139 "isbn:0262035618",
140 "0-262-03561-8",
141 "ISBN-10 0262035618",
142 "https://openlibrary.org/isbn/9780262035613",
143 ] {
144 assert_eq!(isbn(input).unwrap().as_str(), "9780262035613", "{input:?}");
145 }
146 }
147
148 #[test]
149 fn x_check_digits_and_979_prefixes() {
150 assert_eq!(isbn("0-8044-2957-X").unwrap().as_str(), "9780804429573");
151 assert!(isbn("979-10-90636-07-1").is_ok());
152 }
153
154 #[test]
155 fn rejects_bad_checksums_and_lengths() {
156 for input in [
157 "9780262035614",
158 "0262035617",
159 "12345",
160 "9770262035613",
161 "ABCDEFGHIJ",
162 ] {
163 assert!(
164 matches!(
165 isbn(input),
166 Err(IdError::Invalid {
167 kind: IdKind::Isbn,
168 ..
169 })
170 ),
171 "{input:?}"
172 );
173 }
174 }
175}