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