mockforge_bench/
target_parser.rs

1//! Target file parsing for multi-target bench testing
2//!
3//! Supports two formats:
4//! 1. Simple text file: one target URL/IP/hostname per line
5//! 2. JSON format: array of target objects with optional per-target configuration
6
7use crate::error::{BenchError, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::Path;
11
12/// Configuration for a single target
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct TargetConfig {
15    /// Target URL, IP address, or hostname
16    pub url: String,
17    /// Optional authentication header value (e.g., "Bearer token123")
18    pub auth: Option<String>,
19    /// Optional custom headers for this target
20    pub headers: Option<HashMap<String, String>>,
21}
22
23impl TargetConfig {
24    /// Create a new TargetConfig from a URL string
25    pub fn from_url(url: String) -> Self {
26        Self {
27            url,
28            auth: None,
29            headers: None,
30        }
31    }
32
33    /// Normalize the URL to ensure it has a protocol
34    pub fn normalize_url(&mut self) {
35        // If URL doesn't start with http:// or https://, assume http://
36        if !self.url.starts_with("http://") && !self.url.starts_with("https://") {
37            // Check if it looks like it has a port (contains colon but not after http/https)
38            if self.url.contains(':') && !self.url.starts_with("http") {
39                // It's likely an IP:port or hostname:port
40                self.url = format!("http://{}", self.url);
41            } else {
42                // It's likely just a hostname
43                self.url = format!("http://{}", self.url);
44            }
45        }
46    }
47}
48
49/// JSON format for target file (array of targets)
50#[derive(Debug, Deserialize)]
51struct JsonTargetFile {
52    #[serde(rename = "targets")]
53    targets: Option<Vec<JsonTarget>>,
54}
55
56/// Individual target in JSON format
57#[derive(Debug, Deserialize)]
58#[serde(untagged)]
59enum JsonTarget {
60    /// Simple string format: just the URL
61    Simple(String),
62    /// Object format: URL with optional config
63    Object {
64        url: String,
65        auth: Option<String>,
66        headers: Option<HashMap<String, String>>,
67    },
68}
69
70/// Parse a targets file and return a vector of TargetConfig
71///
72/// Automatically detects the format based on file extension and content:
73/// - `.json` files are parsed as JSON
74/// - Other files are parsed as simple text (one target per line)
75pub fn parse_targets_file(path: &Path) -> Result<Vec<TargetConfig>> {
76    // Read file content
77    let content = std::fs::read_to_string(path)
78        .map_err(|e| BenchError::Other(format!("Failed to read targets file: {}", e)))?;
79
80    // Detect format based on extension and content
81    let is_json = path
82        .extension()
83        .and_then(|ext| ext.to_str())
84        .map(|ext| ext.eq_ignore_ascii_case("json"))
85        .unwrap_or(false)
86        || content.trim_start().starts_with('[')
87        || content.trim_start().starts_with('{');
88
89    if is_json {
90        parse_json_targets(&content)
91    } else {
92        parse_text_targets(&content)
93    }
94}
95
96/// Parse targets from JSON format
97fn parse_json_targets(content: &str) -> Result<Vec<TargetConfig>> {
98    // Try parsing as array of targets directly
99    let json_value: serde_json::Value = serde_json::from_str(content)
100        .map_err(|e| BenchError::Other(format!("Failed to parse JSON: {}", e)))?;
101
102    let targets = match json_value {
103        serde_json::Value::Array(arr) => {
104            // Direct array format: [{"url": "...", ...}, ...]
105            arr.into_iter()
106                .map(|item| {
107                    if let Ok(target) = serde_json::from_value::<JsonTarget>(item) {
108                        Ok(match target {
109                            JsonTarget::Simple(url) => TargetConfig::from_url(url),
110                            JsonTarget::Object { url, auth, headers } => {
111                                TargetConfig { url, auth, headers }
112                            }
113                        })
114                    } else {
115                        Err(BenchError::Other("Invalid target format in JSON array".to_string()))
116                    }
117                })
118                .collect::<Result<Vec<_>>>()?
119        }
120        serde_json::Value::Object(obj) => {
121            // Object with "targets" key: {"targets": [...]}
122            if let Some(targets_val) = obj.get("targets") {
123                if let Some(arr) = targets_val.as_array() {
124                    arr.into_iter()
125                        .map(|item| {
126                            if let Ok(target) = serde_json::from_value::<JsonTarget>(item.clone()) {
127                                Ok(match target {
128                                    JsonTarget::Simple(url) => TargetConfig::from_url(url),
129                                    JsonTarget::Object { url, auth, headers } => {
130                                        TargetConfig { url, auth, headers }
131                                    }
132                                })
133                            } else {
134                                Err(BenchError::Other("Invalid target format in JSON".to_string()))
135                            }
136                        })
137                        .collect::<Result<Vec<_>>>()?
138                } else {
139                    return Err(BenchError::Other("Expected 'targets' to be an array".to_string()));
140                }
141            } else {
142                return Err(BenchError::Other(
143                    "JSON object must contain 'targets' array".to_string(),
144                ));
145            }
146        }
147        _ => {
148            return Err(BenchError::Other(
149                "JSON must be an array or object with 'targets' key".to_string(),
150            ));
151        }
152    };
153
154    if targets.is_empty() {
155        return Err(BenchError::Other("No targets found in JSON file".to_string()));
156    }
157
158    // Normalize URLs
159    let mut normalized_targets = targets;
160    for target in &mut normalized_targets {
161        target.normalize_url();
162    }
163
164    Ok(normalized_targets)
165}
166
167/// Parse targets from simple text format (one per line)
168fn parse_text_targets(content: &str) -> Result<Vec<TargetConfig>> {
169    let mut targets = Vec::new();
170
171    for line in content.lines() {
172        let line = line.trim();
173
174        // Skip empty lines and comments (lines starting with #)
175        if line.is_empty() || line.starts_with('#') {
176            continue;
177        }
178
179        // Validate that the line looks like a URL/IP/hostname
180        if line.is_empty() {
181            continue;
182        }
183
184        let mut target = TargetConfig::from_url(line.to_string());
185        target.normalize_url();
186        targets.push(target);
187    }
188
189    if targets.is_empty() {
190        return Err(BenchError::Other("No valid targets found in text file".to_string()));
191    }
192
193    Ok(targets)
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use std::io::Write;
200    use tempfile::NamedTempFile;
201
202    #[test]
203    fn test_parse_text_targets() {
204        let content = r#"
205https://api1.example.com
206https://api2.example.com
207192.168.1.100:8080
208api3.example.com
209# This is a comment
210        "#;
211
212        let targets = parse_text_targets(content).unwrap();
213        assert_eq!(targets.len(), 4);
214        assert_eq!(targets[0].url, "https://api1.example.com");
215        assert_eq!(targets[1].url, "https://api2.example.com");
216        assert_eq!(targets[2].url, "http://192.168.1.100:8080");
217        assert_eq!(targets[3].url, "http://api3.example.com");
218    }
219
220    #[test]
221    fn test_parse_json_targets_array() {
222        let content = r#"
223[
224  {"url": "https://api1.example.com", "auth": "Bearer token1"},
225  {"url": "https://api2.example.com"},
226  "https://api3.example.com"
227]
228        "#;
229
230        let targets = parse_json_targets(content).unwrap();
231        assert_eq!(targets.len(), 3);
232        assert_eq!(targets[0].url, "https://api1.example.com");
233        assert_eq!(targets[0].auth, Some("Bearer token1".to_string()));
234        assert_eq!(targets[1].url, "https://api2.example.com");
235        assert_eq!(targets[2].url, "https://api3.example.com");
236    }
237
238    #[test]
239    fn test_parse_json_targets_object() {
240        let content = r#"
241{
242  "targets": [
243    {"url": "https://api1.example.com"},
244    {"url": "https://api2.example.com", "auth": "Bearer token2"}
245  ]
246}
247        "#;
248
249        let targets = parse_json_targets(content).unwrap();
250        assert_eq!(targets.len(), 2);
251        assert_eq!(targets[0].url, "https://api1.example.com");
252        assert_eq!(targets[1].url, "https://api2.example.com");
253        assert_eq!(targets[1].auth, Some("Bearer token2".to_string()));
254    }
255
256    #[test]
257    fn test_normalize_url() {
258        let mut target = TargetConfig::from_url("api.example.com".to_string());
259        target.normalize_url();
260        assert_eq!(target.url, "http://api.example.com");
261
262        let mut target2 = TargetConfig::from_url("192.168.1.1:8080".to_string());
263        target2.normalize_url();
264        assert_eq!(target2.url, "http://192.168.1.1:8080");
265
266        let mut target3 = TargetConfig::from_url("https://api.example.com".to_string());
267        target3.normalize_url();
268        assert_eq!(target3.url, "https://api.example.com");
269    }
270
271    #[test]
272    fn test_parse_targets_file_text() {
273        let mut file = NamedTempFile::new().unwrap();
274        writeln!(file, "https://api1.example.com").unwrap();
275        writeln!(file, "https://api2.example.com").unwrap();
276        writeln!(file, "# comment").unwrap();
277        writeln!(file, "api3.example.com").unwrap();
278
279        let targets = parse_targets_file(file.path()).unwrap();
280        assert_eq!(targets.len(), 3);
281    }
282
283    #[test]
284    fn test_parse_targets_file_json() {
285        let mut file = NamedTempFile::new().unwrap();
286        file.write_all(
287            r#"[
288  {"url": "https://api1.example.com"},
289  {"url": "https://api2.example.com"}
290]"#
291            .as_bytes(),
292        )
293        .unwrap();
294
295        let targets = parse_targets_file(file.path()).unwrap();
296        assert_eq!(targets.len(), 2);
297    }
298
299    #[test]
300    fn test_parse_targets_file_empty() {
301        let file = NamedTempFile::new().unwrap();
302        std::fs::write(file.path(), "").unwrap();
303
304        let result = parse_targets_file(file.path());
305        assert!(result.is_err());
306    }
307
308    #[test]
309    fn test_parse_targets_file_only_comments() {
310        let mut file = NamedTempFile::new().unwrap();
311        writeln!(file, "# comment 1").unwrap();
312        writeln!(file, "# comment 2").unwrap();
313
314        let result = parse_targets_file(file.path());
315        assert!(result.is_err());
316    }
317
318    #[test]
319    fn test_parse_json_targets_with_headers() {
320        let content = r#"
321[
322  {
323    "url": "https://api1.example.com",
324    "auth": "Bearer token1",
325    "headers": {
326      "X-Custom": "value1",
327      "X-Another": "value2"
328    }
329  }
330]
331        "#;
332
333        let targets = parse_json_targets(content).unwrap();
334        assert_eq!(targets.len(), 1);
335        assert_eq!(targets[0].url, "https://api1.example.com");
336        assert_eq!(targets[0].auth, Some("Bearer token1".to_string()));
337        assert_eq!(
338            targets[0].headers.as_ref().unwrap().get("X-Custom"),
339            Some(&"value1".to_string())
340        );
341        assert_eq!(
342            targets[0].headers.as_ref().unwrap().get("X-Another"),
343            Some(&"value2".to_string())
344        );
345    }
346
347    #[test]
348    fn test_parse_json_targets_invalid_format() {
349        let content = r#"
350{
351  "invalid": "format"
352}
353        "#;
354
355        let result = parse_json_targets(content);
356        assert!(result.is_err());
357    }
358}