odoid 1.0.2

Deterministic mixed-radix ID encoding — maps integers to 6, 7, or 8-character alphanumeric strings. Ambiguous characters I, L, O are excluded.
Documentation
use odoid::{decode, encode, OdoError, ALPHA, MAX};

#[test]
fn output_length_matches_requested() {
    for &length in &[6, 7, 8] {
        assert_eq!(encode(0, length).unwrap().len(), length);
    }
}

#[test]
fn output_is_always_uppercase() {
    for &n in &[0u64, 1, 100, 999999, 1234567] {
        let id = encode(n, 6).unwrap();
        assert_eq!(id, id.to_uppercase());
    }
}

#[test]
fn position_1_is_always_alpha() {
    for &n in &[0u64, 1, 100, 1234567] {
        let id = encode(n, 6).unwrap();
        let ch = id.chars().nth(1).unwrap();
        assert!(ALPHA.contains(ch), "pos 1 = '{ch}' not in ALPHA");
    }
}

#[test]
fn position_2_is_always_digit() {
    for &n in &[0u64, 1, 100, 1234567] {
        let id = encode(n, 6).unwrap();
        let ch = id.chars().nth(2).unwrap();
        assert!(ch.is_ascii_digit(), "pos 2 = '{ch}' not a digit");
    }
}

#[test]
fn excluded_chars_never_appear() {
    for &n in &[0u64, 1, 1000, 1234567, 100_000_000, MAX[8] - 1] {
        let id = encode(n, 8).unwrap();
        assert!(!id.contains('I'), "{id} contains I");
        assert!(!id.contains('L'), "{id} contains L");
        assert!(!id.contains('O'), "{id} contains O");
    }
}

#[test]
fn boundary_zero_valid() {
    for &length in &[6, 7, 8] {
        assert!(encode(0, length).is_ok());
    }
}

#[test]
fn boundary_max_minus1_valid() {
    for &length in &[6, 7, 8] {
        assert!(encode(MAX[length] - 1, length).is_ok());
    }
}

#[test]
fn overflow_at_max() {
    for &length in &[6, 7, 8] {
        assert!(matches!(encode(MAX[length], length), Err(OdoError::Overflow(_))));
    }
}

#[test]
fn overflow_error_message_contains_value() {
    let err = encode(MAX[6], 6).unwrap_err();
    assert!(err.to_string().contains(&MAX[6].to_string()));
}

#[test]
fn unsupported_lengths() {
    for &length in &[0, 1, 5, 9, 100] {
        assert!(
            matches!(encode(0, length), Err(OdoError::UnsupportedLength(_))),
            "length {length} should be unsupported"
        );
    }
}

#[test]
fn round_trips() {
    let cases: &[(u64, usize)] = &[
        (0, 6), (1, 6), (255, 6), (65535, 6), (1234567, 6),
        (0, 7), (1234567, 7),
        (0, 8), (1234567, 8),
    ];
    for &(n, length) in cases {
        let id = encode(n, length).unwrap();
        assert_eq!(decode(&id).unwrap(), n, "round-trip failed for ({n}, {length})");
    }
}

#[test]
fn decode_lowercase_accepted() {
    assert_eq!(decode("0a0000").unwrap(), 0);
}

#[test]
fn decode_mixed_case_matches_upper() {
    assert_eq!(decode("0d7nm7").unwrap(), decode("0D7NM7").unwrap());
}

#[test]
fn decode_excluded_chars_rejected() {
    for ch in ['I', 'L', 'O'] {
        let id = format!("0A0{ch}00");
        assert!(
            matches!(decode(&id), Err(OdoError::InvalidCharacter(_))),
            "expected InvalidCharacter for '{ch}'"
        );
    }
}

#[test]
fn decode_reports_correct_position() {
    let err = decode("0A000O").unwrap_err();
    match err {
        OdoError::InvalidCharacter(e) => assert_eq!(e.position, 6),
        _ => panic!("wrong error type"),
    }
}

#[test]
fn decode_special_char_rejected() {
    assert!(matches!(decode("0A00-0"), Err(OdoError::InvalidCharacter(_))));
}

#[test]
fn decode_unsupported_length() {
    assert!(matches!(decode("0A000"),      Err(OdoError::UnsupportedLength(_))));
    assert!(matches!(decode("0A000000000"), Err(OdoError::UnsupportedLength(_))));
}