Skip to main content

ryo_symbol/
crate_name.rs

1//! Crate name type with validation
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6/// Reserved crate names that cannot be used
7const RESERVED_NAMES: &[&str] = &["test", "proc-macro", "proc_macro", "build"];
8
9/// Crate name from Cargo.toml
10///
11/// Represents a valid Cargo crate name with validation rules:
12/// - Non-empty
13/// - Starts with ASCII letter (`a-z`, `A-Z`)
14/// - Subsequent chars: ASCII alphanumeric, `-`, or `_`
15/// - Max 64 characters (crates.io limit)
16/// - No reserved names
17///
18/// # Naming Convention
19///
20/// | Format | Example | Usage |
21/// |--------|---------|-------|
22/// | Cargo name | `ryo-mutations` | `[package] name` in Cargo.toml |
23/// | Module name | `ryo_mutations` | `use ryo_mutations::...` |
24///
25/// Use `to_module_name()` to convert (hyphen → underscore).
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub struct CrateName(String);
28
29impl CrateName {
30    /// Create a new CrateName with validation
31    ///
32    /// # Validation Rules (Cargo naming convention)
33    /// - Non-empty
34    /// - Starts with ASCII letter (`a-z`, `A-Z`)
35    /// - Subsequent chars: ASCII alphanumeric, `-`, or `_`
36    /// - Max 64 characters
37    /// - Not a reserved name
38    pub fn new(name: impl Into<String>) -> Result<Self, InvalidCrateNameError> {
39        let name = name.into();
40        Self::validate(&name)?;
41        Ok(Self(name))
42    }
43
44    /// Create without validation (internal/test use only)
45    pub(crate) fn new_unchecked(name: impl Into<String>) -> Self {
46        Self(name.into())
47    }
48
49    /// Create for testing (available with test-utils feature)
50    #[cfg(any(test, feature = "test-utils"))]
51    pub fn new_for_test(name: impl Into<String>) -> Self {
52        Self(name.into())
53    }
54
55    fn validate(name: &str) -> Result<(), InvalidCrateNameError> {
56        // Empty check
57        if name.is_empty() {
58            return Err(InvalidCrateNameError::Empty);
59        }
60
61        // Length check
62        if name.len() > 64 {
63            return Err(InvalidCrateNameError::TooLong(name.to_string()));
64        }
65
66        // Reserved name check
67        if RESERVED_NAMES.contains(&name) {
68            return Err(InvalidCrateNameError::Reserved(name.to_string()));
69        }
70
71        // First character must be ASCII letter
72        let mut chars = name.chars();
73        let first = chars
74            .next()
75            .expect("non-empty checked at function entry (name.is_empty() guard)");
76        if !first.is_ascii_alphabetic() {
77            return Err(InvalidCrateNameError::InvalidStart(name.to_string()));
78        }
79
80        // Subsequent characters: ASCII alphanumeric, hyphen, or underscore
81        for c in chars {
82            if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
83                return Err(InvalidCrateNameError::InvalidChar(name.to_string()));
84            }
85        }
86
87        Ok(())
88    }
89
90    /// Get as string slice
91    pub fn as_str(&self) -> &str {
92        &self.0
93    }
94
95    /// Convert to module name (hyphen → underscore)
96    ///
97    /// # Example
98    /// ```
99    /// # use ryo_symbol::CrateName;
100    /// let name = CrateName::new("ryo-mutations").unwrap();
101    /// assert_eq!(name.to_module_name(), "ryo_mutations");
102    /// ```
103    pub fn to_module_name(&self) -> String {
104        self.0.replace('-', "_")
105    }
106
107    /// Consume and return the inner string
108    pub fn into_string(self) -> String {
109        self.0
110    }
111}
112
113impl AsRef<str> for CrateName {
114    fn as_ref(&self) -> &str {
115        &self.0
116    }
117}
118
119impl std::fmt::Display for CrateName {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        write!(f, "{}", self.0)
122    }
123}
124
125/// Invalid crate name error
126#[derive(Debug, Clone, Error)]
127pub enum InvalidCrateNameError {
128    /// The supplied crate name was an empty string.
129    #[error("crate name cannot be empty")]
130    Empty,
131
132    /// The first character was not an ASCII letter (Cargo requires
133    /// names to begin with `[a-zA-Z]`). Carries the offending input.
134    #[error("crate name must start with a letter: '{0}'")]
135    InvalidStart(String),
136
137    /// The name contained a character outside the allowed
138    /// `[a-zA-Z0-9_-]` set. Carries the offending input.
139    #[error("crate name contains invalid character: '{0}'")]
140    InvalidChar(String),
141
142    /// The name exceeded the 64-character maximum length enforced by
143    /// this crate (Cargo itself allows more, but ryo restricts for
144    /// SymbolPath ergonomics). Carries the offending input.
145    #[error("crate name too long (max 64 chars): '{0}'")]
146    TooLong(String),
147
148    /// The name matched a Rust language reserved keyword (e.g. `self`,
149    /// `super`, `crate`). Carries the offending input.
150    #[error("crate name is reserved: '{0}'")]
151    Reserved(String),
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_valid_crate_names() {
160        assert!(CrateName::new("ryo").is_ok());
161        assert!(CrateName::new("ryo-mutations").is_ok());
162        assert!(CrateName::new("ryo_symbol").is_ok());
163        assert!(CrateName::new("Tokio").is_ok());
164        assert!(CrateName::new("a").is_ok());
165    }
166
167    #[test]
168    fn test_invalid_crate_names() {
169        // Empty
170        assert!(matches!(
171            CrateName::new(""),
172            Err(InvalidCrateNameError::Empty)
173        ));
174
175        // Starts with number
176        assert!(matches!(
177            CrateName::new("123abc"),
178            Err(InvalidCrateNameError::InvalidStart(_))
179        ));
180
181        // Starts with hyphen
182        assert!(matches!(
183            CrateName::new("-abc"),
184            Err(InvalidCrateNameError::InvalidStart(_))
185        ));
186
187        // Invalid character
188        assert!(matches!(
189            CrateName::new("abc.def"),
190            Err(InvalidCrateNameError::InvalidChar(_))
191        ));
192
193        // Reserved
194        assert!(matches!(
195            CrateName::new("test"),
196            Err(InvalidCrateNameError::Reserved(_))
197        ));
198    }
199
200    #[test]
201    fn test_to_module_name() {
202        let name = CrateName::new("ryo-mutations").unwrap();
203        assert_eq!(name.to_module_name(), "ryo_mutations");
204
205        let name = CrateName::new("already_underscore").unwrap();
206        assert_eq!(name.to_module_name(), "already_underscore");
207    }
208}