Files
rdbms-playground/src/dsl/shortid.rs
T
claude@clouddev1 41b7e9a049 style: format the whole tree with cargo fmt (stock defaults, #35)
One-time, mechanical reformat — no functional changes. The tree was not
rustfmt-clean (~1800 hunks across ~100 files); this brings it to stock
`cargo fmt` defaults so a `cargo fmt --check` CI gate can follow.
Behaviour-preserving: 2509 pass / 0 fail / 1 ignored (unchanged baseline),
clippy clean. A .git-blame-ignore-revs entry follows so `git blame`
skips this commit.
2026-06-17 21:39:19 +00:00

119 lines
3.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Shortid generation and validation.
//!
//! Per ADR-0005, the `shortid` user-facing type is a base58
//! random identifier of 1012 characters with no ambiguous
//! glyphs (no `0`/`O`/`I`/`l`). The generator is small enough
//! to live in the DSL crate alongside the type definition.
use rand::RngExt;
/// Base58 alphabet — Bitcoin-style. 0 / O / I / l are excluded
/// because they are easily confused in print.
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const DEFAULT_LEN: usize = 10;
/// Length bounds accepted on user-supplied shortid values.
pub const MIN_LEN: usize = 10;
pub const MAX_LEN: usize = 12;
/// Generate a fresh shortid using the thread-local RNG.
#[must_use]
pub fn generate() -> String {
generate_with_rng(&mut rand::rng())
}
/// Generate a shortid from a caller-supplied RNG.
///
/// Lets `seed --seed <n>` produce **reproducible** shortid values
/// (ADR-0048 D4) by threading its seeded RNG through, while the default
/// [`generate`] keeps its thread-RNG behaviour for ordinary inserts.
#[must_use]
pub fn generate_with_rng<R: RngExt + ?Sized>(rng: &mut R) -> String {
let mut out = String::with_capacity(DEFAULT_LEN);
for _ in 0..DEFAULT_LEN {
let idx = rng.random_range(0..ALPHABET.len());
out.push(ALPHABET[idx] as char);
}
out
}
/// Validate a user-supplied shortid value.
pub fn validate(value: &str) -> Result<(), String> {
if value.len() < MIN_LEN || value.len() > MAX_LEN {
return Err(format!(
"shortid must be {MIN_LEN}{MAX_LEN} characters; got {} character(s)",
value.chars().count()
));
}
for c in value.chars() {
if !ALPHABET.contains(&(c as u8)) {
return Err(format!(
"shortid contains '{c}', which is not in the base58 alphabet \
(no 0, O, I, or l; ASCII letters and digits otherwise)"
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn generated_ids_have_default_length() {
let id = generate();
assert_eq!(id.len(), DEFAULT_LEN);
}
#[test]
fn generated_ids_use_only_base58_alphabet() {
for _ in 0..100 {
let id = generate();
for c in id.chars() {
assert!(
ALPHABET.contains(&(c as u8)),
"char {c:?} not in base58 alphabet"
);
}
}
}
#[test]
fn generated_ids_are_not_all_identical() {
// Probabilistically extremely unlikely with a good RNG;
// catches a wholly broken generator (constant output).
let a = generate();
let b = generate();
let c = generate();
assert!(
a != b || b != c,
"all three generated ids were identical: {a}, {b}, {c}"
);
}
#[test]
fn validate_accepts_well_formed_values() {
assert!(validate("23456789Ab").is_ok()); // 10 chars
assert!(validate("23456789AbCD").is_ok()); // 12 chars
}
#[test]
fn validate_rejects_too_short_or_too_long() {
let err = validate("short").unwrap_err();
assert!(err.contains("characters"));
let err = validate("waytoolongafornow").unwrap_err();
assert!(err.contains("characters"));
}
#[test]
fn validate_rejects_ambiguous_glyphs() {
for bad in ["0aaaaaaaaa", "Oaaaaaaaaa", "Iaaaaaaaaa", "laaaaaaaaa"] {
let err = validate(bad).unwrap_err();
assert!(err.contains("base58"), "for {bad}: {err}");
}
}
}