Skip to main content

loam_app/
args.rs

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    /// Order is HashMap-arbitrary.
122    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    // Workers have no `window.location`; the init message forwards the query.
130    static QUERY_OVERRIDE: std::cell::RefCell<Option<(String, String)>> =
131        const { std::cell::RefCell::new(None) };
132}
133
134/// Hash wins on collision, matching the main-thread parse order.
135#[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}