joyful 0.1.1

Generate delightful, random word combinations - Rust port of the joyful TypeScript library
Documentation
//! # Joyful
//!
//! Generate delightful, random word combinations.
//!
//! This library creates memorable, pronounceable strings by combining a prefix with random
//! words from curated word lists. Perfect for generating friendly names, identifiers, or
//! random text that's easy to read and remember.
//!
//! ## Quick Start
//!
//! ```
//! use joyful::{joyful, Options};
//!
//! // Generate with default options (2 segments, dash separator)
//! let name = joyful(Options::default()).unwrap();
//! println!("{}", name); // e.g., "happy-mountain"
//!
//! // Customize the output
//! let custom = joyful(Options {
//!     segments: 3,
//!     separator: '_',
//!     max_length: Some(20),
//! }).unwrap();
//! println!("{}", custom); // e.g., "cool_river_sky"
//! ```
//!
//! ## Features
//!
//! - Configurable number of word segments
//! - Custom separators
//! - Optional maximum length constraints
//! - Guaranteed word uniqueness within each generated string
//! - Always starts with a memorable prefix word

use rand::seq::IndexedRandom;
use std::collections::HashSet;

mod constraints;
pub mod errors;
mod utils;
pub mod words;

use crate::{
    constraints::{DEFAULT_SEPARATOR, MIN_SEGMENTS},
    errors::ValidationError,
    utils::*,
    words::{MAX_SEGMENTS, PREFIXES},
};

/// Configuration options for generating word combinations.
///
/// # Examples
///
/// ```
/// use joyful::Options;
///
/// // Use defaults (2 segments, '-' separator, no length limit)
/// let opts = Options::default();
///
/// // Customize all options
/// let custom_opts = Options {
///     segments: 4,
///     separator: '_',
///     max_length: Some(30),
/// };
/// ```
#[derive(Debug)]
pub struct Options {
    /// Number of words to include in the generated string.
    ///
    /// Must be between 2 and 300 (inclusive).
    pub segments: usize,

    /// Character used to separate words in the output.
    ///
    /// Defaults to `'-'` (dash).
    pub separator: char,

    /// Optional maximum length constraint for the entire generated string.
    ///
    /// When set, the generator will attempt to select words that fit within
    /// this limit. If `None`, no length constraint is applied.
    ///
    /// Note: Very restrictive length constraints may be impossible to satisfy
    /// and will result in a `ValidationError::LengthConstraintImpossible` error.
    pub max_length: Option<usize>,
}

impl Default for Options {
    /// Creates a default `Options` configuration.
    ///
    /// # Default Values
    ///
    /// - `segments`: 2
    /// - `separator`: '-' (dash)
    /// - `max_length`: None (no length constraint)
    fn default() -> Self {
        Self {
            segments: MIN_SEGMENTS,
            separator: DEFAULT_SEPARATOR,
            max_length: None,
        }
    }
}

impl Options {
    /// Validates the options configuration.
    ///
    /// Returns `None` if options are valid, or `Some(ValidationError)` if invalid.
    fn validate(&self) -> Option<ValidationError> {
        if self.segments > *MAX_SEGMENTS {
            return Some(ValidationError::TooManySegments);
        }

        if self.segments < MIN_SEGMENTS {
            return Some(ValidationError::TooFewSegments);
        }

        if let Some(v) = self.max_length
            && v < 1
        {
            return Some(ValidationError::InvalidValue(v));
        }

        None
    }
}

