1use crate::error::{BenchError, Result};
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::Path;
12
13#[derive(Debug, Deserialize)]
19pub struct HarArchive {
20 pub log: HarLog,
22}
23
24#[derive(Debug, Deserialize)]
26pub struct HarLog {
27 pub entries: Vec<HarEntry>,
29}
30
31#[derive(Debug, Deserialize)]
33pub struct HarEntry {
34 pub request: HarRequest,
36 pub response: HarResponse,
38}
39
40#[derive(Debug, Deserialize)]
42pub struct HarQueryParam {
43 pub name: String,
45 pub value: String,
47}
48
49#[derive(Debug, Deserialize, Default)]
51pub struct HarPostData {
52 #[serde(rename = "mimeType", default)]
54 pub mime_type: String,
55 #[serde(default)]
57 pub text: Option<String>,
58}
59
60#[derive(Debug, Deserialize, Default)]
62pub struct HarRequest {
63 #[serde(default)]
65 pub method: String,
66 #[serde(default)]
68 pub url: String,
69 #[serde(default)]
71 pub headers: Vec<HarHeader>,
72 #[serde(rename = "queryString", default)]
74 pub query_string: Vec<HarQueryParam>,
75 #[serde(rename = "postData", default)]
77 pub post_data: Option<HarPostData>,
78}
79
80#[derive(Debug, Deserialize)]
82pub struct HarResponse {
83 pub status: u16,
85 #[serde(default)]
87 pub headers: Vec<HarHeader>,
88 #[serde(default)]
90 pub content: Option<HarContent>,
91}
92
93#[derive(Debug, Deserialize)]
95pub struct HarHeader {
96 pub name: String,
98 pub value: String,
100}
101
102#[derive(Debug, Deserialize)]
104pub struct HarContent {
105 #[serde(rename = "mimeType", default)]
107 pub mime_type: Option<String>,
108 #[serde(default)]
110 pub text: Option<String>,
111}
112
113#[derive(Debug, Clone)]
119pub struct HarToCustomOptions {
120 pub base_url: Option<String>,
123 pub skip_static: bool,
126 pub include_headers: Vec<String>,
129 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#[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 #[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
175const STATIC_EXTENSIONS: &[&str] = &[
180 ".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf",
181 ".map", ".eot",
182];
183
184const SKIP_HEADERS: &[&str] = &[
189 "connection",
190 "transfer-encoding",
191 "date",
192 "server",
193 "content-length",
194 "vary",
195 "x-request-id",
196];
197
198pub 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
218fn generate_custom_yaml(archive: &HarArchive, options: &HarToCustomOptions) -> Result<String> {
220 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 if options.skip_static && is_static_asset(&path_only) {
239 continue;
240 }
241
242 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 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 expected_headers.insert(h.name.clone(), regex_escape(&h.value));
261 }
262 }
263 }
264
265 let expected_body_fields = extract_body_fields(entry);
267
268 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 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
301fn 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
322fn 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 match url::Url::parse(full_url) {
335 Ok(parsed) => parsed.path().to_string(),
336 Err(_) => full_url.to_string(),
337 }
338 }
339}
340
341fn extract_query_string(request: &HarRequest) -> Option<String> {
344 if !request.query_string.is_empty() {
345 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 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
363const REGEX_META: &[char] = &['*', '+', '?', '[', '|', '^', '$', '.'];
366
367enum HeaderMatcher {
369 Regex(Regex),
370 Exact(String),
371}
372
373fn 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 let anchored = format!("^(?:{})$", lower);
384 match Regex::new(&anchored) {
385 Ok(re) => HeaderMatcher::Regex(re),
386 Err(_) => HeaderMatcher::Exact(lower),
388 }
389 } else {
390 HeaderMatcher::Exact(lower)
391 }
392 })
393 .collect()
394}
395
396fn 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
404fn 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
416const MAX_BODY_FIELD_DEPTH: usize = 3;
418
419fn 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 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
452fn 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 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 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 serde_json::Value::Array(arr) => {
499 if let Some(serde_json::Value::Object(_)) = arr.first() {
500 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(), }
525}
526
527#[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 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 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 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 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 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 assert!(headers.contains_key("content-type"), "content-type should match content-.*");
898 assert!(!headers.contains_key("content-length"), "content-length is in skip list");
900 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 assert!(!headers.contains_key("x-other"), "x-other should not match");
908 assert!(headers.contains_key("cache-control"), "cache-control exact match");
910 }
911
912 #[test]
913 fn test_include_headers_exact_no_regex() {
914 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 assert!(names.contains(&"total"));
949 assert!(names.contains(&"results"));
950 assert!(names.contains(&"tags"));
951 assert!(names.contains(&"results.name"));
953 assert!(names.contains(&"results.count"));
954
955 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 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 assert!(names.contains(&"a"));
1016 assert!(names.contains(&"a.b"));
1018 assert!(names.contains(&"a.b.c"));
1020 assert!(names.contains(&"a.b.c.d"));
1022 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}