diff --git a/CLAUDE.md b/CLAUDE.md index 76d7fc1..405994e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,7 @@ crates/ ├── maestro/ # umbrella binary `maestro` (cli + daemon + registry, feature-gated) (NAP-245) ├── maestro-protocol/ # shared wire types (NAP-211) ├── maestro-config/ # shared on-disk config schema (NAP-233) +├── maestro-names/ # orchestra-themed session-handle generator (#23) ├── maestro-cli/ # `maestro list/spawn/attach/kill/policy` dispatch (NAP-216) ├── maestro-daemon/ # `maestro daemon` — tmux wrapper + WS endpoints (NAP-212, NAP-214) ├── maestro-registry/ # `maestro registry` — phone book + HTTP API (NAP-213, NAP-215) diff --git a/Cargo.lock b/Cargo.lock index 6376c0d..82c61a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1217,6 +1217,13 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "maestro-names" +version = "0.1.0" +dependencies = [ + "rand 0.8.6", +] + [[package]] name = "maestro-protocol" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d198b78..9a0dd2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ futures-util = { version = "0.3", default-features = false, features = ["sink", jsonwebtoken = { version = "9", default-features = false } libc = "0.2" maestro-config = { path = "crates/maestro-config" } +maestro-names = { path = "crates/maestro-names" } maestro-protocol = { path = "crates/maestro-protocol" } maestro-tui = { path = "crates/maestro-tui" } nix = { version = "0.29", default-features = false, features = ["term", "signal"] } diff --git a/crates/maestro-names/Cargo.toml b/crates/maestro-names/Cargo.toml new file mode 100644 index 0000000..011cd23 --- /dev/null +++ b/crates/maestro-names/Cargo.toml @@ -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"] } diff --git a/crates/maestro-names/src/lib.rs b/crates/maestro-names/src/lib.rs new file mode 100644 index 0000000..6bc7330 --- /dev/null +++ b/crates/maestro-names/src/lib.rs @@ -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(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(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::>().len(), + ADJECTIVES.len(), + "duplicate adjective" + ); + assert_eq!( + NOUNS.iter().collect::>().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 = [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 = [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")); + } +}