use super::funejson::Value; fn invalid(reason: &str) -> IsbnCheck { IsbnCheck { valid: false, normalised: None, isbn10: None, reason: Some(reason.to_string()), } } fn digit(ch: char) -> u32 { ch as u32 - 48 } /// The EAN-13 check digit for twelve digits: weights 1, 3, 1, 3 ... from the left. fn ean_check(twelve: &[char]) -> char { let mut total = 0; for i in 0..12 { total += digit(twelve[i]) * if i % 2 == 0 { 1 } else { 3 }; } char::from_digit((10 - total % 10) % 10, 10).unwrap() } /// The ISBN-10 check character for nine digits: weights 10 down to 2, mod 11, 10 written X. fn isbn10_check(nine: &[char]) -> char { let mut total = 0; for i in 0..9 { total += digit(nine[i]) * (10 - i as u32); } let check = (11 - total % 11) % 11; if check == 10 { 'X' } else { char::from_digit(check, 10).unwrap() } } /// Check an ISBN-10 or ISBN-13 and return it as an ISBN-13, with its ISBN-10 /// where one exists. /// /// The two forms use different check digits, so conversion recomputes rather /// than copies. Validators answer rather than panic. pub fn validate_isbn(value: &str) -> IsbnCheck { // Hyphen positions vary by publisher, so separators are ignored wholesale. let mut compact: Vec = value.chars().filter(|ch| *ch != ' ' && *ch != '-').collect(); // An optional "ISBN" label, any case, then an optional colon. if compact.len() >= 4 { let label: String = compact[..4].iter().map(|c| c.to_ascii_uppercase()).collect(); if label == "ISBN" { compact.drain(..4); } } if compact.first() == Some(&':') { compact.remove(0); } if compact.is_empty() { return invalid("empty"); } let mut isbn: Vec = Vec::with_capacity(compact.len()); for ch in compact { if ch.is_ascii_digit() { isbn.push(ch); } else if ch == 'X' || ch == 'x' { isbn.push('X'); } else { return invalid("bad-character"); } } if isbn.len() != 10 && isbn.len() != 13 { return invalid("bad-length"); } // X means ten, so it can only be an ISBN-10's check digit. if let Some(x_at) = isbn.iter().position(|ch| *ch == 'X') { if isbn.len() == 13 || x_at != 9 { return invalid("bad-format"); } } if isbn.len() == 13 { let prefix: String = isbn[..3].iter().collect(); if prefix != "978" && prefix != "979" { return invalid("bad-prefix"); } if ean_check(&isbn) != isbn[12] { return invalid("bad-check-digit"); } // 979 ISBNs were never ISBN-10s, so they have no ISBN-10 to give back. let isbn10 = if prefix == "978" { let mut ten: String = isbn[3..12].iter().collect(); ten.push(isbn10_check(&isbn[3..12])); Some(ten) } else { None }; return IsbnCheck { valid: true, normalised: Some(isbn.iter().collect()), isbn10, reason: None, }; } if isbn10_check(&isbn) != isbn[9] { return invalid("bad-check-digit"); } let mut body: Vec = vec!['9', '7', '8']; body.extend_from_slice(&isbn[..9]); let check = ean_check(&body); body.push(check); IsbnCheck { valid: true, normalised: Some(body.iter().collect()), isbn10: Some(isbn.iter().collect()), reason: None, } } /// Object keys are camelCase to match the shared vectors. pub fn isbn_check_to_value(result: &IsbnCheck) -> Value { let text = |field: &Option| match field { Some(s) => Value::str(s), None => Value::Null, }; Value::obj(vec![ ("valid", Value::Bool(result.valid)), ("normalised", text(&result.normalised)), ("isbn10", text(&result.isbn10)), ("reason", text(&result.reason)), ]) } pub fn fune_vector(args: &[Value]) -> Value { // A non-string argument arrives here as an empty string, which is exactly // the answer TypeScript and Python give for a non-string: empty. isbn_check_to_value(&validate_isbn(args[0].as_str())) }