Skip to main content

mockforge_bench/conformance/
har_to_custom.rs

1//! HAR-to-YAML generator for custom conformance checks
2//!
3//! Converts HTTP Archive (HAR) files into YAML configuration files that match
4//! the `--conformance-custom` format, enabling users to generate conformance
5//! checks from recorded traffic.
6
7use crate::error::{BenchError, Result};
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::Path;
12
13// ---------------------------------------------------------------------------
14// Minimal HAR deserialization types (local to avoid circular deps)
15// ---------------------------------------------------------------------------
16
17/// Top-level HAR archive
18#[derive(Debug, Deserialize)]
19pub struct HarArchive {
20    /// The HAR log
21    pub log: HarLog,
22}
23
24/// HAR log containing entries
25#[derive(Debug, Deserialize)]
26pub struct HarLog {
27    /// Recorded HTTP entries
28    pub entries: Vec<HarEntry>,
29}
30
31/// A single HAR entry (request + response pair)
32#[derive(Debug, Deserialize)]
33pub struct HarEntry {
34    /// The outgoing request
35    pub request: HarRequest,
36    /// The received response
37    pub response: HarResponse,
38}
39
40/// HAR query string parameter
41#[derive(Debug, Deserialize)]
42pub struct HarQueryParam {
43    /// Parameter name
44    pub name: String,
45    /// Parameter value
46    pub value: String,
47}
48
49/// HAR post data
50#[derive(Debug, Deserialize, Default)]
51pub struct HarPostData {
52    /// MIME type
53    #[serde(rename = "mimeType", default)]
54    pub mime_type: String,
55    /// Request body text
56    #[serde(default)]
57    pub text: Option<String>,
58}
59
60/// HAR request
61#[derive(Debug, Deserialize, Default)]
62pub struct HarRequest {
63    /// HTTP method (GET, POST, etc.)
64    #[serde(default)]
65    pub method: String,
66    /// Full request URL
67    #[serde(default)]
68    pub url: String,
69    /// Request headers
70    #[serde(default)]
71    pub headers: Vec<HarHeader>,
72    /// Query string parameters (structured)
73    #[serde(rename = "queryString", default)]
74    pub query_string: Vec<HarQueryParam>,
75    /// POST/PUT/PATCH request body
76    #[serde(rename = "postData", default)]
77    pub post_data: Option<HarPostData>,
78}
79
80/// HAR response
81#[derive(Debug, Deserialize)]
82pub struct HarResponse {
83    /// HTTP status code
84    pub status: u16,
85    /// Response headers
86    #[serde(default)]
87    pub headers: Vec<HarHeader>,
88    /// Response body content
89    #[serde(default)]
90    pub content: Option<HarContent>,
91}
92
93/// A single HTTP header
94#[derive(Debug, Deserialize)]
95pub struct HarHeader {
96    /// Header name
97    pub name: String,
98    /// Header value
99    pub value: String,
100}
101
102/// Response body content
103#[derive(Debug, Deserialize)]
104pub struct HarContent {
105    /// MIME type
106    #[serde(rename = "mimeType", default)]
107    pub mime_type: Option<String>,
108    /// Body text
109    #[serde(default)]
110    pub text: Option<String>,
111}
112
113// ---------------------------------------------------------------------------
114// Options
115// ---------------------------------------------------------------------------
116
117/// Options controlling HAR-to-YAML conversion
118#[derive(Debug, Clone)]
119pub struct HarToCustomOptions {
120    /// Base URL to strip from entry URLs. If `None`, auto-detected from the
121    /// first entry's scheme + host + port.
122    pub base_url: Option<String>,
123    /// Skip entries whose path ends with common static-asset extensions
124    /// (.js, .css, .png, .jpg, .gif, .svg, .ico, .woff, .woff2, .ttf, .map).
125    pub skip_static: bool,
126    /// Only include these response headers in the generated checks.
127    /// If empty, no header checks are generated.
128    pub include_headers: Vec<String>,
129    /// Maximum number of entries to process (0 = unlimited).
130    pub max_entries: usize,
131}
132
133impl Default for HarToCustomOptions {
134    fn default() -> Self {
135        Self {
136            base_url: None,
137            skip_static: true,
138            include_headers: Vec::new(),
139            max_entries: 0,
140        }
141    }
142}
143
144// ---------------------------------------------------------------------------
145// Output types (serialize to YAML matching CustomConformanceConfig)
146// ---------------------------------------------------------------------------
147
148#[derive(Debug, Serialize)]
149struct OutputConfig {
150    custom_checks: Vec<OutputCheck>,
151}
152
153#[derive(Debug, Serialize)]
154struct OutputCheck {
155    name: String,
156    path: String,
157    method: String,
158    expected_status: u16,
159    /// Request body for POST/PUT/PATCH (extracted from HAR postData)
160    #[serde(skip_serializing_if = "Option::is_none")]
161    body: Option<String>,
162    #[serde(skip_serializing_if = "HashMap::is_empty")]
163    expected_headers: HashMap<String, String>,
164    #[serde(skip_serializing_if = "Vec::is_empty")]
165    expected_body_fields: Vec<OutputBodyField>,
166}
167
168#[derive(Debug, Serialize)]
169struct OutputBodyField {
170    name: String,
171    #[serde(rename = "type")]
172    field_type: String,
173}
174
175// ---------------------------------------------------------------------------
176// Static-asset extensions
177// ---------------------------------------------------------------------------
178
179const STATIC_EXTENSIONS: &[&str] = &[
180    ".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf",
181    ".map", ".eot",
182];
183
184// ---------------------------------------------------------------------------
185// Hop-by-hop / noise headers to always skip
186// ---------------------------------------------------------------------------
187
188const SKIP_HEADERS: &[&str] = &[
189    "connection",
190    "transfer-encoding",
191    "date",
192    "server",
193    "content-length",
194    "vary",
195    "x-request-id",
196];
197
198// ---------------------------------------------------------------------------
199// Public API
200// ---------------------------------------------------------------------------
201
202/// Generate a YAML string (matching `--conformance-custom` format) from a HAR file.
203pub fn generate_custom_yaml_from_har(
204    har_path: &Path,
205    options: HarToCustomOptions,
206) -> Result<String> {
207    let raw = std::fs::read_to_string(har_path).map_err(|e| {
208        BenchError::Other(format!("Failed to read HAR file '{}': {}", har_path.display(), e))
209    })?;
210
211    let archive: HarArchive = serde_json::from_str(&raw).map_err(|e| {
212        BenchError::Other(format!("Failed to parse HAR file '{}': {}", har_path.display(), e))
213    })?;
214
215    generate_custom_yaml(&archive, &options)
216}
217
218/// Core generation logic, separated for testability.
219fn generate_custom_yaml(archive: &HarArchive, options: &HarToCustomOptions) -> Result<String> {
220    // Auto-detect base URL from first entry if not provided
221    let base_url = match &options.base_url {
222        Some(url) => url.trim_end_matches('/').to_string(),
223        None => detect_base_url(&archive.log.entries)?,
224    };
225
226    let header_matchers = build_header_matchers(&options.include_headers);
227
228    let mut checks = Vec::new();
229
230    for entry in &archive.log.entries {
231        if options.max_entries > 0 && checks.len() >= options.max_entries {
232            break;
233        }
234
235        let path_only = extract_path(&entry.request.url, &base_url);
236
237        // Skip static assets if requested
238        if options.skip_static && is_static_asset(&path_only) {
239            continue;
240        }
241
242        // Build full path with query string if present
243        let path = match extract_query_string(&entry.request) {
244            Some(qs) => format!("{}?{}", path_only, qs),
245            None => path_only.clone(),
246        };
247
248        let method = entry.request.method.to_uppercase();
249
250        // Build expected_headers (filtered)
251        let mut expected_headers = HashMap::new();
252        if !header_matchers.is_empty() {
253            for h in &entry.response.headers {
254                let lower = h.name.to_lowercase();
255                if SKIP_HEADERS.contains(&lower.as_str()) {
256                    continue;
257                }
258                if header_matches(&lower, &header_matchers) {
259                    // Escape regex special chars in the value for a literal match
260                    expected_headers.insert(h.name.clone(), regex_escape(&h.value));
261                }
262            }
263        }
264
265        // Extract body fields from JSON response
266        let expected_body_fields = extract_body_fields(entry);
267
268        // Extract request body from HAR postData (for POST/PUT/PATCH)
269        let body = entry
270            .request
271            .post_data
272            .as_ref()
273            .and_then(|pd| pd.text.as_deref())
274            .filter(|t| !t.is_empty())
275            .map(|t| t.to_string());
276
277        // Build a human-readable check name (use path without query string)
278        let slug = path_only.replace('/', "-").trim_matches('-').to_string();
279        let name =
280            format!("custom:har:{}-{}-{}", method.to_lowercase(), slug, entry.response.status);
281
282        checks.push(OutputCheck {
283            name,
284            path,
285            method,
286            expected_status: entry.response.status,
287            body,
288            expected_headers,
289            expected_body_fields,
290        });
291    }
292
293    let config = OutputConfig {
294        custom_checks: checks,
295    };
296
297    serde_yaml::to_string(&config)
298        .map_err(|e| BenchError::Other(format!("Failed to serialize YAML: {}", e)))
299}
300
301// ---------------------------------------------------------------------------
302// Helpers
303// ---------------------------------------------------------------------------
304
305/// Detect the base URL (scheme + host + port) from the first entry.
306fn detect_base_url(entries: &[HarEntry]) -> Result<String> {
307    let first = entries
308        .first()
309        .ok_or_else(|| BenchError::Other("HAR file contains no entries".to_string()))?;
310
311    let parsed = url::Url::parse(&first.request.url).map_err(|e| {
312        BenchError::Other(format!("Failed to parse URL '{}': {}", first.request.url, e))
313    })?;
314
315    let mut base = format!("{}://{}", parsed.scheme(), parsed.host_str().unwrap_or("localhost"));
316    if let Some(port) = parsed.port() {
317        base.push_str(&format!(":{}", port));
318    }
319    Ok(base)
320}
321
322/// Strip the base URL from a full URL to get the path only (no query string).
323fn extract_path(full_url: &str, base_url: &str) -> String {
324    if let Some(rest) = full_url.strip_prefix(base_url) {
325        if rest.is_empty() {
326            "/".to_string()
327        } else if rest.starts_with('/') {
328            rest.split('?').next().unwrap_or(rest).to_string()
329        } else {
330            format!("/{}", rest.split('?').next().unwrap_or(rest))
331        }
332    } else {
333        // Fallback: parse URL and use the path component
334        match url::Url::parse(full_url) {
335            Ok(parsed) => parsed.path().to_string(),
336            Err(_) => full_url.to_string(),
337        }
338    }
339}
340
341/// Build a query string from structured HAR query params, or fall back to
342/// extracting it from the raw URL.
343fn extract_query_string(request: &HarRequest) -> Option<String> {
344    if !request.query_string.is_empty() {
345        // Use structured queryString array from HAR
346        let pairs: Vec<String> = request
347            .query_string
348            .iter()
349            .map(|p| format!("{}={}", urlencoding::encode(&p.name), urlencoding::encode(&p.value)))
350            .collect();
351        Some(pairs.join("&"))
352    } else {
353        // Fall back to extracting from the URL
354        request.url.split_once('?').map(|(_, qs)| qs.to_string())
355    }
356}
357
358fn is_static_asset(path: &str) -> bool {
359    let lower = path.to_lowercase();
360    STATIC_EXTENSIONS.iter().any(|ext| lower.ends_with(ext))
361}
362
363/// Regex metacharacters that, when present in a header pattern, indicate it
364/// should be compiled as a regex rather than matched as a literal string.
365const REGEX_META: &[char] = &['*', '+', '?', '[', '|', '^', '$', '.'];
366
367/// A compiled header matcher — either a regex pattern or an exact lowercase string.
368enum HeaderMatcher {
369    Regex(Regex),
370    Exact(String),
371}
372
373/// Build header matchers from the user-supplied include-headers list.
374/// Each entry is treated as a regex if it contains any regex metacharacter,
375/// otherwise as an exact case-insensitive match.
376fn build_header_matchers(include_headers: &[String]) -> Vec<HeaderMatcher> {
377    include_headers
378        .iter()
379        .map(|h| {
380            let lower = h.to_lowercase();
381            if lower.contains(REGEX_META) {
382                // Anchor the pattern so it must match the full header name
383                let anchored = format!("^(?:{})$", lower);
384                match Regex::new(&anchored) {
385                    Ok(re) => HeaderMatcher::Regex(re),
386                    // If the regex is invalid, fall back to exact match
387                    Err(_) => HeaderMatcher::Exact(lower),
388                }
389            } else {
390                HeaderMatcher::Exact(lower)
391            }
392        })
393        .collect()
394}
395
396/// Check whether a lowercase header name matches any of the matchers.
397fn header_matches(lower_name: &str, matchers: &[HeaderMatcher]) -> bool {
398    matchers.iter().any(|m| match m {
399        HeaderMatcher::Exact(exact) => lower_name == exact,
400        HeaderMatcher::Regex(re) => re.is_match(lower_name),
401    })
402}
403
404/// Escape regex metacharacters so the value is matched literally.
405fn regex_escape(s: &str) -> String {
406    let mut out = String::with_capacity(s.len() + 8);
407    for ch in s.chars() {
408        if "\\^$.|?*+()[]{}".contains(ch) {
409            out.push('\\');
410        }
411        out.push(ch);
412    }
413    out
414}
415
416/// Maximum recursion depth for nested body field extraction.
417const MAX_BODY_FIELD_DEPTH: usize = 3;
418
419/// Extract field names + JSON types from the response body (if JSON).
420///
421/// Recursively descends into nested objects and arrays (up to
422/// [`MAX_BODY_FIELD_DEPTH`] levels) producing dot-notation paths:
423///   - Nested objects: `parent.child`
424///   - Arrays of objects: inspects the first element and uses `parent[].child`
425fn extract_body_fields(entry: &HarEntry) -> Vec<OutputBodyField> {
426    let content = match &entry.response.content {
427        Some(c) => c,
428        None => return Vec::new(),
429    };
430
431    // Only process JSON responses
432    let mime = content.mime_type.as_deref().unwrap_or("");
433    if !mime.contains("json") {
434        return Vec::new();
435    }
436
437    let text = match &content.text {
438        Some(t) if !t.is_empty() => t,
439        _ => return Vec::new(),
440    };
441
442    let value: serde_json::Value = match serde_json::from_str(text) {
443        Ok(v) => v,
444        Err(_) => return Vec::new(),
445    };
446
447    let mut fields = Vec::new();
448    collect_body_fields(&value, "", &mut fields, 0);
449    fields
450}
451
452/// Recursively collect body fields from a JSON value.
453///
454/// `prefix` is the dot-notation path accumulated so far (empty at root).
455/// `depth` tracks the current recursion level (0 at root).
456fn collect_body_fields(
457    value: &serde_json::Value,
458    prefix: &str,
459    out: &mut Vec<OutputBodyField>,
460    depth: usize,
461) {
462    match value {
463        serde_json::Value::Object(map) => {
464            for (k, v) in map {
465                let name = if prefix.is_empty() {
466                    k.clone()
467                } else {
468                    format!("{}.{}", prefix, k)
469                };
470                out.push(OutputBodyField {
471                    name: name.clone(),
472                    field_type: json_type_name(v),
473                });
474                // Recurse into nested objects/arrays if within depth limit
475                if depth < MAX_BODY_FIELD_DEPTH {
476                    match v {
477                        serde_json::Value::Object(_) => {
478                            collect_body_fields(v, &name, out, depth + 1);
479                        }
480                        serde_json::Value::Array(arr) => {
481                            // Inspect first element of arrays
482                            if let Some(serde_json::Value::Object(_)) = arr.first() {
483                                let arr_prefix = format!("{}[]", name);
484                                collect_body_fields(
485                                    arr.first().unwrap(),
486                                    &arr_prefix,
487                                    out,
488                                    depth + 1,
489                                );
490                            }
491                        }
492                        _ => {}
493                    }
494                }
495            }
496        }
497        // Array: inspect the first element
498        serde_json::Value::Array(arr) => {
499            if let Some(serde_json::Value::Object(_)) = arr.first() {
500                // For top-level arrays (empty prefix), extract fields without a
501                // prefix to maintain backward compatibility. For nested arrays
502                // the caller already appended `[]` to the prefix before recursing.
503                collect_body_fields(arr.first().unwrap(), prefix, out, depth);
504            }
505        }
506        _ => {}
507    }
508}
509
510fn json_type_name(v: &serde_json::Value) -> String {
511    match v {
512        serde_json::Value::String(_) => "string".to_string(),
513        serde_json::Value::Number(n) => {
514            if n.is_i64() || n.is_u64() {
515                "integer".to_string()
516            } else {
517                "number".to_string()
518            }
519        }
520        serde_json::Value::Bool(_) => "boolean".to_string(),
521        serde_json::Value::Array(_) => "array".to_string(),
522        serde_json::Value::Object(_) => "object".to_string(),
523        serde_json::Value::Null => "string".to_string(), // fallback
524    }
525}
526
527// ---------------------------------------------------------------------------
528// Tests
529// ---------------------------------------------------------------------------
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    fn sample_har() -> HarArchive {
536        HarArchive {
537            log: HarLog {
538                entries: vec![
539                    HarEntry {
540                        request: HarRequest {
541                            method: "GET".to_string(),
542                            url: "http://localhost:3000/api/users".to_string(),
543                            headers: vec![],
544                            query_string: vec![],
545                            post_data: None,
546                        },
547                        response: HarResponse {
548                            status: 200,
549                            headers: vec![
550                                HarHeader {
551                                    name: "content-type".to_string(),
552                                    value: "application/json".to_string(),
553                                },
554                                HarHeader {
555                                    name: "x-request-id".to_string(),
556                                    value: "abc-123".to_string(),
557                                },
558                            ],
559                            content: Some(HarContent {
560                                mime_type: Some("application/json".to_string()),
561                                text: Some(
562                                    r#"[{"id": 1, "name": "Alice", "active": true}]"#.to_string(),
563                                ),
564                            }),
565                        },
566                    },
567                    HarEntry {
568                        request: HarRequest {
569                            method: "POST".to_string(),
570                            url: "http://localhost:3000/api/users".to_string(),
571                            headers: vec![],
572                            query_string: vec![],
573                            post_data: None,
574                        },
575                        response: HarResponse {
576                            status: 201,
577                            headers: vec![HarHeader {
578                                name: "content-type".to_string(),
579                                value: "application/json".to_string(),
580                            }],
581                            content: Some(HarContent {
582                                mime_type: Some("application/json".to_string()),
583                                text: Some(r#"{"id": 2, "name": "Bob"}"#.to_string()),
584                            }),
585                        },
586                    },
587                    HarEntry {
588                        request: HarRequest {
589                            method: "GET".to_string(),
590                            url: "http://localhost:3000/static/app.js".to_string(),
591                            headers: vec![],
592                            query_string: vec![],
593                            post_data: None,
594                        },
595                        response: HarResponse {
596                            status: 200,
597                            headers: vec![],
598                            content: None,
599                        },
600                    },
601                ],
602            },
603        }
604    }
605
606    #[test]
607    fn test_basic_generation() {
608        let har = sample_har();
609        let options = HarToCustomOptions {
610            skip_static: true,
611            ..Default::default()
612        };
613        let yaml = generate_custom_yaml(&har, &options).unwrap();
614
615        // Should contain 2 checks (static .js skipped)
616        let config: super::super::custom::CustomConformanceConfig =
617            serde_yaml::from_str(&yaml).unwrap();
618        assert_eq!(config.custom_checks.len(), 2);
619        assert_eq!(config.custom_checks[0].method, "GET");
620        assert_eq!(config.custom_checks[0].path, "/api/users");
621        assert_eq!(config.custom_checks[0].expected_status, 200);
622        assert_eq!(config.custom_checks[1].method, "POST");
623        assert_eq!(config.custom_checks[1].expected_status, 201);
624    }
625
626    #[test]
627    fn test_body_field_extraction() {
628        let har = sample_har();
629        let options = HarToCustomOptions::default();
630        let yaml = generate_custom_yaml(&har, &options).unwrap();
631
632        let config: super::super::custom::CustomConformanceConfig =
633            serde_yaml::from_str(&yaml).unwrap();
634
635        // First entry is an array — should extract fields from the first element
636        let fields = &config.custom_checks[0].expected_body_fields;
637        assert_eq!(fields.len(), 3);
638        assert!(fields.iter().any(|f| f.name == "id" && f.field_type == "integer"));
639        assert!(fields.iter().any(|f| f.name == "name" && f.field_type == "string"));
640        assert!(fields.iter().any(|f| f.name == "active" && f.field_type == "boolean"));
641    }
642
643    #[test]
644    fn test_include_headers() {
645        let har = sample_har();
646        let options = HarToCustomOptions {
647            include_headers: vec!["content-type".to_string()],
648            ..Default::default()
649        };
650        let yaml = generate_custom_yaml(&har, &options).unwrap();
651
652        let config: super::super::custom::CustomConformanceConfig =
653            serde_yaml::from_str(&yaml).unwrap();
654
655        // First check should have content-type header, x-request-id should be skipped
656        let headers = &config.custom_checks[0].expected_headers;
657        assert!(headers.contains_key("content-type"));
658        assert!(!headers.contains_key("x-request-id"));
659    }
660
661    #[test]
662    fn test_skip_static_false() {
663        let har = sample_har();
664        let options = HarToCustomOptions {
665            skip_static: false,
666            ..Default::default()
667        };
668        let yaml = generate_custom_yaml(&har, &options).unwrap();
669
670        let config: super::super::custom::CustomConformanceConfig =
671            serde_yaml::from_str(&yaml).unwrap();
672        // Should include all 3 entries when skip_static is false
673        assert_eq!(config.custom_checks.len(), 3);
674    }
675
676    #[test]
677    fn test_max_entries() {
678        let har = sample_har();
679        let options = HarToCustomOptions {
680            skip_static: false,
681            max_entries: 1,
682            ..Default::default()
683        };
684        let yaml = generate_custom_yaml(&har, &options).unwrap();
685
686        let config: super::super::custom::CustomConformanceConfig =
687            serde_yaml::from_str(&yaml).unwrap();
688        assert_eq!(config.custom_checks.len(), 1);
689    }
690
691    #[test]
692    fn test_custom_base_url() {
693        let har = sample_har();
694        let options = HarToCustomOptions {
695            base_url: Some("http://localhost:3000/api".to_string()),
696            ..Default::default()
697        };
698        let yaml = generate_custom_yaml(&har, &options).unwrap();
699
700        let config: super::super::custom::CustomConformanceConfig =
701            serde_yaml::from_str(&yaml).unwrap();
702        assert_eq!(config.custom_checks[0].path, "/users");
703    }
704
705    #[test]
706    fn test_detect_base_url() {
707        let entries = vec![HarEntry {
708            request: HarRequest {
709                method: "GET".to_string(),
710                url: "https://api.example.com:8443/v1/health".to_string(),
711                headers: vec![],
712                query_string: vec![],
713                post_data: None,
714            },
715            response: HarResponse {
716                status: 200,
717                headers: vec![],
718                content: None,
719            },
720        }];
721
722        let base = detect_base_url(&entries).unwrap();
723        assert_eq!(base, "https://api.example.com:8443");
724    }
725
726    #[test]
727    fn test_empty_entries() {
728        let archive = HarArchive {
729            log: HarLog { entries: vec![] },
730        };
731        let result = detect_base_url(&archive.log.entries);
732        assert!(result.is_err());
733    }
734
735    #[test]
736    fn test_regex_escape() {
737        assert_eq!(regex_escape("application/json"), "application/json");
738        assert_eq!(regex_escape("text/html; charset=utf-8"), "text/html; charset=utf-8");
739        assert_eq!(regex_escape("foo.bar"), "foo\\.bar");
740        assert_eq!(regex_escape("a(b)"), "a\\(b\\)");
741    }
742
743    #[test]
744    fn test_extract_path_with_query_string() {
745        let path = extract_path(
746            "http://localhost:3000/api/users?page=1&limit=10",
747            "http://localhost:3000",
748        );
749        assert_eq!(path, "/api/users");
750    }
751
752    #[test]
753    fn test_extract_query_string_from_structured() {
754        let request = HarRequest {
755            method: "GET".to_string(),
756            url: "http://localhost:3000/api/users?page=1&limit=10".to_string(),
757            headers: vec![],
758            query_string: vec![
759                HarQueryParam {
760                    name: "page".to_string(),
761                    value: "1".to_string(),
762                },
763                HarQueryParam {
764                    name: "limit".to_string(),
765                    value: "10".to_string(),
766                },
767            ],
768            post_data: None,
769        };
770        let qs = extract_query_string(&request).unwrap();
771        assert_eq!(qs, "page=1&limit=10");
772    }
773
774    #[test]
775    fn test_extract_query_string_from_url_fallback() {
776        let request = HarRequest {
777            method: "GET".to_string(),
778            url: "http://localhost:3000/api/users?page=1&limit=10".to_string(),
779            headers: vec![],
780            query_string: vec![],
781            post_data: None,
782        };
783        let qs = extract_query_string(&request).unwrap();
784        assert_eq!(qs, "page=1&limit=10");
785    }
786
787    #[test]
788    fn test_extract_query_string_none_when_absent() {
789        let request = HarRequest {
790            method: "GET".to_string(),
791            url: "http://localhost:3000/api/users".to_string(),
792            headers: vec![],
793            query_string: vec![],
794            post_data: None,
795        };
796        assert!(extract_query_string(&request).is_none());
797    }
798
799    #[test]
800    fn test_har_with_query_params_in_yaml() {
801        let har = HarArchive {
802            log: HarLog {
803                entries: vec![HarEntry {
804                    request: HarRequest {
805                        method: "GET".to_string(),
806                        url: "http://localhost:3000/api/users?page=1&limit=10".to_string(),
807                        headers: vec![],
808                        query_string: vec![
809                            HarQueryParam {
810                                name: "page".to_string(),
811                                value: "1".to_string(),
812                            },
813                            HarQueryParam {
814                                name: "limit".to_string(),
815                                value: "10".to_string(),
816                            },
817                        ],
818                        post_data: None,
819                    },
820                    response: HarResponse {
821                        status: 200,
822                        headers: vec![],
823                        content: None,
824                    },
825                }],
826            },
827        };
828        let options = HarToCustomOptions::default();
829        let yaml = generate_custom_yaml(&har, &options).unwrap();
830
831        let config: super::super::custom::CustomConformanceConfig =
832            serde_yaml::from_str(&yaml).unwrap();
833        assert_eq!(config.custom_checks[0].path, "/api/users?page=1&limit=10");
834    }
835
836    #[test]
837    fn test_include_headers_regex_pattern() {
838        let har = HarArchive {
839            log: HarLog {
840                entries: vec![HarEntry {
841                    request: HarRequest {
842                        method: "GET".to_string(),
843                        url: "http://localhost:3000/api/data".to_string(),
844                        headers: vec![],
845                        query_string: vec![],
846                        post_data: None,
847                    },
848                    response: HarResponse {
849                        status: 200,
850                        headers: vec![
851                            HarHeader {
852                                name: "content-type".to_string(),
853                                value: "application/json".to_string(),
854                            },
855                            HarHeader {
856                                name: "content-length".to_string(),
857                                value: "42".to_string(),
858                            },
859                            HarHeader {
860                                name: "x-api-version".to_string(),
861                                value: "2".to_string(),
862                            },
863                            HarHeader {
864                                name: "x-api-request-id".to_string(),
865                                value: "abc".to_string(),
866                            },
867                            HarHeader {
868                                name: "x-other".to_string(),
869                                value: "ignored".to_string(),
870                            },
871                            HarHeader {
872                                name: "cache-control".to_string(),
873                                value: "no-cache".to_string(),
874                            },
875                        ],
876                        content: None,
877                    },
878                }],
879            },
880        };
881
882        let options = HarToCustomOptions {
883            // "content-.*" is a regex pattern, "cache-control" is exact
884            include_headers: vec![
885                "content-.*".to_string(),
886                "x-api-.*".to_string(),
887                "cache-control".to_string(),
888            ],
889            ..Default::default()
890        };
891        let yaml = generate_custom_yaml(&har, &options).unwrap();
892        let config: super::super::custom::CustomConformanceConfig =
893            serde_yaml::from_str(&yaml).unwrap();
894
895        let headers = &config.custom_checks[0].expected_headers;
896        // content-type matches "content-.*" pattern
897        assert!(headers.contains_key("content-type"), "content-type should match content-.*");
898        // content-length is in SKIP_HEADERS, so it should NOT appear
899        assert!(!headers.contains_key("content-length"), "content-length is in skip list");
900        // x-api-version and x-api-request-id match "x-api-.*"
901        assert!(headers.contains_key("x-api-version"), "x-api-version should match x-api-.*");
902        assert!(
903            headers.contains_key("x-api-request-id"),
904            "x-api-request-id should match x-api-.*"
905        );
906        // x-other should NOT match any pattern
907        assert!(!headers.contains_key("x-other"), "x-other should not match");
908        // cache-control is exact match
909        assert!(headers.contains_key("cache-control"), "cache-control exact match");
910    }
911
912    #[test]
913    fn test_include_headers_exact_no_regex() {
914        // Patterns without metacharacters should work as exact case-insensitive matches
915        let matchers = build_header_matchers(&["x-custom".to_string()]);
916        assert!(header_matches("x-custom", &matchers));
917        assert!(!header_matches("x-custom-extra", &matchers));
918        assert!(!header_matches("x-custo", &matchers));
919    }
920
921    #[test]
922    fn test_nested_body_field_extraction() {
923        let entry = HarEntry {
924            request: HarRequest {
925                method: "GET".to_string(),
926                url: "http://localhost:3000/api/data".to_string(),
927                headers: vec![],
928                query_string: vec![],
929                post_data: None,
930            },
931            response: HarResponse {
932                status: 200,
933                headers: vec![],
934                content: Some(HarContent {
935                    mime_type: Some("application/json".to_string()),
936                    text: Some(
937                        r#"{"total": 10, "results": {"name": "Alice", "count": 5}, "tags": ["a"]}"#
938                            .to_string(),
939                    ),
940                }),
941            },
942        };
943
944        let fields = extract_body_fields(&entry);
945        let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
946
947        // Top-level fields
948        assert!(names.contains(&"total"));
949        assert!(names.contains(&"results"));
950        assert!(names.contains(&"tags"));
951        // Nested object fields
952        assert!(names.contains(&"results.name"));
953        assert!(names.contains(&"results.count"));
954
955        // Check types
956        let results_name = fields.iter().find(|f| f.name == "results.name").unwrap();
957        assert_eq!(results_name.field_type, "string");
958        let results_count = fields.iter().find(|f| f.name == "results.count").unwrap();
959        assert_eq!(results_count.field_type, "integer");
960    }
961
962    #[test]
963    fn test_nested_array_body_field_extraction() {
964        let entry = HarEntry {
965            request: HarRequest {
966                method: "GET".to_string(),
967                url: "http://localhost:3000/api/data".to_string(),
968                headers: vec![],
969                query_string: vec![],
970                post_data: None,
971            },
972            response: HarResponse {
973                status: 200,
974                headers: vec![],
975                content: Some(HarContent {
976                    mime_type: Some("application/json".to_string()),
977                    text: Some(r#"{"items": [{"id": 1, "label": "foo"}]}"#.to_string()),
978                }),
979            },
980        };
981
982        let fields = extract_body_fields(&entry);
983        let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
984
985        assert!(names.contains(&"items"));
986        assert!(names.contains(&"items[].id"));
987        assert!(names.contains(&"items[].label"));
988    }
989
990    #[test]
991    fn test_nested_depth_limit() {
992        // Build a deeply nested JSON: {a: {b: {c: {d: {e: 1}}}}}
993        let entry = HarEntry {
994            request: HarRequest {
995                method: "GET".to_string(),
996                url: "http://localhost:3000/deep".to_string(),
997                headers: vec![],
998                query_string: vec![],
999                post_data: None,
1000            },
1001            response: HarResponse {
1002                status: 200,
1003                headers: vec![],
1004                content: Some(HarContent {
1005                    mime_type: Some("application/json".to_string()),
1006                    text: Some(r#"{"a": {"b": {"c": {"d": {"e": 1}}}}}"#.to_string()),
1007                }),
1008            },
1009        };
1010
1011        let fields = extract_body_fields(&entry);
1012        let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
1013
1014        // depth 0: a (object)
1015        assert!(names.contains(&"a"));
1016        // depth 1: a.b (object)
1017        assert!(names.contains(&"a.b"));
1018        // depth 2: a.b.c (object)
1019        assert!(names.contains(&"a.b.c"));
1020        // depth 3: a.b.c.d (object) — at MAX_BODY_FIELD_DEPTH, so its children are NOT expanded
1021        assert!(names.contains(&"a.b.c.d"));
1022        // a.b.c.d.e should NOT be present (depth limit reached)
1023        assert!(!names.contains(&"a.b.c.d.e"), "should not recurse beyond depth 3");
1024    }
1025
1026    #[test]
1027    fn test_check_name_format() {
1028        let har = sample_har();
1029        let options = HarToCustomOptions::default();
1030        let yaml = generate_custom_yaml(&har, &options).unwrap();
1031
1032        let config: super::super::custom::CustomConformanceConfig =
1033            serde_yaml::from_str(&yaml).unwrap();
1034        assert!(config.custom_checks[0].name.starts_with("custom:"));
1035    }
1036}