1use std::collections::HashMap;
2
3#[derive(Clone, Debug, Default)]
4pub struct Args {
5 map: HashMap<String, String>,
6 bare_flags: Vec<String>,
7 bare_values: HashMap<String, String>,
8}
9
10impl Args {
11 #[cfg(not(target_arch = "wasm32"))]
12 pub fn current() -> Self {
13 Self::from_argv(std::env::args().skip(1))
14 }
15
16 #[cfg(target_arch = "wasm32")]
17 pub fn current() -> Self {
18 let mut map = HashMap::new();
19 if let Some(window) = web_sys::window() {
20 if let Ok(search) = window.location().search() {
21 parse_query_into(&search, &mut map);
22 }
23 if let Ok(hash) = window.location().hash() {
24 parse_query_into(&hash, &mut map);
25 }
26 }
27 QUERY_OVERRIDE.with(|q| {
28 if let Some((search, hash)) = q.borrow().as_ref() {
29 parse_query_into(search, &mut map);
30 parse_query_into(hash, &mut map);
31 }
32 });
33 Self {
34 map,
35 bare_flags: Vec::new(),
36 bare_values: HashMap::new(),
37 }
38 }
39
40 pub fn from_argv<I, S>(argv: I) -> Self
41 where
42 I: IntoIterator<Item = S>,
43 S: AsRef<str>,
44 {
45 let mut map = HashMap::new();
46 let mut bare_flags = Vec::new();
47 let mut bare_values = HashMap::new();
48 let mut awaiting: Option<String> = None;
49 for arg in argv {
50 if arg.as_ref() == "--" {
51 break;
52 }
53 let Some(stripped) = arg.as_ref().strip_prefix("--") else {
54 if let Some(flag) = awaiting.take() {
55 bare_values.insert(flag, arg.as_ref().to_string());
56 }
57 continue;
58 };
59 awaiting = None;
60 match stripped.split_once('=') {
61 Some((k, v)) if !k.is_empty() => {
62 map.insert(k.to_string(), v.to_string());
63 }
64 None if !stripped.is_empty() => {
65 bare_flags.push(stripped.to_string());
66 awaiting = Some(stripped.to_string());
67 }
68 _ => {}
69 }
70 }
71 Self {
72 map,
73 bare_flags,
74 bare_values,
75 }
76 }
77
78 pub fn from_pairs<I, K, V>(pairs: I) -> Self
79 where
80 I: IntoIterator<Item = (K, V)>,
81 K: Into<String>,
82 V: Into<String>,
83 {
84 Self {
85 map: pairs
86 .into_iter()
87 .map(|(k, v)| (k.into(), v.into()))
88 .collect(),
89 bare_flags: Vec::new(),
90 bare_values: HashMap::new(),
91 }
92 }
93
94 pub fn has_bare_flag(&self, key: &str) -> bool {
95 self.bare_flags.iter().any(|flag| flag == key)
96 }
97
98 pub fn bare_flag_value(&self, key: &str) -> Option<&str> {
99 self.bare_values.get(key).map(String::as_str)
100 }
101
102 pub fn flag_value(&self, key: &str) -> Option<&str> {
103 self.bare_flag_value(key).or_else(|| self.get(key))
104 }
105
106 pub fn get(&self, key: &str) -> Option<&str> {
107 self.map.get(key).map(String::as_str)
108 }
109
110 pub fn parse<T: std::str::FromStr>(&self, key: &str) -> Option<T> {
111 self.get(key)?.parse().ok()
112 }
113
114 pub fn get_many<'a>(&'a self, key: &str) -> Vec<&'a str> {
115 match self.get(key) {
116 Some(v) => v.split(',').filter(|s| !s.is_empty()).collect(),
117 None => Vec::new(),
118 }
119 }
120
121 pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
123 self.map.iter().map(|(k, v)| (k.as_str(), v.as_str()))
124 }
125}
126
127#[cfg(target_arch = "wasm32")]
128thread_local! {
129 static QUERY_OVERRIDE: std::cell::RefCell<Option<(String, String)>> =
131 const { std::cell::RefCell::new(None) };
132}
133
134#[cfg(target_arch = "wasm32")]
136pub fn set_query_override(search: String, hash: String) {
137 QUERY_OVERRIDE.with(|q| *q.borrow_mut() = Some((search, hash)));
138}
139
140#[cfg(any(target_arch = "wasm32", test))]
141fn parse_query_into(raw: &str, map: &mut HashMap<String, String>) {
142 let trimmed = raw.trim_start_matches(['?', '#']);
143 if trimmed.is_empty() {
144 return;
145 }
146 for pair in trimmed.split('&') {
147 if let Some((k, v)) = pair.split_once('=') {
148 if !k.is_empty() {
149 let value = v.replace('+', " ");
150 map.insert(k.to_string(), value);
151 }
152 }
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn from_argv_keeps_only_attached_values_and_ignores_positionals() {
162 let args = Args::from_argv(["--seed=42", "sub", "--=x", "--fov=60.5", "--", "--late=1"]);
163 assert_eq!(args.get("seed"), Some("42"));
164 assert_eq!(args.get("fov"), Some("60.5"));
165 assert_eq!(args.get("sub"), None);
166 assert_eq!(args.get("late"), None);
167 assert_eq!(args.get(""), None);
168 }
169
170 #[test]
171 fn argv_pairs_split_on_the_first_equals_so_values_keep_the_rest() {
172 let args = Args::from_argv(["--state=a=b", "--eq=="]);
173 assert_eq!(args.get("state"), Some("a=b"));
174 assert_eq!(args.get("eq"), Some("="));
175
176 assert_eq!(args.get("state=a"), None);
177 assert_eq!(args.get("eq="), None);
178 assert!(!args.has_bare_flag("state=a=b"));
179 }
180
181 #[test]
182 fn a_bare_flag_is_recorded_and_its_value_is_not_absorbed() {
183 let args = Args::from_argv(["--shapes", "5-cell,8-cell", "--seed=42"]);
184 assert_eq!(args.get("shapes"), None);
185 assert!(args.has_bare_flag("shapes"));
186 assert_eq!(args.bare_flag_value("shapes"), Some("5-cell,8-cell"));
187 assert_eq!(args.flag_value("shapes"), Some("5-cell,8-cell"));
188 assert_eq!(
189 Args::from_argv(["--headless=1200"]).flag_value("headless"),
190 Some("1200")
191 );
192 assert_eq!(Args::from_argv(["--other"]).flag_value("headless"), None);
193 assert_eq!(
194 Args::from_argv(["--headless=1200"]).bare_flag_value("headless"),
195 None
196 );
197
198 assert!(!args.has_bare_flag("seed"));
199 assert!(!args.has_bare_flag("5-cell,8-cell"));
200 assert!(!args.has_bare_flag(""));
201 assert!(!Args::from_pairs([("shapes", "5-cell")]).has_bare_flag("shapes"));
202 }
203
204 #[test]
205 fn get_many_splits_on_comma_and_drops_empties() {
206 let args = Args::from_pairs([("shapes", "tesseract,5-cell,,8-cell,")]);
207 assert_eq!(
208 args.get_many("shapes"),
209 vec!["tesseract", "5-cell", "8-cell"]
210 );
211 assert!(args.get_many("missing").is_empty());
212 }
213
214 fn parse_all(fragments: &[&str]) -> Vec<(String, String)> {
215 let mut map = HashMap::new();
216 for fragment in fragments {
217 parse_query_into(fragment, &mut map);
218 }
219 let mut pairs: Vec<(String, String)> = map.into_iter().collect();
220 pairs.sort();
221 pairs
222 }
223
224 fn pairs(expected: &[(&str, &str)]) -> Vec<(String, String)> {
225 expected
226 .iter()
227 .map(|(k, v)| (k.to_string(), v.to_string()))
228 .collect()
229 }
230
231 #[test]
232 fn query_and_fragment_markers_are_stripped() {
233 assert_eq!(parse_all(&["?a=1&b=2"]), pairs(&[("a", "1"), ("b", "2")]));
234 assert_eq!(parse_all(&["#a=1"]), pairs(&[("a", "1")]));
235 }
236
237 #[test]
238 fn query_segments_without_a_nonempty_key_and_separator_are_dropped() {
239 assert_eq!(parse_all(&["?flag"]), pairs(&[]));
240 assert_eq!(parse_all(&["?=1"]), pairs(&[]));
241 assert_eq!(parse_all(&["?"]), pairs(&[]));
242 assert_eq!(parse_all(&[""]), pairs(&[]));
243
244 assert_eq!(parse_all(&["?flag&=1&a=2"]), pairs(&[("a", "2")]));
245 }
246
247 #[test]
248 fn query_values_decode_plus_as_space_and_keep_empty_values() {
249 assert_eq!(
250 parse_all(&["?title=hello+world&plus=a+b+c&empty="]),
251 pairs(&[("empty", ""), ("plus", "a b c"), ("title", "hello world")])
252 );
253 }
254
255 #[test]
256 fn query_repeated_keys_resolve_last_write_wins_within_and_across_fragments() {
257 assert_eq!(parse_all(&["?a=1&a=2&a=3"]), pairs(&[("a", "3")]));
258
259 assert_eq!(
260 parse_all(&["?a=search&b=only-search", "#a=hash"]),
261 pairs(&[("a", "hash"), ("b", "only-search")])
262 );
263 }
264
265 #[test]
266 fn query_pairs_split_on_the_first_equals_so_values_keep_the_rest() {
267 assert_eq!(parse_all(&["?state=a=b"]), pairs(&[("state", "a=b")]));
268 assert_eq!(parse_all(&["?eq=="]), pairs(&[("eq", "=")]));
269
270 assert_eq!(
271 parse_all(&["?a=x=y&b=2"]),
272 pairs(&[("a", "x=y"), ("b", "2")])
273 );
274 }
275}