feat(maestro): orchestra-themed session-name generator (#23)
New shared `maestro-names` crate producing `adjective-instrument` session handles drawn from the orchestra pit — musical tempo/dynamics/articulation markings × instruments and forms (e.g. `allegro-cello`, `crescendo-oboe`, `staccato-viola`), tying handles to the app's conductor metaphor. - Pure generation takes an injected RNG; no thread_rng/clock in pure paths, so seeded tests are reproducible (repo invariant). - `generate_unique` prefers the bare name, appending an incrementing numeric suffix (`-2`, `-3`, …) only on collision via an injected `is_taken`. - `generate_thread` is the one impure convenience wrapper for runtime callers. - Wordlists are public so the web UI can mirror them for instant reroll; the Rust generator stays the source of truth. Closes #23. Part of #21 (spawn dialog rework). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "maestro-names"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Orchestra-flavoured session-handle generator (adjective × instrument) shared across Maestro"
|
||||
|
||||
[dependencies]
|
||||
rand.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
rand = { workspace = true, features = ["small_rng"] }
|
||||
@@ -0,0 +1,214 @@
|
||||
//! `maestro-names` — the shared session-handle generator.
|
||||
//!
|
||||
//! Maestro is a conductor's podium for every shell you care about, so the
|
||||
//! handles it hands out are drawn from the orchestra pit: a musical adjective
|
||||
//! (tempo, dynamics, articulation) paired with an instrument or musical form.
|
||||
//! The result reads like a marking on a score — `allegro-cello`,
|
||||
//! `crescendo-oboe`, `staccato-viola`.
|
||||
//!
|
||||
//! ## Determinism
|
||||
//!
|
||||
//! Every pure path takes an **injected** [`rand::Rng`]; nothing here reaches
|
||||
//! for `thread_rng()` or the clock, so seeded tests are reproducible (a repo
|
||||
//! invariant). [`generate_thread`] is the one impure convenience wrapper for
|
||||
//! production callers that just want a name and don't care about the seed.
|
||||
//!
|
||||
//! The Rust generator is the source of truth. If the web UI wants an instant
|
||||
//! client-side reroll it can mirror [`ADJECTIVES`] / [`NOUNS`] verbatim, but
|
||||
//! the wordlists live here.
|
||||
|
||||
use rand::seq::SliceRandom;
|
||||
use rand::Rng;
|
||||
|
||||
/// Musical adjectives — tempo, dynamics, and articulation markings.
|
||||
pub const ADJECTIVES: &[&str] = &[
|
||||
"allegro",
|
||||
"andante",
|
||||
"adagio",
|
||||
"presto",
|
||||
"vivace",
|
||||
"largo",
|
||||
"grave",
|
||||
"lento",
|
||||
"moderato",
|
||||
"crescendo",
|
||||
"staccato",
|
||||
"legato",
|
||||
"forte",
|
||||
"dolce",
|
||||
"agitato",
|
||||
"cantabile",
|
||||
"sostenuto",
|
||||
"tremolo",
|
||||
"rubato",
|
||||
"marcato",
|
||||
"brillante",
|
||||
"maestoso",
|
||||
"grazioso",
|
||||
"espressivo",
|
||||
"tranquillo",
|
||||
"animato",
|
||||
"spirito",
|
||||
"sotto",
|
||||
"vivo",
|
||||
"feroce",
|
||||
];
|
||||
|
||||
/// Musical nouns — instruments and forms from the concert hall.
|
||||
pub const NOUNS: &[&str] = &[
|
||||
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet", "trombone",
|
||||
"tuba", "horn", "timpani", "harp", "piano", "organ", "lyre", "lute", "fugue", "sonata",
|
||||
"octave", "chord", "cadence", "prelude", "rondo", "nocturne", "scherzo", "refrain", "coda",
|
||||
"motif", "overture",
|
||||
];
|
||||
|
||||
/// Generate a single `adjective-noun` handle from an injected RNG.
|
||||
///
|
||||
/// ```
|
||||
/// use rand::SeedableRng;
|
||||
/// let mut rng = rand::rngs::SmallRng::seed_from_u64(7);
|
||||
/// let name = maestro_names::generate(&mut rng);
|
||||
/// assert!(name.contains('-'));
|
||||
/// ```
|
||||
pub fn generate<R: Rng + ?Sized>(rng: &mut R) -> String {
|
||||
// Both lists are compile-time non-empty (asserted in tests), so the
|
||||
// `choose` calls cannot return `None`.
|
||||
let adj = ADJECTIVES.choose(rng).expect("ADJECTIVES is non-empty");
|
||||
let noun = NOUNS.choose(rng).expect("NOUNS is non-empty");
|
||||
format!("{adj}-{noun}")
|
||||
}
|
||||
|
||||
/// Generate a handle that isn't already taken.
|
||||
///
|
||||
/// Draws `adjective-noun` candidates from `rng`; if `is_taken` rejects one, a
|
||||
/// numeric uniqueness suffix is appended and incremented (`-2`, `-3`, …) until
|
||||
/// a free handle is found. The bare form is preferred — the suffix only
|
||||
/// appears on collision, matching the score-marking aesthetic.
|
||||
///
|
||||
/// `is_taken` is the single source of "already used"; given the same RNG and
|
||||
/// the same predicate the output is fully deterministic.
|
||||
pub fn generate_unique<R, F>(rng: &mut R, is_taken: F) -> String
|
||||
where
|
||||
R: Rng + ?Sized,
|
||||
F: Fn(&str) -> bool,
|
||||
{
|
||||
let base = generate(rng);
|
||||
if !is_taken(&base) {
|
||||
return base;
|
||||
}
|
||||
// Start at 2 so the first duplicate reads `name`, `name-2`, `name-3` …
|
||||
for suffix in 2u64.. {
|
||||
let candidate = format!("{base}-{suffix}");
|
||||
if !is_taken(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
unreachable!("u64 suffix space is effectively inexhaustible")
|
||||
}
|
||||
|
||||
/// Impure convenience wrapper: generate a handle from the thread RNG.
|
||||
///
|
||||
/// For production callers that just want a name. Tests and any deterministic
|
||||
/// path should use [`generate`] / [`generate_unique`] with a seeded RNG.
|
||||
pub fn generate_thread() -> String {
|
||||
generate(&mut rand::thread_rng())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand::rngs::SmallRng;
|
||||
use rand::SeedableRng;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn seeded(seed: u64) -> SmallRng {
|
||||
SmallRng::seed_from_u64(seed)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wordlists_are_non_empty() {
|
||||
assert!(!ADJECTIVES.is_empty());
|
||||
assert!(!NOUNS.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wordlists_have_no_duplicates() {
|
||||
assert_eq!(
|
||||
ADJECTIVES.iter().collect::<HashSet<_>>().len(),
|
||||
ADJECTIVES.len(),
|
||||
"duplicate adjective"
|
||||
);
|
||||
assert_eq!(
|
||||
NOUNS.iter().collect::<HashSet<_>>().len(),
|
||||
NOUNS.len(),
|
||||
"duplicate noun"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_is_adjective_dash_noun() {
|
||||
let mut rng = seeded(1);
|
||||
let name = generate(&mut rng);
|
||||
let (adj, noun) = name.split_once('-').expect("must contain a single dash");
|
||||
assert!(ADJECTIVES.contains(&adj), "{adj} not an adjective");
|
||||
assert!(NOUNS.contains(&noun), "{noun} not a noun");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_is_deterministic_under_seed() {
|
||||
// Same seed → identical sequence; this is the core repo invariant.
|
||||
let mut a = seeded(42);
|
||||
let mut b = seeded(42);
|
||||
for _ in 0..50 {
|
||||
assert_eq!(generate(&mut a), generate(&mut b));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_seeds_diverge() {
|
||||
// Not a guarantee for a single draw, but across a run the sequences
|
||||
// must differ — otherwise the RNG isn't actually being threaded.
|
||||
let mut a = seeded(1);
|
||||
let mut b = seeded(2);
|
||||
let seq_a: Vec<_> = (0..30).map(|_| generate(&mut a)).collect();
|
||||
let seq_b: Vec<_> = (0..30).map(|_| generate(&mut b)).collect();
|
||||
assert_ne!(seq_a, seq_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_returns_bare_name_when_free() {
|
||||
let mut rng = seeded(3);
|
||||
let name = generate_unique(&mut rng, |_| false);
|
||||
assert!(
|
||||
!name.ends_with("-2"),
|
||||
"no suffix expected when nothing taken"
|
||||
);
|
||||
assert!(name.contains('-'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_appends_incrementing_suffix_on_collision() {
|
||||
// Reserve the bare name and the first two suffixed variants; the
|
||||
// generator must walk past them to `-4`.
|
||||
let mut probe = seeded(9);
|
||||
let base = generate(&mut probe);
|
||||
let taken: HashSet<String> = [base.clone(), format!("{base}-2"), format!("{base}-3")]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut rng = seeded(9);
|
||||
let name = generate_unique(&mut rng, |n| taken.contains(n));
|
||||
assert_eq!(name, format!("{base}-4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_suffix_starts_at_two() {
|
||||
let mut probe = seeded(11);
|
||||
let base = generate(&mut probe);
|
||||
let taken: HashSet<String> = [base.clone()].into_iter().collect();
|
||||
|
||||
let mut rng = seeded(11);
|
||||
let name = generate_unique(&mut rng, |n| taken.contains(n));
|
||||
assert_eq!(name, format!("{base}-2"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user