Functional Weave
Code in Rust

validation.vin

Check a 17-character vehicle identification number and its check digit; enforced for North American VINs.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 17 tests, run in TypeScript, Python and Rust.

What it does

Checks a vehicle identification number: the 17-character code stamped on the chassis and printed on a V5C logbook. ISO 3779 fixes the length and alphabet; the check digit in position 9 comes from the North American standard (US 49 CFR 565.15) and is the part that catches typos.

THE CHECK DIGIT IS NOT UNIVERSAL, and that decides the shape of the answer. North American law requires it, so a VIN whose world manufacturer identifier starts with 1 to 5 (United States, Canada, Mexico) and whose check digit does not match is `valid: false` with `bad-check-digit`. ISO 3779 itself does not require one, and many European and Japanese manufacturers put a letter or an arbitrary digit in position 9 for vehicles built for their home markets. Rejecting those would reject real cars on UK roads, so for every other region a mismatch leaves `valid: true` and reports `checkDigitMatches: false`. A caller who knows the vehicle should carry a check digit (anything built for sale in North America, and most Chinese VINs) can insist on `checkDigitMatches`.

For example

  • validate_vin(1M8GDM9AXKP042788) → valid true, normalised 1M8GDM9AXKP042788, wmi 1M8, check digit matches true, reason — the textbook VIN whose check digit is X (remainder ten)
  • validate_vin(1HGCM82633A004352) → valid true, normalised 1HGCM82633A004352, wmi 1HG, check digit matches true, reason — a US-built Honda
  • validate_vin(11111111111111111) → valid true, normalised 11111111111111111, wmi 111, check digit matches true, reason — seventeen ones: the weights sum to 89, which leaves 1

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

pub fn validate_vin(value: &str) -> VinCheck
valuestringa 17-character VIN in any case, optionally spaced
returnsVinCheck

The type it declares, generated into your project

/// The result of checking a VIN. Strings and checkDigitMatches are null when valid is false; reason is null when it is true.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VinCheck {
    pub valid: bool,
    /// upper case, no spaces
    pub normalised: Option<String>,
    /// the world manufacturer identifier, the first three characters
    pub wmi: Option<String>,
    /// whether position 9 holds the computed check digit
    pub check_digit_matches: Option<bool>,
    /// empty, bad-character, bad-length, forbidden-letter or bad-check-digit
    pub reason: Option<String>,
}

Your code names it in one line, in the file that uses it

fune!(validation.vin@^1);  // then call validate_vin(…)
impl/rust.rs · 106 lines · open · 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

/// Letter values from 49 CFR 565.15, indexed by letter A-Z. I, O and Q are
/// never used and hold -1; the others run 1-9 in three passes (A-I, J-R, S-Z)
/// with the forbidden letters' slots skipped.
const LETTER_VALUES: [i64; 26] = [1, 2, 3, 4, 5, 6, 7, 8, -1, 1, 2, 3, 4, 5, -1, 7, -1, 9, 2, 3, 4, 5, 6, 7, 8, 9];

/// Position weights; position 9 is the check digit itself and weighs nothing.
const WEIGHTS: [i64; 17] = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2];

fn invalid(reason: &str) -> VinCheck {
    VinCheck {
        valid: false,
        normalised: None,
        wmi: None,
        check_digit_matches: None,
        reason: Some(reason.to_string()),
    }
}

/// Check a 17-character VIN.
///
/// The check digit is enforced only for North American VINs (first character
/// 1-5), where the law requires it; elsewhere a mismatch is reported in
/// `check_digit_matches` but does not make the VIN invalid, because many
/// European and Asian VINs never carried one. Validators answer rather than
/// panic.
pub fn validate_vin(value: &str) -> VinCheck {
    let vin: Vec<char> = value
        .chars()
        .filter(|ch| *ch != ' ')
        .map(|ch| ch.to_ascii_uppercase())
        .collect();
    if vin.is_empty() {
        return invalid("empty");
    }
    if vin.iter().any(|ch| !(ch.is_ascii_digit() || ch.is_ascii_uppercase())) {
        return invalid("bad-character");
    }
    if vin.len() != 17 {
        return invalid("bad-length");
    }

    let mut total: i64 = 0;
    for (i, ch) in vin.iter().enumerate() {
        let n = if ch.is_ascii_digit() {
            *ch as i64 - 48
        } else {
            let n = LETTER_VALUES[(*ch as u8 - b'A') as usize];
            // I, O and Q read as 1, 0 and 0, so the standard never issues them.
            if n < 0 {
                return invalid("forbidden-letter");
            }
            n
        };
        total += n * WEIGHTS[i];
    }
    let remainder = total % 11;
    let expected = if remainder == 10 {
        'X'
    } else {
        char::from_digit(remainder as u32, 10).unwrap()
    };
    let matches = vin[8] == expected;

    // WMIs starting 1-5 are North American, where 49 CFR 565 makes the check
    // digit mandatory; anywhere else it is optional, so only report it.
    if !matches && ('1'..='5').contains(&vin[0]) {
        return invalid("bad-check-digit");
    }

    VinCheck {
        valid: true,
        normalised: Some(vin.iter().collect()),
        wmi: Some(vin[..3].iter().collect()),
        check_digit_matches: Some(matches),
        reason: None,
    }
}

