1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5
6pub mod prelude {
8 pub use crate::{
9 CliOption, CliOptionError, CliOptionName, OptionNameError, OptionValue,
10 is_valid_option_name, split_equals_token,
11 };
12}
13
14#[derive(Clone, Debug, PartialEq, Eq)]
16pub enum OptionNameError {
17 Empty,
19 EdgeHyphen,
21 InvalidCharacter,
23}
24
25impl fmt::Display for OptionNameError {
26 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
27 match self {
28 Self::Empty => formatter.write_str("option name cannot be empty"),
29 Self::EdgeHyphen => formatter.write_str("option name cannot start or end with '-'"),
30 Self::InvalidCharacter => formatter
31 .write_str("option name must be ASCII alphanumeric with optional internal hyphens"),
32 }
33 }
34}
35
36impl std::error::Error for OptionNameError {}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
40pub enum CliOptionError {
41 InvalidName(OptionNameError),
43 MissingLongPrefix,
45 MissingEquals,
47}
48
49impl fmt::Display for CliOptionError {
50 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51 match self {
52 Self::InvalidName(error) => write!(formatter, "{error}"),
53 Self::MissingLongPrefix => formatter.write_str("option token must start with --"),
54 Self::MissingEquals => formatter.write_str("option token must contain '='"),
55 }
56 }
57}
58
59impl std::error::Error for CliOptionError {}
60
61impl From<OptionNameError> for CliOptionError {
62 fn from(error: OptionNameError) -> Self {
63 Self::InvalidName(error)
64 }
65}
66
67#[derive(Clone, Debug, PartialEq, Eq, Hash)]
69pub struct CliOptionName {
70 name: String,
71}
72
73impl CliOptionName {
74 pub fn new(name: impl Into<String>) -> Result<Self, OptionNameError> {
80 let name = name.into();
81 validate_option_name(&name)?;
82 Ok(Self { name })
83 }
84
85 #[must_use]
87 pub fn as_str(&self) -> &str {
88 &self.name
89 }
90}
91
92impl AsRef<str> for CliOptionName {
93 fn as_ref(&self) -> &str {
94 self.as_str()
95 }
96}
97
98impl fmt::Display for CliOptionName {
99 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
100 formatter.write_str(&self.name)
101 }
102}
103
104#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
106pub struct OptionValue {
107 value: String,
108}
109
110impl OptionValue {
111 #[must_use]
113 pub fn new(value: impl Into<String>) -> Self {
114 Self {
115 value: value.into(),
116 }
117 }
118
119 #[must_use]
121 pub fn as_str(&self) -> &str {
122 &self.value
123 }
124
125 #[must_use]
127 pub fn into_string(self) -> String {
128 self.value
129 }
130}
131
132impl AsRef<str> for OptionValue {
133 fn as_ref(&self) -> &str {
134 self.as_str()
135 }
136}
137
138impl From<String> for OptionValue {
139 fn from(value: String) -> Self {
140 Self::new(value)
141 }
142}
143
144impl From<&str> for OptionValue {
145 fn from(value: &str) -> Self {
146 Self::new(value)
147 }
148}
149
150impl fmt::Display for OptionValue {
151 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
152 formatter.write_str(&self.value)
153 }
154}
155
156#[derive(Clone, Debug, PartialEq, Eq, Hash)]
158pub struct CliOption {
159 name: CliOptionName,
160 value: OptionValue,
161}
162
163impl CliOption {
164 #[must_use]
166 pub const fn new(name: CliOptionName, value: OptionValue) -> Self {
167 Self { name, value }
168 }
169
170 pub fn from_name_value(name: &str, value: impl Into<String>) -> Result<Self, CliOptionError> {
176 Ok(Self::new(
177 CliOptionName::new(name)?,
178 OptionValue::new(value),
179 ))
180 }
181
182 pub fn from_equals_token(token: &str) -> Result<Self, CliOptionError> {
188 split_equals_token(token)
189 }
190
191 #[must_use]
193 pub const fn name(&self) -> &CliOptionName {
194 &self.name
195 }
196
197 #[must_use]
199 pub const fn value(&self) -> &OptionValue {
200 &self.value
201 }
202
203 #[must_use]
205 pub fn to_equals_token(&self) -> String {
206 format!("--{}={}", self.name, self.value)
207 }
208}
209
210#[must_use]
212pub fn is_valid_option_name(name: &str) -> bool {
213 validate_option_name(name).is_ok()
214}
215
216pub fn split_equals_token(token: &str) -> Result<CliOption, CliOptionError> {
222 let token = token
223 .strip_prefix("--")
224 .ok_or(CliOptionError::MissingLongPrefix)?;
225 let (name, value) = token.split_once('=').ok_or(CliOptionError::MissingEquals)?;
226
227 CliOption::from_name_value(name, value)
228}
229
230fn validate_option_name(name: &str) -> Result<(), OptionNameError> {
231 let bytes = name.as_bytes();
232 if bytes.is_empty() {
233 return Err(OptionNameError::Empty);
234 }
235
236 if bytes[0] == b'-' || bytes[bytes.len() - 1] == b'-' {
237 return Err(OptionNameError::EdgeHyphen);
238 }
239
240 if bytes
241 .iter()
242 .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-')
243 {
244 Ok(())
245 } else {
246 Err(OptionNameError::InvalidCharacter)
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::{CliOption, CliOptionError, OptionNameError, is_valid_option_name};
253
254 #[test]
255 fn validates_option_names() {
256 assert!(is_valid_option_name("color"));
257 assert!(is_valid_option_name("dry-run"));
258 assert!(!is_valid_option_name(""));
259 assert!(!is_valid_option_name("dry_run"));
260 assert!(!is_valid_option_name("dry-"));
261 }
262
263 #[test]
264 fn builds_options_from_pair_and_equals_token() -> Result<(), CliOptionError> {
265 let pair = CliOption::from_name_value("format", "json")?;
266 let equals = CliOption::from_equals_token("--color=auto")?;
267
268 assert_eq!(pair.to_equals_token(), "--format=json");
269 assert_eq!(equals.name().as_str(), "color");
270 assert_eq!(equals.value().as_str(), "auto");
271 Ok(())
272 }
273
274 #[test]
275 fn rejects_invalid_option_tokens() {
276 assert_eq!(
277 CliOption::from_equals_token("color=auto"),
278 Err(CliOptionError::MissingLongPrefix)
279 );
280 assert_eq!(
281 CliOption::from_equals_token("--color"),
282 Err(CliOptionError::MissingEquals)
283 );
284 assert_eq!(
285 CliOption::from_equals_token("--dry_=true"),
286 Err(CliOptionError::InvalidName(
287 OptionNameError::InvalidCharacter
288 ))
289 );
290 }
291}