Skip to main content

rws_proxy/proxy_config/
parser.rs

1//! Hand-rolled TOML parser that extracts `[[upstream]]`, `[[route]]`,
2//! `[[tcp_proxy]]`, `[[udp_proxy]]`, `[[ws_proxy]]` sections from
3//! `rws.config.toml` into a flat `SectionMap`.
4//!
5//! The output is a `HashMap<String, Vec<(String, String)>>` where each key is
6//! a section path (e.g. `"route[0].action.proxy"`) and each value is the list
7//! of key-value pairs found in that section.
8
9use std::collections::HashMap;
10use crate::entry_point::config_file::toml_str::{unquote, Quotes};
11
12/// Section path → list of (key, value) pairs.
13pub type SectionMap = HashMap<String, Vec<(String, String)>>;
14
15/// Parse a TOML string into a `SectionMap`.
16///
17/// Supports the subset of TOML used in `rws.config.toml`:
18/// - Array-of-tables `[[X]]`
19/// - Standard tables `[X]`
20/// - Key-value pairs (string, bool, integer, inline tables, arrays)
21pub fn parse(toml: &str) -> SectionMap {
22    let mut map: SectionMap = HashMap::new();
23
24    // Outer array table tracking
25    let mut outer_name: Option<String> = None; // e.g. "route"
26    let mut outer_idx: usize = 0;
27    let mut outer_counters: HashMap<String, usize> = HashMap::new(); // "route" → 2
28    let mut inner_counters: HashMap<String, usize> = HashMap::new(); // "route[0].middleware.rewrite.request" → 1
29
30    // Current section path (where key-value pairs are written)
31    let mut current_section: String = String::new();
32
33    for line in logical_lines(toml) {
34
35        if line.is_empty() {
36            continue;
37        }
38
39        if line.starts_with("[[") && line.ends_with("]]") {
40            // Array-of-tables header
41            let name = line[2..line.len() - 2].trim().to_string();
42
43            // Is this a nested array inside the current outer table?
44            if let Some(ref on) = outer_name.clone() {
45                let prefix = format!("{}.", on);
46                if name.starts_with(&prefix) {
47                    // Nested array: "route[N].middleware.rewrite.request"
48                    let base = format!("{}{}", outer_section_base(on, outer_idx), &name[on.len()..]);
49                    let cnt = inner_counters.entry(base.clone()).or_insert(0);
50                    let section_path = format!("{}[{}]", base, cnt);
51                    *cnt += 1;
52                    current_section = section_path.clone();
53                    map.entry(current_section.clone()).or_default();
54                    continue;
55                }
56            }
57
58            // Top-level array-of-tables
59            let idx = outer_counters.entry(name.clone()).or_insert(0);
60            outer_idx = *idx;
61            *idx += 1;
62            outer_name = Some(name.clone());
63            inner_counters.clear();
64            current_section = outer_section_base(&name, outer_idx);
65            map.entry(current_section.clone()).or_default();
66        } else if line.starts_with('[') && line.ends_with(']') {
67            // Standard table header
68            let name = line[1..line.len() - 1].trim().to_string();
69
70            if let Some(ref on) = outer_name.clone() {
71                let prefix = format!("{}.", on);
72                if name.starts_with(&prefix) {
73                    // Sub-table of current outer: e.g. "route.match" inside "route"
74                    current_section = format!(
75                        "{}{}",
76                        outer_section_base(on, outer_idx),
77                        &name[on.len()..]
78                    );
79                    map.entry(current_section.clone()).or_default();
80                    continue;
81                }
82            }
83
84            // Standalone table (reset outer tracking)
85            outer_name = None;
86            inner_counters.clear();
87            current_section = name.clone();
88            map.entry(current_section.clone()).or_default();
89        } else if let Some(eq) = line.find('=') {
90            // Key = value pair
91            let key = line[..eq].trim().to_string();
92            let raw_val = line[eq + 1..].trim().to_string();
93
94            if raw_val.starts_with('{') {
95                // Inline table: `rate_limit = { max_requests = 5 }` is the
96                // sub-table `[<section>.rate_limit]`, exactly as in TOML. (It
97                // used to become a `rate_limit.max_requests` key in the parent
98                // section, which nothing reads — inline auth, rate limits and
99                // IP filters were silently ignored.)
100                let inner = &raw_val[1..raw_val.rfind('}').unwrap_or(raw_val.len())];
101                let sub_section = if current_section.is_empty() { key.clone() } else { format!("{}.{}", current_section, key) };
102                let entries = map.entry(sub_section).or_default();
103                for part in split_inline_table(inner) {
104                    if let Some(ieq) = part.find('=') {
105                        let ik = part[..ieq].trim().to_string();
106                        let iv = parse_value(part[ieq + 1..].trim());
107                        entries.push((ik, iv));
108                    }
109                }
110            } else {
111                let value = parse_value(&raw_val);
112                map.entry(current_section.clone())
113                    .or_default()
114                    .push((key, value));
115            }
116        }
117    }
118
119    map
120}
121
122/// Build the base path for an outer array entry, e.g. `"route[0]"`.
123fn outer_section_base(name: &str, idx: usize) -> String {
124    format!("{}[{}]", name, idx)
125}
126
127/// Strip an inline TOML comment (first `#` not inside a quoted string).
128/// The lines of `toml` with comments removed and trimmed, a multi-line
129/// array (`key = [` … `]`) joined into one line — the rest of the parser
130/// reads one `key = value` per line. A bracket inside a string doesn't
131/// count; an array left open at the end is returned as it is.
132#[doc(hidden)]
133pub fn logical_lines(toml: &str) -> Vec<String> {
134    let mut out = Vec::new();
135    let mut pending: Option<(String, i64)> = None;
136    for raw in toml.lines() {
137        let line = strip_comment(raw).trim();
138        if let Some((joined, depth)) = pending.as_mut() {
139            if !line.is_empty() {
140                joined.push(' ');
141                joined.push_str(line);
142                *depth += bracket_depth(line);
143            }
144            if *depth <= 0 {
145                out.push(pending.take().expect("pending").0);
146            }
147            continue;
148        }
149        let opens_array = line.split_once('=').is_some_and(|(key, value)| !key.trim().starts_with('[') && value.trim_start().starts_with('['));
150        let depth = if opens_array { bracket_depth(line) } else { 0 };
151        if depth > 0 {
152            pending = Some((line.to_string(), depth));
153        } else {
154            out.push(line.to_string());
155        }
156    }
157    if let Some((joined, _)) = pending {
158        out.push(joined);
159    }
160    out
161}
162
163/// `[` minus `]` outside quoted strings.
164fn bracket_depth(line: &str) -> i64 {
165    let mut depth = 0;
166    let mut quotes = Quotes::default();
167    for c in line.chars() {
168        if quotes.step(c) {
169            continue;
170        }
171        match c {
172            '[' => depth += 1,
173            ']' => depth -= 1,
174            _ => {}
175        }
176    }
177    depth
178}
179
180#[doc(hidden)]
181pub fn strip_comment(line: &str) -> &str {
182    let mut quotes = Quotes::default();
183    for (i, c) in line.char_indices() {
184        if !quotes.step(c) && c == '#' {
185            return &line[..i];
186        }
187    }
188    line
189}
190
191/// Parse a single TOML value into its string representation.
192///
193/// - Quoted strings → bare string (quotes stripped, a basic string's escapes
194///   decoded: [`unquote`])
195/// - Arrays `["a","b"]` → `"a,b"` (joined with comma)
196/// - Booleans / numbers → as-is
197#[doc(hidden)]
198pub fn parse_value(raw: &str) -> String {
199    let raw = raw.trim();
200    if raw.starts_with('[') && raw.ends_with(']') {
201        // Array of scalars
202        let inner = &raw[1..raw.len() - 1];
203        let items: Vec<String> = split_array_items(inner)
204            .into_iter()
205            .map(|s| unquote(s.trim()))
206            .collect();
207        return items.join(",");
208    }
209    unquote(raw)
210}
211
212/// Split a TOML array body (without brackets) into individual items.
213/// Handles quoted strings that may contain commas.
214fn split_array_items(inner: &str) -> Vec<&str> {
215    let mut items = Vec::new();
216    let mut depth = 0i32;
217    let mut quotes = Quotes::default();
218    let mut start = 0;
219    for (i, c) in inner.char_indices() {
220        if quotes.step(c) {
221            continue;
222        }
223        match c {
224            '[' | '{' => depth += 1,
225            ']' | '}' => depth -= 1,
226            ',' if depth == 0 => {
227                items.push(inner[start..i].trim());
228                start = i + 1;
229            }
230            _ => {}
231        }
232    }
233    let last = inner[start..].trim();
234    if !last.is_empty() {
235        items.push(last);
236    }
237    items
238}
239
240/// Split inline table body `"key = val, key2 = val2"` into parts at top-level commas.
241fn split_inline_table(inner: &str) -> Vec<String> {
242    let mut parts = Vec::new();
243    let mut depth = 0i32;
244    let mut quotes = Quotes::default();
245    let mut start = 0;
246    for (i, c) in inner.char_indices() {
247        if quotes.step(c) {
248            continue;
249        }
250        match c {
251            '[' | '{' => depth += 1,
252            ']' | '}' => depth -= 1,
253            ',' if depth == 0 => {
254                let part = inner[start..i].trim();
255                if !part.is_empty() {
256                    parts.push(part.to_string());
257                }
258                start = i + 1;
259            }
260            _ => {}
261        }
262    }
263    let part = inner[start..].trim();
264    if !part.is_empty() {
265        parts.push(part.to_string());
266    }
267    parts
268}
269
270// ── Helper accessors ───────────────────────────────────────────────────────────
271
272/// Get the first value for `key` in `section`, or `None`.
273#[doc(hidden)]
274pub fn get(map: &SectionMap, section: &str, key: &str) -> Option<String> {
275    map.get(section)?.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
276}
277
278/// Get the first value for `key` in `section`, or empty string.
279#[doc(hidden)]
280pub fn get_str(map: &SectionMap, section: &str, key: &str) -> String {
281    get(map, section, key).unwrap_or_default()
282}
283
284/// Get the first value for `key` in `section` as `u64`, or `default`.
285#[doc(hidden)]
286pub fn get_u64(map: &SectionMap, section: &str, key: &str, default: u64) -> u64 {
287    get(map, section, key)
288        .and_then(|v| v.parse().ok())
289        .unwrap_or(default)
290}
291
292/// Get the first value for `key` in `section` as `u32`, or `default`.
293#[doc(hidden)]
294pub fn get_u32(map: &SectionMap, section: &str, key: &str, default: u32) -> u32 {
295    get(map, section, key)
296        .and_then(|v| v.parse().ok())
297        .unwrap_or(default)
298}
299
300/// Get the first value for `key` in `section` as a list (split on `,`).
301#[doc(hidden)]
302pub fn get_array(map: &SectionMap, section: &str, key: &str) -> Vec<String> {
303    match get(map, section, key) {
304        Some(v) if !v.is_empty() => v.split(',').map(|s| s.trim().to_string()).collect(),
305        _ => vec![],
306    }
307}
308
309/// Returns `true` if the section key exists in the map.
310#[doc(hidden)]
311pub fn section_exists(map: &SectionMap, section: &str) -> bool {
312    map.contains_key(section)
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    const SAMPLE: &str = r#"
320[server]
321ip = "0.0.0.0"
322port = 8080
323
324[[upstream]]
325name = "api"
326backends = ["backend1:8080", "backend2:8080"]
327strategy = "round_robin"
328
329[upstream.health_check]
330path = "/health"
331interval_secs = 10
332timeout_ms = 2000
333healthy_threshold = 2
334unhealthy_threshold = 3
335
336[[route]]
337name = "api-route"
338
339[route.match]
340path = "/api/*"
341method = "GET"
342
343[route.action]
344type = "proxy"
345
346[route.action.proxy]
347upstream = "api"
348connect_timeout_ms = 5000
349read_timeout_ms = 30000
350
351[route.middleware]
352
353[route.middleware.rate_limit]
354max_requests = 100
355window_secs = 60
356
357[[route.middleware.rewrite.request]]
358type = "header_set"
359name = "X-Env"
360value = "production"
361
362[[tcp_proxy]]
363name = "db-proxy"
364listen = "0.0.0.0:5432"
365backends = ["db1:5432", "db2:5432"]
366connect_timeout_ms = 3000
367"#;
368
369    #[test]
370    fn parse_server_section() {
371        let map = parse(SAMPLE);
372        assert_eq!(get(&map, "server", "ip").as_deref(), Some("0.0.0.0"));
373        assert_eq!(get_u64(&map, "server", "port", 0), 8080);
374    }
375
376    #[test]
377    fn parse_upstream() {
378        let map = parse(SAMPLE);
379        assert!(section_exists(&map, "upstream[0]"));
380        assert_eq!(get_str(&map, "upstream[0]", "name"), "api");
381        assert_eq!(
382            get_array(&map, "upstream[0]", "backends"),
383            vec!["backend1:8080", "backend2:8080"]
384        );
385    }
386
387    #[test]
388    fn parse_upstream_health_check() {
389        let map = parse(SAMPLE);
390        assert!(section_exists(&map, "upstream[0].health_check"));
391        assert_eq!(get_str(&map, "upstream[0].health_check", "path"), "/health");
392        assert_eq!(get_u64(&map, "upstream[0].health_check", "interval_secs", 0), 10);
393    }
394
395    #[test]
396    fn parse_route() {
397        let map = parse(SAMPLE);
398        assert!(section_exists(&map, "route[0]"));
399        assert_eq!(get_str(&map, "route[0]", "name"), "api-route");
400        assert_eq!(get_str(&map, "route[0].match", "path"), "/api/*");
401        assert_eq!(get_str(&map, "route[0].match", "method"), "GET");
402        assert_eq!(get_str(&map, "route[0].action.proxy", "upstream"), "api");
403        assert_eq!(get_u64(&map, "route[0].action.proxy", "connect_timeout_ms", 0), 5000);
404    }
405
406    #[test]
407    fn parse_route_middleware() {
408        let map = parse(SAMPLE);
409        assert_eq!(get_u32(&map, "route[0].middleware.rate_limit", "max_requests", 0), 100);
410        assert_eq!(get_u64(&map, "route[0].middleware.rate_limit", "window_secs", 0), 60);
411    }
412
413    #[test]
414    fn parse_nested_rewrite_array() {
415        let map = parse(SAMPLE);
416        assert!(section_exists(&map, "route[0].middleware.rewrite.request[0]"));
417        assert_eq!(get_str(&map, "route[0].middleware.rewrite.request[0]", "type"), "header_set");
418        assert_eq!(get_str(&map, "route[0].middleware.rewrite.request[0]", "name"), "X-Env");
419    }
420
421    #[test]
422    fn parse_tcp_proxy() {
423        let map = parse(SAMPLE);
424        assert!(section_exists(&map, "tcp_proxy[0]"));
425        assert_eq!(get_str(&map, "tcp_proxy[0]", "name"), "db-proxy");
426        assert_eq!(get_str(&map, "tcp_proxy[0]", "listen"), "0.0.0.0:5432");
427        assert_eq!(
428            get_array(&map, "tcp_proxy[0]", "backends"),
429            vec!["db1:5432", "db2:5432"]
430        );
431    }
432
433    #[test]
434    fn multi_line_arrays_are_joined() {
435        let toml = "[authz]\ntuples = [\n  \"doc:1#owner@user:a\",  # first\n  \"doc:2#owner@user:[b]\",\n]\nstore = \"memory\"\n";
436        let map = parse(toml);
437        assert_eq!(get_array(&map, "authz", "tuples"), ["doc:1#owner@user:a", "doc:2#owner@user:[b]"]);
438        assert_eq!(get_str(&map, "authz", "store"), "memory");
439        assert_eq!(logical_lines("a = [1,\n2]\n[t]\nb = []"), ["a = [1, 2]", "[t]", "b = []"]);
440        assert_eq!(logical_lines("a = [1,\n2"), ["a = [1, 2"], "an unclosed array ends with the file");
441    }
442
443    #[test]
444    fn strip_comments_test() {
445        assert_eq!(strip_comment("key = \"value\" # a comment"), "key = \"value\" ");
446        assert_eq!(strip_comment("# full comment"), "");
447        assert_eq!(strip_comment("url = \"http://example.com#fragment\""), "url = \"http://example.com#fragment\"");
448    }
449
450    #[test]
451    fn parse_value_array() {
452        assert_eq!(parse_value(r#"["a", "b", "c"]"#), "a,b,c");
453    }
454
455    #[test]
456    fn parse_value_string() {
457        assert_eq!(parse_value(r#""hello""#), "hello");
458    }
459}