/// Object keys are camelCase to match the shared vectors.
pub fn vin_check_to_value(result: &VinCheck) -> 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)),
        ("wmi", text(&result.wmi)),
        (
            "checkDigitMatches",
            match result.check_digit_matches {
                Some(b) => Value::Bool(b),
                None => Value::Null,
            },
        ),
        ("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.
    vin_check_to_value(&validate_vin(args[0].as_str()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add validation.vin
Download for Rust validation.vin-1.0.0-rust.fune · 12,794 bytes sha256 47fa479773bdf6780cf201a7dddc663834449ebb9cc2a45d792aa57280c17f58

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./validation.vin-1.0.0-rust.fune, or fetch it from a terminal with fune pull validation.vin@1.0.0:rust.

The whole function, every language, is one file too: validation.vin-1.0.0.fune, 17,748 bytes, sha256 310dc9072c78fe16ddf0e7f98d75b9535b807ba32c97aed2874a45be9b7b36c6. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before validation.vin

after — your function gets the result and the arguments, and returns the final result.

// fune: after validation.vin

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show validation.vin --steps.

// fune: step validation.vin after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
the textbook VIN whose check digit is X (remainder ten) 1M8GDM9AXKP042788 → valid true, normalised 1M8GDM9AXKP042788, wmi 1M8, check digit matches true, reason —
a US-built Honda 1HGCM82633A004352 → valid true, normalised 1HGCM82633A004352, wmi 1HG, check digit matches true, reason —
seventeen ones: the weights sum to 89, which leaves 1 11111111111111111 → valid true, normalised 11111111111111111, wmi 111, check digit matches true, reason —
a Mexican-built VIN, where the check digit is 0 3VWFE21C04M000001 → valid true, normalised 3VWFE21C04M000001, wmi 3VW, check digit matches true, reason —
lower case and spaces are normalised 1hgcm8263 3a004352 → valid true, normalised 1HGCM82633A004352, wmi 1HG, check digit matches true, reason —
a North American VIN with the wrong check digit is invalid 1HGCM82643A004352 → valid false, normalised —, wmi —, check digit matches —, reason bad-check-digit
a Mexican VIN with the wrong check digit is invalid too 3VWFE21C54M000001 → valid false, normalised —, wmi —, check digit matches —, reason bad-check-digit
a single mistyped character elsewhere is caught by the check digit 1HGCM82633A004353 → valid false, normalised —, wmi —, check digit matches —, reason bad-check-digit
a European VIN with Z in position 9 is valid but reports the mismatch WVWZZZ1JZXW000001 → valid true, normalised WVWZZZ1JZXW000001, wmi WVW, check digit matches false, reason —
an O where a 0 belongs is a forbidden letter 1HGCM82633A0O4352 → valid false, normalised —, wmi —, check digit matches —, reason forbidden-letter
Show the other 7 tests
CaseArgumentsExpected
a lower-case i is folded and still forbidden 1hgcm82633a0043i2 → valid false, normalised —, wmi —, check digit matches —, reason forbidden-letter
sixteen characters is too short 1HGCM82633A00435 → valid false, normalised —, wmi —, check digit matches —, reason bad-length
eighteen characters is too long 1HGCM82633A0043520 → valid false, normalised —, wmi —, check digit matches —, reason bad-length
a hyphen is a bad character, reported before the length 1HGCM826-33A004352 → valid false, normalised —, wmi —, check digit matches —, reason bad-character
the empty string → valid false, normalised —, wmi —, check digit matches —, reason empty
spaces only is empty → valid false, normalised —, wmi —, check digit matches —, reason empty
a non-string is empty, not an exception — → valid false, normalised —, wmi —, check digit matches —, reason empty

More from the author

THE CALCULATION: each character becomes a number (digits are themselves; A-H are 1-8, J-N 1-5, P 7, R 9, S-Z 2-9, skipping the forbidden letters), is multiplied by the weight for its position (8 7 6 5 4 3 2 10 0 9 8 7 6 5 4 3 2), and the sum is taken modulo 11; a remainder of 10 is written `X`. Position 9, the check digit itself, has weight 0.

FORBIDDEN LETTERS: I, O and Q never appear in a VIN because they read as 1, 0 and 0. They get their own reason, `forbidden-letter`, because they almost always mean a transcription slip that the user can fix by retyping that one character.

ACCEPTED INPUT: letters in either case (folded to upper case) and digits; ASCII spaces are ignored anywhere. Hyphens and anything else are refused, because VINs are never printed with them.

| reason | meaning | |--------------------|----------------------------------------------------------| | `empty` | nothing but spaces, or not a string at all | | `bad-character` | anything other than A-Z, a-z, 0-9 and spaces | | `bad-length` | not exactly 17 characters | | `forbidden-letter` | contains I, O or Q | | `bad-check-digit` | a North American VIN (starts 1-5) whose check digit is wrong |

The checks run in that order; the first failure is the reason. Validators answer rather than throw.

Out of scope: decoding the model year (position 10), the plant, or the manufacturer's name from the WMI, and pre-1981 VINs, which were not 17 characters.

Sources: ISO 3779:2009 "Road vehicles - Vehicle identification number (VIN) - Content and structure"; US 49 CFR 565.15 "Content requirements" (check digit, transliteration and weight tables), https://www.ecfr.gov/current/title-49/subtitle-B/chapter-V/part-565/subpart-B/section-565.15.

Files

PathBytes
README.md2,965
impl/python.py2,294
impl/rust.rs3,519
impl/typescript.ts2,429
vectors.json3,525