Functional Weave
Code in Python

validation.isbn@1.0.0

impl/rust.rs

4,274 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one

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<char> = 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<char> = 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<char> = 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<String>| 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()))
}