yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Workspace name minting (DESIGN §3.1): two words from an embedded wordlist,
//! hyphenated — `cobalt-gecko`. The name is the workspace's directory leaf
//! **and** the `--as` identity of every ball claim that workspace makes (§3.2),
//! so a fresh name must collide with neither a live workspace nor a live claim.
//!
//! The mint is a **pure function over an injected RNG and an occupied set**
//! ([`mint`]): one RNG draw picks a start index into the pool of ordered
//! distinct word pairs, then the scan walks forward with wraparound to the
//! first unoccupied name. Collision retry is that scan; exhaustion is the scan
//! running the whole pool out ([`MintError::Exhausted`]). No retry budget, no
//! probabilistic termination — the walk is bounded by the pool itself.
//!
//! The occupied set ([`occupied`]) is assembled, never stored: the readdir of
//! the names root plus the claimants the caller read from `bl list --json`
//! across enumerated projects. **The dir's existence is the registration**
//! (§3.1) — there is no registry file to keep in step.

use std::collections::HashSet;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

/// The embedded pool (§3.1). Provenance and licence are recorded in the file's
/// own header; it is data, so it ships in the binary via `include_str!`.
const WORDS_TXT: &str = include_str!("words.txt");

/// The one way a mint fails: every pair in the pool is already taken.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MintError {
    /// All `n * (n-1)` ordered distinct pairs are occupied.
    #[error("name pool exhausted: all {0} two-word combinations are occupied")]
    Exhausted(usize),
}

/// The injected randomness the mint is pure over. A trait rather than a
/// concrete generator so a test drives the mint with a scripted draw and the
/// production seeding stays out of the pure path.
pub trait Rng {
    /// The next 64 random bits.
    fn next_u64(&mut self) -> u64;
}

/// SplitMix64 — the production [`Rng`]. Chosen because it is ~6 lines of
/// wrapping arithmetic: the mint needs one draw per name, and a `rand`
/// dependency for that is not worth the supply-chain surface (AGENTS.md rule 6:
/// zero new dependencies).
#[derive(Debug, Clone)]
pub struct SplitMix64 {
    state: u64,
}

impl SplitMix64 {
    /// A generator from an explicit seed — reproducible, and the seam the
    /// entropy path funnels through.
    pub fn from_seed(seed: u64) -> Self {
        Self { state: seed }
    }

    /// A generator seeded from the wall clock and this process's id. Neither
    /// input is secret — the mint is a collision-avoidance device, not a
    /// security one, and the occupied-set check is what actually guarantees
    /// uniqueness.
    pub fn from_entropy() -> Self {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_nanos() as u64;
        Self::from_seed(nanos ^ (u64::from(std::process::id()) << 32))
    }
}

impl Rng for SplitMix64 {
    fn next_u64(&mut self) -> u64 {
        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
        let mut z = self.state;
        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
        z ^ (z >> 31)
    }
}

/// The embedded wordlist as words: non-blank, non-comment lines, trimmed.
fn wordlist() -> Vec<&'static str> {
    WORDS_TXT
        .lines()
        .map(str::trim)
        .filter(|l| !l.is_empty() && !l.starts_with('#'))
        .collect()
}

/// The `idx`-th ordered pair of *distinct* words, hyphenated. The pool is
/// `n * (n-1)` and the map is a bijection onto it: `idx / (n-1)` selects the
/// first word and `idx % (n-1)` the second, skipped past the first so a word
/// never pairs with itself. Out-of-range indices cannot occur (callers derive
/// `idx` modulo the pool) and read as empty rather than panicking (rule 4).
fn pair(words: &[&str], idx: usize) -> String {
    let span = words.len().saturating_sub(1).max(1);
    let first = idx / span;
    let second = idx % span;
    let second = second + usize::from(second >= first);
    let at = |i: usize| words.get(i).copied().unwrap_or_default();
    format!("{}-{}", at(first), at(second))
}

/// The mint over an explicit wordlist — the whole algorithm, kept
/// list-injectable so tests exercise collision retry and exhaustion on a
/// two-word pool instead of the embedded one.
fn mint_from(
    words: &[&str],
    rng: &mut dyn Rng,
    occupied: &HashSet<String>,
) -> Result<String, MintError> {
    let pool = words.len() * words.len().saturating_sub(1);
    let start = (rng.next_u64() % pool.max(1) as u64) as usize;
    for step in 0..pool {
        let name = pair(words, (start + step) % pool);
        if !occupied.contains(&name) {
            return Ok(name);
        }
    }
    Err(MintError::Exhausted(pool))
}

/// Mint a name from the embedded wordlist (§3.1): the first pair not in
/// `occupied`, scanning from an RNG-chosen start. Pure — same RNG and same
/// occupied set, same name.
pub fn mint(rng: &mut dyn Rng, occupied: &HashSet<String>) -> Result<String, MintError> {
    mint_from(&wordlist(), rng, occupied)
}

/// The occupied set (§3.1): every directory leaf under `names_root` plus every
/// `claimant` the caller collected from `bl list --json` across enumerated
/// projects. Deliberately *wider* than workspace enumeration — enumeration
/// wants dirs holding `repo.git`, but a half-created dir still owns its name,
/// so occupancy asks only "does the leaf exist". A missing root contributes
/// nothing: the general path with no inputs, not a bootstrap special case.
pub fn occupied(names_root: &Path, claimants: &[String]) -> HashSet<String> {
    std::fs::read_dir(names_root)
        .into_iter()
        .flatten()
        .flatten()
        .filter(|e| e.path().is_dir())
        .filter_map(|e| e.file_name().into_string().ok())
        .chain(claimants.iter().cloned())
        .collect()
}

#[cfg(test)]
mod tests;