Skip to main content

use_option/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5
6/// Commonly used CLI option primitives.
7pub mod prelude {
8    pub use crate::{
9        CliOption, CliOptionError, CliOptionName, OptionNameError, OptionValue,
10        is_valid_option_name, split_equals_token,
11    };
12}
13
14/// Validation errors for CLI option names.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub enum OptionNameError {
17    /// The option name was empty.
18    Empty,
19    /// The option name started or ended with a hyphen.
20    EdgeHyphen,
21    /// The option name contained a character outside the supported primitive set.
22    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/// Errors for primitive CLI option construction.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub enum CliOptionError {
41    /// The option name was invalid.
42    InvalidName(OptionNameError),
43    /// The token did not begin with `--`.
44    MissingLongPrefix,
45    /// The token did not contain an equals sign.
46    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/// A validated CLI option name without a leading `--`.
68#[derive(Clone, Debug, PartialEq, Eq, Hash)]
69pub struct CliOptionName {
70    name: String,
71}
72
73impl CliOptionName {
74    /// Creates a validated CLI option name.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`OptionNameError`] when `name` is not a basic option name.
79    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    /// Returns the option name without a leading `--`.
86    #[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/// An owned CLI option value.
105#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
106pub struct OptionValue {
107    value: String,
108}
109
110impl OptionValue {
111    /// Creates an owned option value.
112    #[must_use]
113    pub fn new(value: impl Into<String>) -> Self {
114        Self {
115            value: value.into(),
116        }
117    }
118
119    /// Returns the borrowed value.
120    #[must_use]
121    pub fn as_str(&self) -> &str {
122        &self.value
123    }
124
125    /// Returns the owned value.
126    #[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/// A primitive command-line key/value option.
157#[derive(Clone, Debug, PartialEq, Eq, Hash)]
158pub struct CliOption {
159    name: CliOptionName,
160    value: OptionValue,
161}
162
163impl CliOption {
164    /// Creates a CLI option from an already validated name and owned value.
165    #[must_use]
166    pub const fn new(name: CliOptionName, value: OptionValue) -> Self {
167        Self { name, value }
168    }
169
170    /// Creates a CLI option from a name and value pair, such as `--key value`.
171    ///
172    /// # Errors
173    ///
174    /// Returns [`CliOptionError`] when `name` is not a valid option name.
175    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    /// Creates a CLI option from a token like `--key=value`.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`CliOptionError`] when the token does not use `--key=value` form.
187    pub fn from_equals_token(token: &str) -> Result<Self, CliOptionError> {
188        split_equals_token(token)
189    }
190
191    /// Returns the option name.
192    #[must_use]
193    pub const fn name(&self) -> &CliOptionName {
194        &self.name
195    }
196
197    /// Returns the option value.
198    #[must_use]
199    pub const fn value(&self) -> &OptionValue {
200        &self.value
201    }
202
203    /// Returns the `--key=value` token form.
204    #[must_use]
205    pub fn to_equals_token(&self) -> String {
206        format!("--{}={}", self.name, self.value)
207    }
208}
209
210/// Returns whether `name` is a valid primitive CLI option name.
211#[must_use]
212pub fn is_valid_option_name(name: &str) -> bool {
213    validate_option_name(name).is_ok()
214}
215
216/// Splits a token like `--key=value` into a primitive CLI option.
217///
218/// # Errors
219///
220/// Returns [`CliOptionError`] when the token is missing the `--` prefix, the `=`, or a valid name.
221pub 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}