Skip to main content

apif_parser/
error_recovery.rs

1// Error recovery parser for GCTF files
2// Parses as much as possible and collects all errors
3
4use crate::assertions::strip_assertion_comments;
5use crate::ast::{DocumentMetadata, FileMeta, GctfDocument, Section, SectionContent, SectionType};
6use crate::gctf_tokenizer;
7use apif_diagnostics::{DiagnosticCode, DiagnosticCollection, Range};
8use std::path::Path;
9
10/// Result of error recovery parsing
11pub struct ErrorRecoveryResult {
12    pub document: GctfDocument,
13    pub diagnostics: DiagnosticCollection,
14    pub recovered_sections: usize,
15    pub failed_sections: usize,
16}
17
18/// Parse GCTF file with error recovery.
19/// Supports multiple documents via document chain.
20pub fn parse_with_recovery(file_path: &Path) -> ErrorRecoveryResult {
21    let content = std::fs::read_to_string(file_path).unwrap_or_default();
22    parse_content_with_recovery(&content, file_path.to_string_lossy().as_ref())
23}
24
25/// Parse GCTF content string with error recovery.
26/// Documents are determined implicitly: REQUEST after RESPONSE/ERROR/ASSERTS,
27/// or ENDPOINT/ADDRESS starts a new document.
28pub fn parse_content_with_recovery(content: &str, file_path: &str) -> ErrorRecoveryResult {
29    let single = parse_single_with_recovery(content, file_path);
30
31    // Split by implicit boundaries
32    let docs = crate::split_sections_by_boundary(&single.document.sections);
33
34    if docs.len() <= 1 {
35        return single;
36    }
37
38    // Link in reverse
39    let mut head: Option<GctfDocument> = None;
40    let total_recovered = single.recovered_sections;
41    let total_failed = single.failed_sections;
42
43    for doc_sections in docs.into_iter().rev() {
44        let mut doc = build_doc_from_sections(&doc_sections, file_path);
45        doc.next_document = head.map(Box::new);
46        head = Some(doc);
47    }
48
49    ErrorRecoveryResult {
50        document: head.unwrap_or(single.document),
51        diagnostics: single.diagnostics,
52        recovered_sections: total_recovered,
53        failed_sections: total_failed,
54    }
55}
56
57fn build_doc_from_sections(sections: &[Section], file_path: &str) -> GctfDocument {
58    GctfDocument {
59        file_path: file_path.to_string(),
60        sections: sections.to_vec(),
61        metadata: DocumentMetadata {
62            source: None,
63            mtime: None,
64            parsed_at: 0,
65            ..Default::default()
66        },
67        next_document: None,
68    }
69}
70
71/// Parse a single document (no `--- NEW ---` splitting)
72fn parse_single_with_recovery(content: &str, file_path: &str) -> ErrorRecoveryResult {
73    let mut diagnostics = DiagnosticCollection::new();
74    let mut sections = Vec::new();
75    let mut recovered_sections = 0;
76    let failed_sections = 0;
77
78    let lines: Vec<&str> = content.lines().collect();
79    let mut current_line = 0;
80
81    let mut pending_attributes: Vec<crate::ast::GctfAttribute> = Vec::new();
82
83    while current_line < lines.len() {
84        match parse_section(
85            &lines,
86            current_line,
87            &mut diagnostics,
88            &mut pending_attributes,
89        ) {
90            Ok((section, end_line)) => {
91                sections.push(section);
92                recovered_sections += 1;
93                current_line = end_line;
94            }
95            Err(end_line) => {
96                current_line = end_line;
97            }
98        }
99    }
100
101    let document = GctfDocument {
102        file_path: file_path.to_string(),
103        sections,
104        metadata: DocumentMetadata {
105            source: Some(content.to_string()),
106            mtime: None,
107            parsed_at: 0,
108            ..Default::default()
109        },
110        next_document: None,
111    };
112
113    ErrorRecoveryResult {
114        document,
115        diagnostics,
116        recovered_sections,
117        failed_sections,
118    }
119}
120
121/// Parse a single section from lines
122fn parse_section(
123    lines: &[&str],
124    start_line: usize,
125    diagnostics: &mut DiagnosticCollection,
126    pending_attributes: &mut Vec<crate::ast::GctfAttribute>,
127) -> Result<(Section, usize), usize> {
128    let line = lines.get(start_line).copied().unwrap_or("");
129    let trimmed = line.trim();
130
131    if trimmed.is_empty() || trimmed.starts_with("//") {
132        return Err(start_line + 1);
133    }
134
135    if trimmed.starts_with("#[") && trimmed.ends_with(']') {
136        let inner = &trimmed[2..trimmed.len() - 1];
137        if let Some(attr) = crate::content_parser::parse_attribute(inner) {
138            pending_attributes.push(attr);
139        }
140        return Err(start_line + 1);
141    }
142
143    if !trimmed.starts_with("---") {
144        return Err(start_line + 1);
145    }
146
147    let (section_type, inline_options) =
148        match parse_section_header(trimmed, start_line, diagnostics) {
149            Some(t) => t,
150            None => return Err(start_line + 1),
151        };
152
153    let content_start = start_line + 1;
154    let (content, content_end) = extract_section_content(lines, content_start, section_type);
155
156    let content_result = parse_section_content(&content, content_start, section_type, diagnostics);
157
158    let section = Section {
159        section_type,
160        content: content_result,
161        inline_options,
162        raw_content: content.join("\n"),
163        start_line,
164        end_line: content_end,
165        attributes: std::mem::take(pending_attributes),
166    };
167
168    Ok((section, content_end + 1))
169}
170
171/// Parse section header like "--- ENDPOINT ---"
172fn parse_section_header(
173    line: &str,
174    line_num: usize,
175    diagnostics: &mut DiagnosticCollection,
176) -> Option<(SectionType, crate::ast::InlineOptions)> {
177    // Remove --- delimiters
178    let without_delimiters = line.trim_start_matches('-').trim_end_matches('-').trim();
179
180    // Extract section name and inline options
181    let (section_name, inline_opts_str) = without_delimiters
182        .split_once(' ')
183        .map_or((without_delimiters, ""), |(name, opts)| (name, opts));
184    let section_name = section_name.trim();
185    let inline_opts_str = inline_opts_str.trim();
186
187    // Parse section type
188    let section_type = match section_name.to_uppercase().as_str() {
189        "ADDRESS" => SectionType::Address,
190        "ENDPOINT" => SectionType::Endpoint,
191        "REQUEST" => SectionType::Request,
192        "RESPONSE" => SectionType::Response,
193        "ERROR" => SectionType::Error,
194        "EXTRACT" => SectionType::Extract,
195        "ASSERTS" => SectionType::Asserts,
196        "REQUEST_HEADERS" => SectionType::RequestHeaders,
197        "HEADERS" => {
198            diagnostics.warning(
199                DiagnosticCode::DeprecatedSymbol,
200                "HEADERS is deprecated, use REQUEST_HEADERS".to_string(),
201                Range::at_line(line_num),
202            );
203            SectionType::RequestHeaders
204        }
205        "TLS" => SectionType::Tls,
206        "PROTO" => SectionType::Proto,
207        "OPTIONS" => SectionType::Options,
208        "META" => SectionType::Meta,
209        "BENCH" => SectionType::Bench,
210        _ => {
211            diagnostics.warning(
212                DiagnosticCode::UnknownSectionType,
213                format!("Unknown section type: {}", section_name),
214                Range::at_line(line_num),
215            );
216            return None;
217        }
218    };
219
220    // Parse inline options if present
221    let has_opts = !inline_opts_str.is_empty();
222    let inline_options = if has_opts && section_type.supports_inline_options() {
223        match crate::content_parser::parse_inline_options(inline_opts_str) {
224            Ok(opts) => opts,
225            Err(_) => {
226                parse_inline_options_diagnostic(inline_opts_str, line_num, diagnostics);
227                Default::default()
228            }
229        }
230    } else {
231        if has_opts {
232            parse_inline_options_diagnostic(inline_opts_str, line_num, diagnostics);
233        }
234        Default::default()
235    };
236
237    Some((section_type, inline_options))
238}
239
240/// Extract content lines for a section
241fn extract_section_content(
242    lines: &[&str],
243    start: usize,
244    _section_type: SectionType,
245) -> (Vec<String>, usize) {
246    let mut content = Vec::new();
247    let mut end_line = start;
248
249    for (i, line) in lines.iter().enumerate().skip(start) {
250        let trimmed = line.trim();
251
252        // Check for next section header
253        if trimmed.starts_with("---") && trimmed.ends_with("---") {
254            break;
255        }
256
257        // Skip attribute lines (they belong to the next section, not current)
258        if trimmed.starts_with("#[") && trimmed.ends_with(']') {
259            continue;
260        }
261
262        content.push(line.to_string());
263        end_line = i;
264    }
265
266    (content, end_line)
267}
268
269/// Parse section content based on type
270fn parse_section_content(
271    content: &[String],
272    start_line: usize,
273    section_type: SectionType,
274    diagnostics: &mut DiagnosticCollection,
275) -> SectionContent {
276    let content_str = content.join("\n");
277
278    match section_type {
279        SectionType::Address | SectionType::Endpoint => {
280            SectionContent::Single(content_str.trim().to_string())
281        }
282        SectionType::Request | SectionType::Response | SectionType::Error => {
283            if content_str.trim().is_empty() {
284                SectionContent::Empty
285            } else {
286                // Try to parse as JSON5 (with comments), but don't fail - just add diagnostic
287                match crate::json_mod::from_str(&content_str) {
288                    Ok(value) => SectionContent::Json(value),
289                    Err(e) => {
290                        // Add error but continue parsing
291                        diagnostics.error(
292                            DiagnosticCode::JsonParseError,
293                            format!("Failed to parse JSON: {}", e),
294                            Range::at_line(start_line),
295                        );
296                        // Return as-is to allow further processing
297                        SectionContent::Json(serde_json::Value::String(content_str))
298                    }
299                }
300            }
301        }
302        SectionType::Extract => {
303            // Parse extract variables
304            let mut extractions = std::collections::HashMap::new();
305            for (i, line) in content.iter().enumerate() {
306                let trimmed = line.trim();
307                if trimmed.is_empty() || trimmed.starts_with('#') {
308                    continue;
309                }
310
311                if let Some(eq_pos) = trimmed.find('=') {
312                    let name = trimmed[..eq_pos].trim().to_string();
313                    let query = trimmed[eq_pos + 1..].trim().to_string();
314                    extractions.insert(name, query);
315                } else {
316                    diagnostics.warning(
317                        DiagnosticCode::InvalidSyntax,
318                        "Invalid EXTRACT syntax, expected: name = query",
319                        Range::at_line(start_line + i),
320                    );
321                }
322            }
323            SectionContent::Extract(extractions)
324        }
325        SectionType::Asserts => {
326            // Collect assertion lines
327            let assertions: Vec<String> = content
328                .iter()
329                .filter_map(|line| strip_assertion_comments(line))
330                .collect();
331            SectionContent::Assertions(assertions)
332        }
333        SectionType::RequestHeaders
334        | SectionType::Tls
335        | SectionType::Proto
336        | SectionType::Options
337        | SectionType::Bench => {
338            // Parse key-value pairs
339            let mut key_values = std::collections::HashMap::new();
340            for (i, line) in content.iter().enumerate() {
341                let trimmed = line.trim();
342                if trimmed.is_empty() || trimmed.starts_with('#') {
343                    continue;
344                }
345
346                if let Some(colon_pos) = trimmed.find(':') {
347                    let key = trimmed[..colon_pos].trim().to_string();
348                    let value = trimmed[colon_pos + 1..].trim().to_string();
349                    key_values.insert(key, value);
350                } else {
351                    diagnostics.warning(
352                        DiagnosticCode::InvalidSyntax,
353                        "Invalid key-value syntax, expected: key: value",
354                        Range::at_line(start_line + i),
355                    );
356                }
357            }
358            SectionContent::KeyValues(key_values)
359        }
360        SectionType::Meta => {
361            // Use tokenizer to strip GCTF comment lines before parsing YAML
362            let raw = content.join("\n");
363            let tokens = gctf_tokenizer::tokenize_gctf(&raw);
364            let yaml_lines: Vec<String> = content
365                .iter()
366                .zip(tokens.iter())
367                .filter(|(_, t)| !matches!(t.kind, gctf_tokenizer::GctfTokenKind::Comment(_)))
368                .map(|(l, _)| l.clone())
369                .collect();
370            let cleaned = yaml_lines.join("\n");
371            let meta = serde_yaml_ng::from_str::<FileMeta>(&cleaned).unwrap_or_default();
372            SectionContent::Meta(meta)
373        }
374    }
375}
376
377/// Parse inline options like "with_asserts=true"
378fn parse_inline_options_diagnostic(
379    options_str: &str,
380    line_num: usize,
381    diagnostics: &mut DiagnosticCollection,
382) {
383    // Parse options like: with_asserts=true unordered_arrays=true
384    for option in options_str.split_whitespace() {
385        if let Some(eq_pos) = option.find('=') {
386            let key = &option[..eq_pos];
387            let value = &option[eq_pos + 1..];
388
389            match key {
390                "with_asserts" | "unordered_arrays" | "partial" => {
391                    if value != "true" && value != "false" {
392                        diagnostics.warning(
393                            DiagnosticCode::InvalidFieldValue,
394                            format!("Invalid boolean value for {}: {}", key, value),
395                            Range::at_line(line_num),
396                        );
397                    }
398                }
399                "tolerance" => {
400                    if value.parse::<f64>().is_err() {
401                        diagnostics.warning(
402                            DiagnosticCode::InvalidFieldValue,
403                            format!("Invalid numeric value for {}: {}", key, value),
404                            Range::at_line(line_num),
405                        );
406                    }
407                }
408                "redact" => {}
409                _ => {
410                    diagnostics.hint(
411                        DiagnosticCode::InvalidFieldValue,
412                        format!("Unknown inline option: {}", key),
413                        Range::at_line(line_num),
414                    );
415                }
416            }
417        }
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn test_parse_with_recovery_valid_file() {
427        let content = r#"--- ENDPOINT ---
428service/Method
429
430--- REQUEST ---
431{"key": "value"}
432
433--- RESPONSE ---
434{"result": "ok"}
435"#;
436
437        let result = parse_content_with_recovery(content, "test.gctf");
438
439        assert_eq!(result.recovered_sections, 3);
440        assert_eq!(result.failed_sections, 0);
441        assert!(!result.document.sections.is_empty());
442    }
443
444    #[test]
445    fn test_parse_with_recovery_invalid_json() {
446        let content = r#"--- ENDPOINT ---
447service/Method
448
449--- REQUEST ---
450{"key": "value"
451
452--- RESPONSE ---
453{"result": "ok"}
454"#;
455
456        let result = parse_content_with_recovery(content, "test.gctf");
457
458        // Should recover and continue parsing
459        assert_eq!(result.recovered_sections, 3);
460        assert_eq!(result.failed_sections, 0);
461        // Should have diagnostic for invalid JSON
462        assert!(result.diagnostics.has_errors());
463    }
464
465    #[test]
466    fn test_parse_with_recovery_multiple_errors() {
467        let content = r#"--- ENDPOINT ---
468service/Method
469
470--- REQUEST ---
471{invalid json
472
473--- RESPONSE ---
474{also invalid
475
476--- EXTRACT ---
477var = .field
478"#;
479
480        let result = parse_content_with_recovery(content, "test.gctf");
481
482        // Should recover all sections
483        assert_eq!(result.recovered_sections, 4);
484        // Should have multiple diagnostics
485        assert!(result.diagnostics.diagnostics.len() >= 2);
486    }
487
488    #[test]
489    fn test_parse_with_recovery_unknown_section() {
490        let content = r#"--- ENDPOINT ---
491service/Method
492
493--- UNKNOWN_SECTION ---
494content
495
496--- RESPONSE ---
497{"ok": true}
498"#;
499
500        let result = parse_content_with_recovery(content, "test.gctf");
501
502        // Should skip unknown section
503        assert!(result.diagnostics.has_warnings());
504    }
505
506    #[test]
507    fn test_parse_with_recovery_invalid_extract() {
508        let content = r#"--- EXTRACT ---
509valid = .field
510invalid line without equals
511another = .field2
512"#;
513
514        let result = parse_content_with_recovery(content, "test.gctf");
515
516        // Should parse valid extracts and warn about invalid
517        assert!(result.diagnostics.has_warnings());
518    }
519
520    #[test]
521    fn test_parse_with_recovery_asserts_double_slash_comments() {
522        let content = r#"--- ENDPOINT ---
523grpc.health.v1.Health/Watch
524
525--- REQUEST ---
526{"service": "examples.health.watch"}
527
528--- ASSERTS ---
529// Watch delay in stubs.yaml is 10ms.
530// Delay applies before the first message in the scope.
531@scope.message_count() == 2
532@elapsed_ms() >= 10
533@total_elapsed_ms() >= 10
534"#;
535
536        let result = parse_content_with_recovery(content, "test.gctf");
537        let asserts = result
538            .document
539            .sections
540            .iter()
541            .find(|s| s.section_type == SectionType::Asserts)
542            .expect("ASSERTS section should be parsed");
543
544        if let SectionContent::Assertions(lines) = &asserts.content {
545            assert_eq!(lines.len(), 3);
546            assert_eq!(lines[0], "@scope.message_count() == 2");
547            assert_eq!(lines[1], "@elapsed_ms() >= 10");
548            assert_eq!(lines[2], "@total_elapsed_ms() >= 10");
549        } else {
550            panic!("expected assertions content");
551        }
552    }
553
554    #[test]
555    fn test_parse_with_recovery_asserts_inline_comments() {
556        let content = r#"--- ENDPOINT ---
557grpc.health.v1.Health/Watch
558
559--- REQUEST ---
560{"service": "examples.health.watch"}
561
562--- ASSERTS ---
563@scope.message_count() == 2 // exactly two updates expected
564@elapsed_ms() >= 10 # startup delay should be applied
565@regex(.note, "^https://example.com")
566"#;
567
568        let result = parse_content_with_recovery(content, "test.gctf");
569        let asserts = result
570            .document
571            .sections
572            .iter()
573            .find(|s| s.section_type == SectionType::Asserts)
574            .expect("ASSERTS section should be parsed");
575
576        if let SectionContent::Assertions(lines) = &asserts.content {
577            assert_eq!(lines.len(), 3);
578            assert_eq!(lines[0], "@scope.message_count() == 2");
579            assert_eq!(lines[1], "@elapsed_ms() >= 10");
580            assert_eq!(lines[2], "@regex(.note, \"^https://example.com\")");
581        } else {
582            panic!("expected assertions content");
583        }
584    }
585
586    #[test]
587    fn test_parse_with_recovery_headers_deprecated() {
588        let content = r#"--- ENDPOINT ---
589svc/Method
590
591--- HEADERS ---
592content-type: application/grpc
593
594--- REQUEST ---
595{}
596
597--- RESPONSE ---
598{}
599"#;
600        let result = parse_content_with_recovery(content, "test.gctf");
601        assert!(result.diagnostics.has_warnings());
602        // Should still parse REQUEST and RESPONSE
603        assert_eq!(result.recovered_sections, 4);
604    }
605
606    #[test]
607    fn test_parse_with_recovery_tls_section_key_values() {
608        let content = r#"--- TLS ---
609enabled: true
610cert_path: /path/to/cert
611
612--- ENDPOINT ---
613svc/Method
614
615--- REQUEST ---
616{}
617
618--- RESPONSE ---
619{}
620"#;
621        let result = parse_content_with_recovery(content, "test.gctf");
622        assert_eq!(result.recovered_sections, 4);
623        let tls = result
624            .document
625            .sections
626            .iter()
627            .find(|s| s.section_type == SectionType::Tls)
628            .expect("TLS section should be parsed");
629        if let SectionContent::KeyValues(kvs) = &tls.content {
630            assert_eq!(kvs.get("enabled"), Some(&"true".to_string()));
631            assert_eq!(kvs.get("cert_path"), Some(&"/path/to/cert".to_string()));
632        } else {
633            panic!("expected key-values content");
634        }
635    }
636
637    #[test]
638    fn test_parse_with_recovery_options_section() {
639        let content = r#"--- OPTIONS ---
640timeout: 5000
641retries: 3
642
643--- ENDPOINT ---
644svc/Method
645
646--- REQUEST ---
647{}
648
649--- RESPONSE ---
650{}
651"#;
652        let result = parse_content_with_recovery(content, "test.gctf");
653        assert_eq!(result.recovered_sections, 4);
654        let opts = result
655            .document
656            .sections
657            .iter()
658            .find(|s| s.section_type == SectionType::Options)
659            .expect("OPTIONS section should be parsed");
660        if let SectionContent::KeyValues(kvs) = &opts.content {
661            assert_eq!(kvs.get("timeout"), Some(&"5000".to_string()));
662            assert_eq!(kvs.get("retries"), Some(&"3".to_string()));
663        } else {
664            panic!("expected key-values content");
665        }
666    }
667
668    #[test]
669    fn test_parse_with_recovery_proto_section() {
670        let content = r#"--- PROTO ---
671protos: ["service.proto"]
672import_dirs: ["/protos"]
673
674--- ENDPOINT ---
675svc/Method
676
677--- REQUEST ---
678{}
679
680--- RESPONSE ---
681{}
682"#;
683        let result = parse_content_with_recovery(content, "test.gctf");
684        assert_eq!(result.recovered_sections, 4);
685    }
686
687    #[test]
688    fn test_parse_with_recovery_empty_response() {
689        let content = r#"--- ENDPOINT ---
690svc/Method
691
692--- REQUEST ---
693{}
694
695--- RESPONSE ---
696
697"#;
698        let result = parse_content_with_recovery(content, "test.gctf");
699        assert_eq!(result.recovered_sections, 3);
700        let response = result
701            .document
702            .sections
703            .iter()
704            .find(|s| s.section_type == SectionType::Response)
705            .expect("RESPONSE section should exist");
706        assert!(matches!(response.content, SectionContent::Empty));
707    }
708
709    #[test]
710    fn test_parse_with_recovery_non_section_header_lines() {
711        let content = r#"some random line
712more text
713--- ENDPOINT ---
714svc/Method
715
716--- REQUEST ---
717{}
718
719--- RESPONSE ---
720{}
721"#;
722        let result = parse_content_with_recovery(content, "test.gctf");
723        // Non-section-header lines should be skipped
724        assert_eq!(result.recovered_sections, 3);
725    }
726
727    #[test]
728    fn test_parse_with_recovery_comment_lines() {
729        let content = r#"# This is a comment
730// Another comment
731
732--- ENDPOINT ---
733svc/Method
734
735--- REQUEST ---
736{}
737
738--- RESPONSE ---
739{}
740"#;
741        let result = parse_content_with_recovery(content, "test.gctf");
742        assert_eq!(result.recovered_sections, 3);
743    }
744
745    #[test]
746    fn test_parse_with_recovery_inline_options_invalid_boolean() {
747        let content = r#"--- ENDPOINT with_asserts=maybe ---
748svc/Method
749
750--- REQUEST ---
751{}
752
753--- RESPONSE ---
754{}
755"#;
756        let result = parse_content_with_recovery(content, "test.gctf");
757        assert!(result.diagnostics.has_warnings());
758        assert_eq!(result.recovered_sections, 3);
759    }
760
761    #[test]
762    fn test_parse_with_recovery_inline_options_invalid_numeric() {
763        let content = r#"--- ENDPOINT tolerance=abc ---
764svc/Method
765
766--- REQUEST ---
767{}
768
769--- RESPONSE ---
770{}
771"#;
772        let result = parse_content_with_recovery(content, "test.gctf");
773        assert!(result.diagnostics.has_warnings());
774    }
775
776    #[test]
777    fn test_parse_with_recovery_inline_options_unknown() {
778        let content = r#"--- ENDPOINT unknown_option=value ---
779svc/Method
780
781--- REQUEST ---
782{}
783
784--- RESPONSE ---
785{}
786"#;
787        let result = parse_content_with_recovery(content, "test.gctf");
788        // Unknown options produce hints, not warnings
789        assert_eq!(result.recovered_sections, 3);
790    }
791
792    #[test]
793    fn test_parse_with_recovery_inline_options_valid() {
794        let content = r#"--- ENDPOINT with_asserts=true unordered_arrays=true partial=false tolerance=0.05 ---
795svc/Method
796
797--- REQUEST ---
798{}
799
800--- RESPONSE ---
801{}
802"#;
803        let result = parse_content_with_recovery(content, "test.gctf");
804        assert_eq!(result.recovered_sections, 3);
805    }
806
807    #[test]
808    fn test_parse_with_recovery_request_headers_section() {
809        let content = r#"--- ENDPOINT ---
810svc/Method
811
812--- REQUEST_HEADERS ---
813authorization: Bearer token
814x-custom: value
815
816--- REQUEST ---
817{}
818
819--- RESPONSE ---
820{}
821"#;
822        let result = parse_content_with_recovery(content, "test.gctf");
823        assert_eq!(result.recovered_sections, 4);
824        let headers = result
825            .document
826            .sections
827            .iter()
828            .find(|s| s.section_type == SectionType::RequestHeaders)
829            .expect("REQUEST_HEADERS section should be parsed");
830        if let SectionContent::KeyValues(kvs) = &headers.content {
831            assert_eq!(kvs.get("authorization"), Some(&"Bearer token".to_string()));
832            assert_eq!(kvs.get("x-custom"), Some(&"value".to_string()));
833        } else {
834            panic!("expected key-values content");
835        }
836    }
837
838    #[test]
839    fn test_parse_with_recovery_invalid_key_value_syntax() {
840        let content = r#"--- TLS ---
841enabled: true
842invalid line without colon
843
844--- ENDPOINT ---
845svc/Method
846
847--- REQUEST ---
848{}
849
850--- RESPONSE ---
851{}
852"#;
853        let result = parse_content_with_recovery(content, "test.gctf");
854        assert!(result.diagnostics.has_warnings());
855        assert_eq!(result.recovered_sections, 4);
856    }
857}