rusty_whsp/
lib.rs

1use std::{borrow::Cow, collections::HashMap, env, fmt};
2
3pub type ConfigType = &'static str;
4
5#[derive(Debug, Clone)]
6pub enum ValidValue<'a> {
7    Number(i64),
8    String(Cow<'a, str>),
9    Boolean(bool),
10}
11
12pub struct Whsp<'a> {
13    pub config_set: HashMap<&'a str, ConfigOptionBase<'a>>,
14    pub short_options: HashMap<&'a str, &'a str>,
15    pub options: WhspOptions,
16}
17
18#[derive(Debug)]
19pub struct WhspOptions {
20    pub allow_positionals: bool,
21    pub env_prefix: Option<&'static str>,
22    pub usage: Option<&'static str>,
23}
24
25#[derive(Debug)]
26pub struct ConfigOptionBase<'a> {
27    pub config_type: ConfigType,
28    pub short: Option<&'a str>,
29    pub default: Option<ValidValue<'a>>,
30    pub description: Option<&'a str>,
31    pub validate: Option<Validator>,
32    pub multiple: bool,
33}
34
35#[derive(Debug)]
36pub enum Validator {
37    NumberRange(i64, i64),
38    Regex(&'static str),
39    None,
40}
41
42impl<'a> Whsp<'a> {
43    pub fn num(&mut self, fields: HashMap<&'a str, ConfigOptionBase<'a>>) {
44        for (name, mut option) in fields {
45            option.config_type = "number";
46            self.config_set.insert(name, option);
47        }
48    }
49
50    pub fn num_list(&mut self, fields: HashMap<&'a str, ConfigOptionBase<'a>>) {
51        for (name, mut option) in fields {
52            option.config_type = "number";
53            option.multiple = true;
54            self.config_set.insert(name, option);
55        }
56    }
57
58    pub fn opt(&mut self, fields: HashMap<&'a str, ConfigOptionBase<'a>>) {
59        for (name, mut option) in fields {
60            option.config_type = "string";
61            self.config_set.insert(name, option);
62        }
63    }
64
65    pub fn opt_list(&mut self, fields: HashMap<&'a str, ConfigOptionBase<'a>>) {
66        for (name, mut option) in fields {
67            option.config_type = "string";
68            option.multiple = true;
69            self.config_set.insert(name, option);
70        }
71    }
72
73    pub fn flag(&mut self, fields: HashMap<&'a str, ConfigOptionBase<'a>>) {
74        for (name, mut option) in fields {
75            option.config_type = "boolean";
76            self.config_set.insert(name, option);
77        }
78    }
79
80    pub fn flag_list(&mut self, fields: HashMap<&'a str, ConfigOptionBase<'a>>) {
81        for (name, mut option) in fields {
82            option.config_type = "boolean";
83            option.multiple = true;
84            self.config_set.insert(name, option);
85        }
86    }
87
88    pub fn validate_name(
89        &mut self,
90        name: &'a str,
91        option: &ConfigOptionBase<'a>,
92    ) -> Result<(), String> {
93        if !name.chars().all(char::is_alphanumeric) {
94            return Err(format!(
95                "Invalid option name: {name}, must be alphanumeric."
96            ));
97        }
98        if let Some(short) = option.short {
99            if self.short_options.contains_key(short) {
100                return Err(format!("Short option {short} is already in use."));
101            }
102            self.short_options.insert(short, name);
103        }
104        Ok(())
105    }
106
107    pub fn write_env(&self, parsed: &OptionsResult) {
108        if let Some(prefix) = self.options.env_prefix {
109            for (field, value) in &parsed.values {
110                let env_key = to_env_key(prefix, field);
111                let env_value = to_env_val(value);
112                env::set_var(env_key, env_value);
113            }
114        }
115    }
116
117    pub fn parse_raw(&self, args: &'a [String]) -> OptionsResult<'a> {
118        let mut values = HashMap::new();
119        let mut positionals = Vec::new();
120        let mut i = 0;
121
122        while i < args.len() {
123            let arg = &args[i];
124            if let Some(key) = arg.strip_prefix("--") {
125                if let Some(config) = self.config_set.get(key) {
126                    if config.config_type == "boolean" {
127                        values.insert(key, ValidValue::Boolean(true));
128                    } else if i + 1 < args.len() {
129                        let val = &args[i + 1];
130                        values.insert(
131                            key,
132                            match config.config_type {
133                                "string" => ValidValue::String(val.into()),
134                                "number" => ValidValue::Number(val.parse().unwrap()),
135                                _ => panic!("Unknown config type"),
136                            },
137                        );
138                        i += 1;
139                    }
140                }
141            } else if let Some(short) = arg.strip_prefix('-') {
142                if let Some(&key) = self.short_options.get(short) {
143                    if let Some(config) = self.config_set.get(key) {
144                        if config.config_type == "boolean" {
145                            values.insert(key, ValidValue::Boolean(true));
146                        } else if i + 1 < args.len() {
147                            let val = &args[i + 1];
148                            values.insert(
149                                key,
150                                match config.config_type {
151                                    "string" => ValidValue::String(val.into()),
152                                    "number" => ValidValue::Number(val.parse().unwrap()),
153                                    _ => panic!("Unknown config type"),
154                                },
155                            );
156                            i += 1;
157                        }
158                    }
159                }
160            } else {
161                positionals.push(arg.as_str());
162            }
163            i += 1;
164        }
165
166        OptionsResult {
167            values,
168            positionals,
169        }
170    }
171
172    pub fn validate(&self, o: &HashMap<String, ValidValue>) -> Result<(), String> {
173        for (field, value) in o {
174            let config = self
175                .config_set
176                .get(field.as_str())
177                .ok_or(format!("Unknown config option: {field}"))?;
178            validate_options(config, value)?;
179        }
180        Ok(())
181    }
182
183    pub fn set_defaults_from_env(&mut self) {
184        if let Some(prefix) = self.options.env_prefix {
185            for (key, option) in self.config_set.iter_mut() {
186                let env_key = to_env_key(prefix, key);
187                if let Ok(val) = env::var(&env_key) {
188                    let valid_val = from_env_val(val, option.config_type);
189                    option.default = Some(valid_val);
190                }
191            }
192        }
193    }
194}
195
196impl fmt::Display for ValidValue<'_> {
197    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
198        match self {
199            ValidValue::Number(val) => write!(f, "{val}"),
200            ValidValue::String(val) => write!(f, "{val}"),
201            ValidValue::Boolean(val) => write!(f, "{val}"),
202        }
203    }
204}
205
206impl<'a> ConfigOptionBase<'a> {
207    pub const fn new(
208        config_type: ConfigType,
209        multiple: bool,
210        short: Option<&'a str>,
211        description: Option<&'a str>,
212    ) -> Self {
213        Self {
214            config_type,
215            short,
216            default: None,
217            description,
218            validate: None,
219            multiple,
220        }
221    }
222
223    pub fn validate_value(&self, value: &ValidValue) -> bool {
224        if let Some(ref validate) = self.validate {
225            match *validate {
226                Validator::Regex(regex) => matches!(value, ValidValue::String(s) if regex == s),
227                Validator::NumberRange(min, max) => {
228                    matches!(value, ValidValue::Number(num) if *num >= min && *num <= max)
229                },
230                Validator::None => true,
231            }
232        } else {
233            matches!(
234                (self.config_type, value),
235                ("string", ValidValue::String(_))
236                    | ("number", ValidValue::Number(_))
237                    | ("boolean", ValidValue::Boolean(_))
238            )
239        }
240    }
241}
242
243pub fn to_env_key(prefix: &str, key: &str) -> String {
244    format!("{}_{}", prefix.to_uppercase(), key.to_uppercase())
245}
246
247pub fn from_env_val<'a, E: Into<Cow<'a, str>>>(env: E, config_type: &str) -> ValidValue<'a> {
248    match config_type {
249        "string" => ValidValue::String(env.into()),
250        "number" => ValidValue::Number(env.into().parse().unwrap()),
251        "boolean" => ValidValue::Boolean(env.into() == "1"),
252        _ => panic!("Unknown config type"),
253    }
254}
255
256pub fn to_env_val(value: &ValidValue) -> String {
257    match value {
258        ValidValue::String(v) => v.to_string(),
259        ValidValue::Number(v) => v.to_string(),
260        ValidValue::Boolean(v) => {
261            if *v {
262                "1"
263            } else {
264                "0"
265            }
266        }
267        .to_string(),
268    }
269}
270
271pub fn validate_options(config: &ConfigOptionBase, value: &ValidValue) -> Result<(), String> {
272    if !config.validate_value(value) {
273        return Err(format!("Invalid value {value:?} for option"));
274    }
275    Ok(())
276}
277
278#[derive(Debug)]
279pub struct OptionsResult<'a> {
280    pub values: HashMap<&'a str, ValidValue<'a>>,
281    pub positionals: Vec<&'a str>,
282}