ryo-symbol 0.1.0

Symbol system for Rust codebase - unique identifiers and file path management
Documentation
//! Crate name type with validation

use serde::{Deserialize, Serialize};
use thiserror::Error;

/// Reserved crate names that cannot be used
const RESERVED_NAMES: &[&str] = &["test", "proc-macro", "proc_macro", "build"];

/// Crate name from Cargo.toml
///
/// Represents a valid Cargo crate name with validation rules:
/// - Non-empty
/// - Starts with ASCII letter (`a-z`, `A-Z`)
/// - Subsequent chars: ASCII alphanumeric, `-`, or `_`
/// - Max 64 characters (crates.io limit)
/// - No reserved names
///
/// # Naming Convention
///
/// | Format | Example | Usage |
/// |--------|---------|-------|
/// | Cargo name | `ryo-mutations` | `[package] name` in Cargo.toml |
/// | Module name | `ryo_mutations` | `use ryo_mutations::...` |
///
/// Use `to_module_name()` to convert (hyphen → underscore).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CrateName(String);

impl CrateName {
    /// Create a new CrateName with validation
    ///
    /// # Validation Rules (Cargo naming convention)
    /// - Non-empty
    /// - Starts with ASCII letter (`a-z`, `A-Z`)
    /// - Subsequent chars: ASCII alphanumeric, `-`, or `_`
    /// - Max 64 characters
    /// - Not a reserved name
    pub fn new(name: impl Into<String>) -> Result<Self, InvalidCrateNameError> {
        let name = name.into();
        Self::validate(&name)?;
        Ok(Self(name))
    }

    /// Create without validation (internal/test use only)
    pub(crate) fn new_unchecked(name: impl Into<String>) -> Self {
        Self(name.into())
    }

    /// Create for testing (available with test-utils feature)
    #[cfg(any(test, feature = "test-utils"))]
    pub fn new_for_test(name: impl Into<String>) -> Self {
        Self(name.into())
    }

    fn validate(name: &str) -> Result<(), InvalidCrateNameError> {
        // Empty check
        if name.is_empty() {
            return Err(InvalidCrateNameError::Empty);
        }

        // Length check
        if name.len() > 64 {
            return Err(InvalidCrateNameError::TooLong(name.to_string()));
        }

        // Reserved name check
        if RESERVED_NAMES.contains(&name) {
            return Err(InvalidCrateNameError::Reserved(name.to_string()));
        }

        // First character must be ASCII letter
        let mut chars = name.chars();
        let first = chars
            .next()
            .expect("non-empty checked at function entry (name.is_empty() guard)");
        if !first.is_ascii_alphabetic() {
            return Err(InvalidCrateNameError::InvalidStart(name.to_string()));
        }

        // Subsequent characters: ASCII alphanumeric, hyphen, or underscore
        for c in chars {
            if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
                return Err(InvalidCrateNameError::InvalidChar(name.to_string()));
            }
        }

        Ok(())
    }

    /// Get as string slice
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Convert to module name (hyphen → underscore)
    ///
    /// # Example
    /// ```
    /// # use ryo_symbol::CrateName;
    /// let name = CrateName::new("ryo-mutations").unwrap();
    /// assert_eq!(name.to_module_name(), "ryo_mutations");
    /// ```
    pub fn to_module_name(&self) -> String {
        self.0.replace('-', "_")
    }

    /// Consume and return the inner string
    pub fn into_string(self) -> String {
        self.0
    }
}

impl AsRef<str> for CrateName {
    fn as_ref(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for CrateName {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

/// Invalid crate name error
#[derive(Debug, Clone, Error)]
pub enum InvalidCrateNameError {
    /// The supplied crate name was an empty string.
    #[error("crate name cannot be empty")]
    Empty,

    /// The first character was not an ASCII letter (Cargo requires
    /// names to begin with `[a-zA-Z]`). Carries the offending input.
    #[error("crate name must start with a letter: '{0}'")]
    InvalidStart(String),

    /// The name contained a character outside the allowed
    /// `[a-zA-Z0-9_-]` set. Carries the offending input.
    #[error("crate name contains invalid character: '{0}'")]
    InvalidChar(String),

    /// The name exceeded the 64-character maximum length enforced by
    /// this crate (Cargo itself allows more, but ryo restricts for
    /// SymbolPath ergonomics). Carries the offending input.
    #[error("crate name too long (max 64 chars): '{0}'")]
    TooLong(String),

    /// The name matched a Rust language reserved keyword (e.g. `self`,
    /// `super`, `crate`). Carries the offending input.
    #[error("crate name is reserved: '{0}'")]
    Reserved(String),
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_valid_crate_names() {
        assert!(CrateName::new("ryo").is_ok());
        assert!(CrateName::new("ryo-mutations").is_ok());
        assert!(CrateName::new("ryo_symbol").is_ok());
        assert!(CrateName::new("Tokio").is_ok());
        assert!(CrateName::new("a").is_ok());
    }

    #[test]
    fn test_invalid_crate_names() {
        // Empty
        assert!(matches!(
            CrateName::new(""),
            Err(InvalidCrateNameError::Empty)
        ));

        // Starts with number
        assert!(matches!(
            CrateName::new("123abc"),
            Err(InvalidCrateNameError::InvalidStart(_))
        ));

        // Starts with hyphen
        assert!(matches!(
            CrateName::new("-abc"),
            Err(InvalidCrateNameError::InvalidStart(_))
        ));

        // Invalid character
        assert!(matches!(
            CrateName::new("abc.def"),
            Err(InvalidCrateNameError::InvalidChar(_))
        ));

        // Reserved
        assert!(matches!(
            CrateName::new("test"),
            Err(InvalidCrateNameError::Reserved(_))
        ));
    }

    #[test]
    fn test_to_module_name() {
        let name = CrateName::new("ryo-mutations").unwrap();
        assert_eq!(name.to_module_name(), "ryo_mutations");

        let name = CrateName::new("already_underscore").unwrap();
        assert_eq!(name.to_module_name(), "already_underscore");
    }
}