Skip to main content

tiny_proxy/config/
parser.rs

1use crate::config::{Config, Directive, SiteConfig};
2use crate::error::ProxyError;
3use std::collections::HashMap;
4use std::str::FromStr;
5
6#[derive(Debug)]
7struct PendingBlock {
8    directive_type: String,
9    args: Vec<String>,
10    // Timeout settings for reverse_proxy blocks (in seconds)
11    connect_timeout: Option<u64>,
12    read_timeout: Option<u64>,
13}
14
15/// Parse a human-readable duration string into seconds.
16///
17/// Supported formats:
18/// - Plain number: `"30"` → 30 seconds
19/// - Seconds: `"30s"` → 30
20/// - Minutes: `"5m"` → 300
21/// - Hours: `"2h"` → 7200
22/// - Days: `"1d"` → 86400
23fn parse_duration(s: &str) -> Result<u64, ProxyError> {
24    let s = s.trim();
25    if s.is_empty() {
26        return Err(ProxyError::Parse("Empty duration value".to_string()));
27    }
28
29    // Try plain number first (seconds)
30    if let Ok(secs) = s.parse::<u64>() {
31        return Ok(secs);
32    }
33
34    // Parse with suffix
35    let (num_part, multiplier) = if let Some(n) = s.strip_suffix('s') {
36        (n, 1u64)
37    } else if let Some(n) = s.strip_suffix('m') {
38        (n, 60u64)
39    } else if let Some(n) = s.strip_suffix('h') {
40        (n, 3600u64)
41    } else if let Some(n) = s.strip_suffix('d') {
42        (n, 86400u64)
43    } else {
44        return Err(ProxyError::Parse(format!(
45            "Invalid duration '{}'. Use a plain number or Ns/Nm/Nh/Nd",
46            s
47        )));
48    };
49
50    let value: u64 = num_part
51        .parse()
52        .map_err(|_| ProxyError::Parse(format!("Invalid numeric value in duration: '{}'", s)))?;
53
54    Ok(value * multiplier)
55}
56
57impl Config {
58    pub fn from_file(path: &str) -> Result<Self, ProxyError> {
59        let content = std::fs::read_to_string(path)?;
60        content.parse()
61    }
62}
63
64impl FromStr for Config {
65    type Err = ProxyError;
66
67    fn from_str(content: &str) -> Result<Self, Self::Err> {
68        let mut sites = HashMap::new();
69        let mut current_site_address: Option<String> = None;
70
71        let mut directive_stack: Vec<Vec<Directive>> = vec![vec![]];
72        let mut block_stack: Vec<PendingBlock> = vec![];
73
74        for (line_num, raw_line) in content.lines().enumerate() {
75            let line = raw_line.trim();
76            if line.is_empty() || line.starts_with('#') {
77                continue;
78            }
79
80            // 1. Handle opening brace
81            if line.ends_with('{') {
82                let parts: Vec<&str> = line.split_whitespace().collect();
83                if parts.is_empty() {
84                    continue;
85                }
86
87                // Top-level site block
88                if directive_stack.len() == 1 && current_site_address.is_none() {
89                    current_site_address = Some(parts[0].to_string());
90                    continue;
91                }
92
93                // Nested block (handle_path, method, reverse_proxy, etc.)
94                let directive_type = parts[0].to_string();
95                // Filter out trailing "{" from args
96                let args = parts[1..]
97                    .iter()
98                    .filter(|s| **s != "{")
99                    .map(|s| s.to_string())
100                    .collect();
101
102                block_stack.push(PendingBlock {
103                    directive_type,
104                    args,
105                    connect_timeout: None,
106                    read_timeout: None,
107                });
108                directive_stack.push(vec![]);
109                continue;
110            }
111
112            // 2. Handle closing brace
113            if line == "}" {
114                if directive_stack.len() > 1 {
115                    let finished_directives = directive_stack.pop().unwrap();
116                    let block_info = block_stack.pop().unwrap();
117
118                    let completed_directive = match block_info.directive_type.as_str() {
119                        "handle_path" => {
120                            let pattern = block_info.args.first().cloned().unwrap_or_default();
121                            Directive::HandlePath {
122                                pattern,
123                                directives: finished_directives,
124                            }
125                        }
126                        "method" => Directive::Method {
127                            methods: block_info.args,
128                            directives: finished_directives,
129                        },
130                        "reverse_proxy" => {
131                            let to = block_info.args.first().cloned().unwrap_or_default();
132                            Directive::ReverseProxy {
133                                to,
134                                connect_timeout: block_info.connect_timeout,
135                                read_timeout: block_info.read_timeout,
136                            }
137                        }
138                        _ => {
139                            return Err(ProxyError::Parse(format!(
140                                "Unknown block type: {}",
141                                block_info.directive_type
142                            )))
143                        }
144                    };
145
146                    directive_stack
147                        .last_mut()
148                        .unwrap()
149                        .push(completed_directive);
150                } else {
151                    // Site block closed
152                    if let Some(address) = current_site_address.take() {
153                        let site_directives = directive_stack.pop().unwrap();
154                        sites.insert(
155                            address.clone(),
156                            SiteConfig {
157                                address,
158                                directives: site_directives,
159                            },
160                        );
161                        directive_stack.push(vec![]);
162                    }
163                }
164                continue;
165            }
166
167            // 3. Handle simple directives (single line)
168            let parts: Vec<&str> = line.split_whitespace().collect();
169            if parts.is_empty() {
170                continue;
171            }
172
173            let directive_name = parts[0];
174            let args = parts[1..].to_vec();
175
176            // Special handling: timeout settings inside a reverse_proxy block
177            if let Some(block) = block_stack.last_mut() {
178                if block.directive_type == "reverse_proxy" {
179                    match directive_name {
180                        "connect_timeout" => {
181                            let raw = args.first().cloned().ok_or_else(|| {
182                                ProxyError::Parse("Missing value for connect_timeout".to_string())
183                            })?;
184                            block.connect_timeout = Some(parse_duration(raw).map_err(|e| {
185                                ProxyError::Parse(format!(
186                                    "Invalid connect_timeout on line {}: {}",
187                                    line_num + 1,
188                                    e
189                                ))
190                            })?);
191                            continue;
192                        }
193                        "read_timeout" => {
194                            let raw = args.first().cloned().ok_or_else(|| {
195                                ProxyError::Parse("Missing value for read_timeout".to_string())
196                            })?;
197                            block.read_timeout = Some(parse_duration(raw).map_err(|e| {
198                                ProxyError::Parse(format!(
199                                    "Invalid read_timeout on line {}: {}",
200                                    line_num + 1,
201                                    e
202                                ))
203                            })?);
204                            continue;
205                        }
206                        _ => {
207                            return Err(ProxyError::Parse(format!(
208                                "Unexpected directive '{}' inside reverse_proxy block on line {}. Only connect_timeout and read_timeout are allowed.",
209                                directive_name, line_num + 1
210                            )));
211                        }
212                    }
213                }
214            }
215
216            // Regular directive parsing
217            let directive = match directive_name {
218                "reverse_proxy" => {
219                    let to = args.first().cloned().ok_or_else(|| {
220                        ProxyError::Parse("Missing backend URL for reverse_proxy".to_string())
221                    })?;
222                    Directive::ReverseProxy {
223                        to: to.to_string(),
224                        connect_timeout: None,
225                        read_timeout: None,
226                    }
227                }
228                "uri_replace" => {
229                    let find = args.first().cloned().ok_or_else(|| {
230                        ProxyError::Parse("Missing 'find' arg for uri_replace".to_string())
231                    })?;
232                    let replace = args.get(1).cloned().ok_or_else(|| {
233                        ProxyError::Parse("Missing 'replace' arg for uri_replace".to_string())
234                    })?;
235                    Directive::UriReplace {
236                        find: find.to_string(),
237                        replace: replace.to_string(),
238                    }
239                }
240                "header" => {
241                    let raw_name = args.first().cloned().ok_or_else(|| {
242                        ProxyError::Parse("Missing 'name' arg for header".to_string())
243                    })?;
244                    if let Some(name) = raw_name.strip_prefix('-') {
245                        if name.is_empty() {
246                            return Err(ProxyError::Parse(
247                                "Missing header name after '-' for header removal".to_string(),
248                            ));
249                        }
250                        Directive::Header {
251                            name: name.to_string(),
252                            value: None,
253                        }
254                    } else {
255                        let value = args.get(1).cloned().ok_or_else(|| {
256                            ProxyError::Parse("Missing 'value' arg for header".to_string())
257                        })?;
258                        Directive::Header {
259                            name: raw_name.to_string(),
260                            value: Some(value.to_string()),
261                        }
262                    }
263                }
264                "respond" => {
265                    let status = args.first().and_then(|s| s.parse().ok()).ok_or_else(|| {
266                        ProxyError::Parse("Invalid status for respond".to_string())
267                    })?;
268                    let body = args.get(1).cloned().unwrap_or_default();
269                    Directive::Respond {
270                        status,
271                        body: body.to_string(),
272                    }
273                }
274                "strip_prefix" => {
275                    let prefix = args.first().cloned().ok_or_else(|| {
276                        ProxyError::Parse("Missing 'prefix' arg for strip_prefix".to_string())
277                    })?;
278                    Directive::StripPrefix {
279                        prefix: prefix.to_string(),
280                    }
281                }
282                "redirect" => {
283                    let (status, url) = if args.len() >= 2 {
284                        let status: u16 = args[0].parse().map_err(|_| {
285                            ProxyError::Parse(format!(
286                                "Invalid status code for redirect: {}",
287                                args[0]
288                            ))
289                        })?;
290                        let url = args[1..].join(" ");
291                        (status, url)
292                    } else {
293                        let url = args.first().cloned().ok_or_else(|| {
294                            ProxyError::Parse("Missing 'url' arg for redirect".to_string())
295                        })?;
296                        (301u16, url.to_string())
297                    };
298                    Directive::Redirect {
299                        status,
300                        url: url.to_string(),
301                    }
302                }
303                _ => {
304                    return Err(ProxyError::Parse(format!(
305                        "Unknown directive '{}' on line {}",
306                        directive_name,
307                        line_num + 1
308                    )))
309                }
310            };
311
312            directive_stack.last_mut().unwrap().push(directive);
313        }
314
315        Ok(Config { sites })
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_parse_duration_seconds() {
325        assert_eq!(parse_duration("30").unwrap(), 30);
326        assert_eq!(parse_duration("30s").unwrap(), 30);
327    }
328
329    #[test]
330    fn test_parse_duration_minutes() {
331        assert_eq!(parse_duration("5m").unwrap(), 300);
332    }
333
334    #[test]
335    fn test_parse_duration_hours() {
336        assert_eq!(parse_duration("2h").unwrap(), 7200);
337    }
338
339    #[test]
340    fn test_parse_duration_days() {
341        assert_eq!(parse_duration("1d").unwrap(), 86400);
342    }
343
344    #[test]
345    fn test_parse_duration_invalid() {
346        assert!(parse_duration("").is_err());
347        assert!(parse_duration("abc").is_err());
348        assert!(parse_duration("10x").is_err());
349    }
350
351    #[test]
352    fn test_parse_reverse_proxy_simple() {
353        let config = "localhost:8080 {\n    reverse_proxy http://backend:9001\n}";
354        let result: Config = config.parse().unwrap();
355        let site = result.sites.get("localhost:8080").unwrap();
356
357        assert_eq!(site.directives.len(), 1);
358        match &site.directives[0] {
359            Directive::ReverseProxy {
360                to,
361                connect_timeout,
362                read_timeout,
363            } => {
364                assert_eq!(to, "http://backend:9001");
365                assert_eq!(*connect_timeout, None);
366                assert_eq!(*read_timeout, None);
367            }
368            _ => panic!("Expected ReverseProxy directive"),
369        }
370    }
371
372    #[test]
373    fn test_parse_reverse_proxy_with_timeouts() {
374        let config = r#"localhost:8080 {
375    reverse_proxy http://backend:9001 {
376        connect_timeout 10s
377        read_timeout 5m
378    }
379}"#;
380        let result: Config = config.parse().unwrap();
381        let site = result.sites.get("localhost:8080").unwrap();
382
383        assert_eq!(site.directives.len(), 1);
384        match &site.directives[0] {
385            Directive::ReverseProxy {
386                to,
387                connect_timeout,
388                read_timeout,
389            } => {
390                assert_eq!(to, "http://backend:9001");
391                assert_eq!(*connect_timeout, Some(10));
392                assert_eq!(*read_timeout, Some(300));
393            }
394            _ => panic!("Expected ReverseProxy directive"),
395        }
396    }
397
398    #[test]
399    fn test_parse_reverse_proxy_with_connect_timeout_only() {
400        let config = r#"localhost:8080 {
401    reverse_proxy http://backend:9001 {
402        connect_timeout 5s
403    }
404}"#;
405        let result: Config = config.parse().unwrap();
406        let site = result.sites.get("localhost:8080").unwrap();
407
408        match &site.directives[0] {
409            Directive::ReverseProxy {
410                connect_timeout,
411                read_timeout,
412                ..
413            } => {
414                assert_eq!(*connect_timeout, Some(5));
415                assert_eq!(*read_timeout, None);
416            }
417            _ => panic!("Expected ReverseProxy directive"),
418        }
419    }
420
421    #[test]
422    fn test_parse_reverse_proxy_block_rejects_unknown_directive() {
423        let config = r#"localhost:8080 {
424    reverse_proxy http://backend:9001 {
425        unknown_setting 42
426    }
427}"#;
428        let result: Result<Config, _> = config.parse();
429        assert!(result.is_err());
430        let err_msg = format!("{}", result.unwrap_err());
431        assert!(err_msg.contains("Unexpected directive"), "{}", err_msg);
432    }
433}