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 connect_timeout: Option<u64>,
12 read_timeout: Option<u64>,
13}
14
15fn 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 if let Ok(secs) = s.parse::<u64>() {
31 return Ok(secs);
32 }
33
34 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 if line.ends_with('{') {
82 let parts: Vec<&str> = line.split_whitespace().collect();
83 if parts.is_empty() {
84 continue;
85 }
86
87 if directive_stack.len() == 1 && current_site_address.is_none() {
89 current_site_address = Some(parts[0].to_string());
90 continue;
91 }
92
93 let directive_type = parts[0].to_string();
95 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 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 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 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 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 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}