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