Skip to main content

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