Skip to main content

aspero_core/id/
isbn.rs

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/// An International Standard Book Number, canonically ISBN-13 digits
9/// (`9780262035613`).
10///
11/// Accepts ISBN-10 and ISBN-13, with or without hyphens or spaces, with an
12/// `ISBN`, `ISBN-10` or `ISBN-13` label; both checksums are validated, and
13/// ISBN-10s are converted, so `0-262-03561-8` equals `978-0-262-03561-3`.
14#[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
23/// Digits (and a final `X`) with separators removed.
24fn 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
43/// Whether `s` (compacted) is a checksum-valid ISBN-10 or ISBN-13.
44pub(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    /// `openlibrary.org/isbn/<isbn>`.
96    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    /// `isbn:`, `ISBN `, `ISBN-13:`, `ISBN-10 `.
105    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}