/// Generates a random, delightful word combination.
///
/// Creates a string by combining a random prefix with additional unique words,
/// separated by the specified separator character.
///
/// # Arguments
///
/// * `options` - Configuration for the generation process. See [`Options`] for details.
///
/// # Returns
///
/// * `Ok(String)` - A generated string matching the specified options
/// * `Err(ValidationError)` - If the options are invalid or constraints are impossible to satisfy
///
/// # Errors
///
/// This function returns an error in the following cases:
///
/// * [`ValidationError::TooFewSegments`] - If `segments` is less than 2
/// * [`ValidationError::TooManySegments`] - If `segments` exceeds 300
/// * [`ValidationError::InvalidValue`] - If `max_length` is set to a value less than 1
/// * [`ValidationError::LengthConstraintImpossible`] - If the `max_length` constraint
///   cannot be satisfied with the requested number of segments
///
/// # Examples
///
/// ```
/// use joyful::{joyful, Options};
///
/// // Basic usage with defaults
/// let result = joyful(Options::default()).unwrap();
/// assert_eq!(result.split('-').count(), 2);
///
/// // Custom configuration
/// let result = joyful(Options {
///     segments: 3,
///     separator: '.',
///     max_length: None,
/// }).unwrap();
/// assert_eq!(result.split('.').count(), 3);
///
/// // With length constraint
/// let result = joyful(Options {
///     segments: 2,
///     separator: '-',
///     max_length: Some(15),
/// }).unwrap();
/// assert!(result.len() <= 15);
/// ```
///
/// # Uniqueness Guarantee
///
/// Each word in the generated string is guaranteed to be unique (no duplicates).
/// The first word is always chosen from a curated list of prefixes, while subsequent
/// words are selected from the full word list.
pub fn joyful(options: Options) -> Result<String, ValidationError> {
    if let Some(validation_error) = options.validate() {
        return Err(validation_error);
    }

    let mut used = HashSet::new();
    let mut words: Vec<String> = vec![];
    let mut add_word = |prefix: String| {
        used.insert(prefix.clone());
        words.push(prefix);
    };

    let Some(max_length) = options.max_length else {
        let prefix = PREFIXES.choose(&mut rand::rng()).unwrap().to_owned();
        add_word(prefix);

        for _ in 1..options.segments {
            let word = get_unique_category(&used);
            used.insert(word.clone());
            words.push(word)
        }

        return Ok(words.join(&options.separator.to_string()));
    };

    let prefix_budget = calc_budget(max_length, 0, options.segments - 1, false);
    let prefix =
        get_prefix_with_budget(prefix_budget).ok_or(ValidationError::LengthConstraintImpossible)?;

    let mut used_length = prefix.len();

    add_word(prefix);

    for i in 1..options.segments {
        let words_still_needed = options.segments - i - 1;
        let next_word_budget = calc_budget(max_length, used_length, words_still_needed, true);
        let word = get_unique_word_with_budget(&used, next_word_budget)
            .ok_or(ValidationError::LengthConstraintImpossible)?;

        used_length += word.len() + 1;
        used.insert(word.clone());
        words.push(word);
    }

    Ok(words.join(&options.separator.to_string()))
}

#[cfg(test)]
mod test {
    use super::*;
    use crate::words::PREFIXES;

    #[test]
    fn it_works_with_defaults() {
        let generated = joyful(Options::default()).unwrap();
        let splitted = generated.split(DEFAULT_SEPARATOR);

        assert_eq!(splitted.count(), MIN_SEGMENTS)
    }

    #[test]
    fn ensure_randomness() {
        let mut map: HashSet<String> = HashSet::new();

        for _ in 0..20 {
            let r = joyful(Options::default()).unwrap();

            match map.get(&r) {
                Some(_) => {
                    panic!("broken randomness");
                }
                None => {
                    map.insert(r);
                }
            }
        }

        assert_eq!(map.len(), 20);
    }

    #[test]
    fn word_uniqness() {
        let mut dataset: HashSet<String> = HashSet::new();
        let generated = joyful(Options {
            segments: 300,
            ..Default::default()
        })
        .unwrap();

        let words = generated.split(DEFAULT_SEPARATOR);

        for word in words {
            let None = dataset.get(word) else {
                panic!("word uniqness broken!");
            };

            dataset.insert(word.to_string());
        }

        assert_eq!(dataset.len(), 300)
    }

    #[test]
    fn first_word_is_always_prefix() {
        let generated = joyful(Options::default()).unwrap();
        let parts: Vec<String> = generated
            .split(DEFAULT_SEPARATOR)
            .map(|s| s.to_string())
            .collect();
        let prefix = parts.first().unwrap();

        assert!(PREFIXES.contains(prefix));
    }

    #[test]
    fn respects_max_length() {
        let options = Options {
            segments: 3,
            max_length: Some(20),
            ..Default::default()
        };

        let result = joyful(options);

        assert!(result.is_ok());
        let result = result.unwrap();

        assert!(result.len() <= 20);
        assert_eq!(result.split(DEFAULT_SEPARATOR).count(), 3);
    }

    #[test]
    fn fails_on_impossible_constraints() {
        let options = Options {
            segments: 10,
            max_length: Some(3),
            ..Default::default()
        };

        let result = joyful(options);

        assert!(matches!(
            result,
            Err(ValidationError::LengthConstraintImpossible)
        ))
    }

    #[test]
    fn options_segments_validation_min() {
        let options = Options {
            segments: 0,
            ..Default::default()
        };

        if let Some(e) = options.validate() {
            assert!(matches!(e, ValidationError::TooFewSegments))
        } else {
            panic!("should have returned an error");
        }
    }

    #[test]
    fn options_segments_validation_max() {
        let options = Options {
            segments: 10_000,
            ..Default::default()
        };

        if let Some(e) = options.validate() {
            assert!(matches!(e, ValidationError::TooManySegments))
        } else {
            panic!("should have returned an error");
        }
    }
}