Skip to main content

apif_parser/
validator.rs

1// GCTF document validator - validates parsed AST
2// Checks for required sections, conflicts, and data integrity
3
4use anyhow::{Result, bail};
5use apif_ast::*;
6use serde::Serialize;
7
8/// Validation error
9#[derive(Debug, Clone, Serialize)]
10pub struct ValidationError {
11    pub message: String,
12    pub line: Option<usize>,
13    pub severity: ErrorSeverity,
14}
15
16/// Error severity
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
18#[serde(rename_all = "lowercase")]
19pub enum ErrorSeverity {
20    Error,
21    Warning,
22    Info,
23}
24
25// ─── Bench schema constants — format-level validation data ───
26// These live in the parser because they define what's valid in .gctf format.
27// The bench runtime module re-exports them for its own use.
28
29pub const BENCH_NUMERIC_KEYS: &[&str] = &[
30    "concurrency",
31    "requests",
32    "max_rps",
33    "connections",
34    "cpus",
35    "skip_first",
36    "load_start",
37    "load_step",
38    "load_end",
39    "load_midpoint",
40    "load_amplitude",
41    "load_frequency",
42    "load_spike_target",
43    "load_spike_after",
44    "load_spike_duration",
45];
46
47pub const BENCH_DURATION_KEYS: &[&str] = &[
48    "max_duration",
49    "connect_timeout",
50    "keepalive",
51    "ramp_up",
52    "warmup",
53    "duration",
54    "cache_ttl",
55    "cool_down",
56    "load_step_duration",
57    "load_max_duration",
58    "progress_interval",
59];
60
61pub const BENCH_MODE_VALUES: &[&str] = &["fixed", "stepping", "adaptive", "closed", "open"];
62pub const BENCH_LOAD_SCHEDULE_VALUES: &[&str] =
63    &["const", "step", "line", "sine", "spike", "custom"];
64pub const BENCH_DURATION_STOP_VALUES: &[&str] = &["close", "wait", "ignore"];
65pub const BENCH_ASSERT_MODE_VALUES: &[&str] =
66    &["full", "sampled", "off", "fail_fast", "collect_all", "skip"];
67pub const BENCH_CACHE_VALUES: &[&str] = &["on", "off", "refresh", "true", "false", "1", "0"];
68
69pub fn supported_bench_keys() -> Vec<&'static str> {
70    let mut keys = Vec::new();
71    keys.extend_from_slice(BENCH_NUMERIC_KEYS);
72    keys.extend_from_slice(BENCH_DURATION_KEYS);
73    keys.push("no_assert");
74    keys.push("count_errors_in_latency");
75    keys.push("mode");
76    keys.push("profile");
77    keys.push("load_schedule");
78    keys.push("name");
79    keys.push("assert_mode");
80    keys.push("duration_stop");
81    keys.push("sample_rate");
82    keys.push("cache");
83    keys.push("latency_percentiles");
84    keys.push("warmup_mode");
85    keys.push("load_profile");
86    keys.push("sources");
87    keys.push("thresholds.*");
88    keys.sort_unstable();
89    keys.dedup();
90    keys
91}
92
93pub fn is_allowed_value(value: &str, allowed: &[&str]) -> bool {
94    let normalized = value.trim().to_ascii_lowercase();
95    allowed.iter().any(|v| *v == normalized)
96}
97
98pub fn allowed_values_message(allowed: &[&str]) -> String {
99    allowed.join(", ")
100}
101
102pub fn canonical_bench_key(key: &str) -> Option<&'static str> {
103    for canonical in supported_bench_keys() {
104        if canonical == "thresholds.*" {
105            continue;
106        }
107        if key == canonical {
108            return Some(canonical);
109        }
110    }
111    None
112}
113
114pub fn suggest_bench_key(raw_key: &str) -> Option<&'static str> {
115    let needle = raw_key.trim().to_ascii_lowercase().replace('-', "_");
116    if needle.is_empty() || needle == "thresholds" || needle.starts_with("thresholds.") {
117        return None;
118    }
119    let candidates = supported_bench_keys();
120    let mut best: Option<(&'static str, usize)> = None;
121    for key in candidates {
122        if key == "thresholds.*" {
123            continue;
124        }
125        let key_norm = key.to_ascii_lowercase();
126        let Some(score) = bounded_edit_distance(&needle, &key_norm, 3) else {
127            continue;
128        };
129        match best {
130            Some((_, best_score)) if score >= best_score => {}
131            _ => best = Some((key, score)),
132        }
133    }
134    best.map(|(k, _)| k)
135}
136
137fn bounded_edit_distance(a: &str, b: &str, max: usize) -> Option<usize> {
138    let a_bytes = a.as_bytes();
139    let b_bytes = b.as_bytes();
140    if a_bytes == b_bytes {
141        return Some(0);
142    }
143    if a_bytes.len().abs_diff(b_bytes.len()) > max {
144        return None;
145    }
146    let mut prev: Vec<usize> = (0..=b_bytes.len()).collect();
147    let mut curr = vec![0; b_bytes.len() + 1];
148    for (i, &ac) in a_bytes.iter().enumerate() {
149        curr[0] = i + 1;
150        let mut row_min = curr[0];
151        for (j, &bc) in b_bytes.iter().enumerate() {
152            let cost = if ac == bc { 0 } else { 1 };
153            let v = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
154            curr[j + 1] = v;
155            row_min = row_min.min(v);
156        }
157        if row_min > max {
158            return None;
159        }
160        std::mem::swap(&mut prev, &mut curr);
161    }
162    let dist = prev[b_bytes.len()];
163    if dist <= max { Some(dist) } else { None }
164}
165
166/// Validate a parsed GCTF document (returns all errors/warnings without bailing)
167pub fn validate_document_diagnostics(document: &GctfDocument) -> Vec<ValidationError> {
168    let mut errors = Vec::new();
169
170    // Check for required sections
171    validate_required_sections(document, &mut errors);
172
173    // Check for conflicts
174    validate_conflicts(document, &mut errors);
175
176    // Validate content
177    validate_content(document, &mut errors);
178
179    // Validate structure
180    validate_structure(document, &mut errors);
181
182    errors
183}
184
185/// Validate a parsed GCTF document (legacy wrapper that bails on error)
186pub fn validate_document(document: &GctfDocument) -> Result<Vec<ValidationError>> {
187    let errors = validate_document_diagnostics(document);
188    let has_errors = errors.iter().any(|e| e.severity == ErrorSeverity::Error);
189
190    if has_errors {
191        let error_messages: Vec<String> = errors
192            .iter()
193            .filter(|e| e.severity == ErrorSeverity::Error)
194            .map(|e| format!("Line {}: {}", e.line.unwrap_or(0), e.message))
195            .collect();
196
197        bail!("Validation failed:\n{}", error_messages.join("\n"));
198    }
199
200    Ok(errors)
201}
202
203/// Validate required sections
204fn validate_required_sections(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
205    // ENDPOINT is required
206    if document.get_endpoint().is_none() {
207        errors.push(ValidationError {
208            message: "ENDPOINT section is required".to_string(),
209            line: None,
210            severity: ErrorSeverity::Error,
211        });
212    }
213
214    // ADDRESS or environment variable is required
215    // The address might also be provided via CLI args or Config file, which the validator
216    // doesn't have access to here. We should probably relax this check or make it a warning.
217    // Ideally validation happens with full context, but for now let's check env var.
218    // If neither section nor env var is present, we warn instead of error,
219    // because it might be supplied at runtime.
220    let env_addr = std::env::var("GRPCTESTIFY_ADDRESS").ok();
221    if document.get_address(env_addr.as_deref()).is_none() {
222        // Downgrade to warning because address can come from CLI/Config
223        errors.push(ValidationError {
224            message: format!(
225                "ADDRESS section missing (ensure {} is set or passed via --address)",
226                "GRPCTESTIFY_ADDRESS"
227            ),
228            line: None,
229            severity: ErrorSeverity::Warning,
230        });
231    }
232
233    // At least RESPONSE, ERROR or ASSERTS should be present for verification
234    let has_response = document.first_section(SectionType::Response).is_some();
235    let has_error = document.first_section(SectionType::Error).is_some();
236    let has_asserts = document.first_section(SectionType::Asserts).is_some();
237
238    if !has_response && !has_error && !has_asserts {
239        errors.push(ValidationError {
240            message: "At least one verification section (RESPONSE, ERROR, or ASSERTS) is required"
241                .to_string(),
242            line: None,
243            severity: ErrorSeverity::Error,
244        });
245    }
246}
247
248/// Validate conflicts
249fn validate_conflicts(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
250    // RESPONSE and ERROR cannot both be present
251    if document.has_response_error_conflict() {
252        errors.push(ValidationError {
253            message: "Cannot have both RESPONSE and ERROR sections".to_string(),
254            line: None,
255            severity: ErrorSeverity::Error,
256        });
257    }
258}
259
260/// Validate content
261fn validate_content(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
262    // Validate endpoint format
263    if let Some(endpoint) = document.get_endpoint()
264        && !endpoint.contains('/')
265    {
266        errors.push(ValidationError {
267            message: format!(
268                "Invalid endpoint format: {}. Expected format: package.Service/Method",
269                endpoint
270            ),
271            line: document
272                .first_section(SectionType::Endpoint)
273                .map(|s| s.start_line),
274            severity: ErrorSeverity::Error,
275        });
276    }
277
278    // Validate address format
279    if let Some(address) = document.get_address(None)
280        && !address.contains(':')
281    {
282        errors.push(ValidationError {
283            message: format!(
284                "Invalid address format: {}. Expected format: host:port",
285                address
286            ),
287            line: document
288                .first_section(SectionType::Address)
289                .map(|s| s.start_line),
290            severity: ErrorSeverity::Error,
291        });
292    }
293
294    // Validate JSON sections
295    for section_type in [
296        SectionType::Request,
297        SectionType::Response,
298        SectionType::Error,
299    ] {
300        for section in document.sections_by_type(section_type) {
301            match &section.content {
302                SectionContent::Json(json) => {
303                    // Check if JSON is valid object or array
304                    // For ERROR, we also allow strings
305                    let is_valid = if section_type == SectionType::Error {
306                        json.is_object() || json.is_array() || json.is_string()
307                    } else {
308                        json.is_object() || json.is_array()
309                    };
310
311                    if !is_valid {
312                        errors.push(ValidationError {
313                            message: format!(
314                                "{:?} section must contain valid JSON object or array{}",
315                                section_type,
316                                if section_type == SectionType::Error {
317                                    " or string"
318                                } else {
319                                    ""
320                                }
321                            ),
322                            line: Some(section.start_line),
323                            severity: ErrorSeverity::Error,
324                        });
325                    }
326
327                    if section_type == SectionType::Error
328                        && let Some(details) = json.get("details")
329                    {
330                        if !details.is_array() {
331                            errors.push(ValidationError {
332                                message: "ERROR section field 'details' must be an array"
333                                    .to_string(),
334                                line: Some(section.start_line),
335                                severity: ErrorSeverity::Error,
336                            });
337                        } else if let Some(detail_items) = details.as_array() {
338                            for detail in detail_items {
339                                if !detail.is_object() {
340                                    errors.push(ValidationError {
341                                        message: "ERROR section 'details' items must be objects"
342                                            .to_string(),
343                                        line: Some(section.start_line),
344                                        severity: ErrorSeverity::Error,
345                                    });
346                                    break;
347                                }
348
349                                if let Some(type_value) = detail.get("@type")
350                                    && !type_value.is_string()
351                                {
352                                    errors.push(ValidationError {
353                                        message:
354                                            "ERROR.details item field '@type' must be a string"
355                                                .to_string(),
356                                        line: Some(section.start_line),
357                                        severity: ErrorSeverity::Error,
358                                    });
359                                }
360                            }
361                        }
362                    }
363                }
364                SectionContent::JsonLines(values) => {
365                    if section_type != SectionType::Response {
366                        errors.push(ValidationError {
367                            message: format!(
368                                "{:?} section does not support newline-delimited JSON messages",
369                                section_type
370                            ),
371                            line: Some(section.start_line),
372                            severity: ErrorSeverity::Error,
373                        });
374                    } else if values.is_empty() {
375                        errors.push(ValidationError {
376                            message: "RESPONSE section contains no JSON messages".to_string(),
377                            line: Some(section.start_line),
378                            severity: ErrorSeverity::Error,
379                        });
380                    }
381                }
382                _ => {}
383            }
384        }
385    }
386
387    // Validate key-value sections
388    for section_type in [
389        SectionType::RequestHeaders,
390        SectionType::Tls,
391        SectionType::Proto,
392        SectionType::Options,
393        SectionType::Bench,
394    ] {
395        for section in document.sections_by_type(section_type) {
396            if let SectionContent::KeyValues(kv) = &section.content {
397                // Check for empty keys or values
398                for key in kv.keys() {
399                    if key.is_empty() {
400                        errors.push(ValidationError {
401                            message: format!("Empty key in {:?} section", section_type),
402                            line: Some(section.start_line),
403                            severity: ErrorSeverity::Error,
404                        });
405                    }
406                }
407
408                if section_type == SectionType::Options {
409                    let mut parsed_no_retry: Option<bool> = None;
410                    let mut parsed_retry: Option<u32> = None;
411                    for (key, value) in kv {
412                        match key.as_str() {
413                            "timeout" => {
414                                if value.trim().parse::<u64>().ok().is_none_or(|v| v == 0) {
415                                    errors.push(ValidationError {
416                                        message: format!(
417                                            "OPTIONS.timeout must be a positive integer, got '{}'",
418                                            value
419                                        ),
420                                        line: Some(section.start_line),
421                                        severity: ErrorSeverity::Error,
422                                    });
423                                }
424                            }
425                            "no_retry" | "no-retry" => {
426                                let normalized = value.trim().to_ascii_lowercase();
427                                let is_bool = matches!(
428                                    normalized.as_str(),
429                                    "true" | "1" | "yes" | "on" | "false" | "0" | "no" | "off"
430                                );
431                                if !is_bool {
432                                    errors.push(ValidationError {
433                                        message: format!(
434                                            "OPTIONS.{} must be a boolean, got '{}'",
435                                            key, value
436                                        ),
437                                        line: Some(section.start_line),
438                                        severity: ErrorSeverity::Error,
439                                    });
440                                } else {
441                                    parsed_no_retry = Some(matches!(
442                                        normalized.as_str(),
443                                        "true" | "1" | "yes" | "on"
444                                    ));
445                                }
446
447                                if key == "no-retry" {
448                                    errors.push(ValidationError {
449                                        message:
450                                            "OPTIONS.no-retry is deprecated; prefer OPTIONS.no_retry"
451                                                .to_string(),
452                                        line: Some(section.start_line),
453                                        severity: ErrorSeverity::Warning,
454                                    });
455                                }
456                            }
457                            "retry" => {
458                                if value.trim().parse::<u32>().is_err() {
459                                    errors.push(ValidationError {
460                                        message: format!(
461                                            "OPTIONS.retry must be a non-negative integer, got '{}'",
462                                            value
463                                        ),
464                                        line: Some(section.start_line),
465                                        severity: ErrorSeverity::Error,
466                                    });
467                                } else {
468                                    parsed_retry = value.trim().parse::<u32>().ok();
469                                }
470                            }
471                            "retry_delay" | "retry-delay" => {
472                                if value.trim().parse::<f64>().ok().is_none_or(|v| v < 0.0) {
473                                    errors.push(ValidationError {
474                                        message: format!(
475                                            "OPTIONS.retry_delay must be a non-negative number, got '{}'",
476                                            value
477                                        ),
478                                        line: Some(section.start_line),
479                                        severity: ErrorSeverity::Error,
480                                    });
481                                }
482
483                                if key == "retry-delay" {
484                                    errors.push(ValidationError {
485                                        message:
486                                            "OPTIONS.retry-delay is deprecated; prefer OPTIONS.retry_delay"
487                                                .to_string(),
488                                        line: Some(section.start_line),
489                                        severity: ErrorSeverity::Warning,
490                                    });
491                                }
492                            }
493                            "compression" => {
494                                let normalized = value.trim().to_ascii_lowercase();
495                                if !matches!(normalized.as_str(), "none" | "gzip") {
496                                    errors.push(ValidationError {
497                                        message: format!(
498                                            "OPTIONS.compression must be one of: none, gzip (got '{}')",
499                                            value
500                                        ),
501                                        line: Some(section.start_line),
502                                        severity: ErrorSeverity::Error,
503                                    });
504                                }
505                            }
506                            _ => {
507                                errors.push(ValidationError {
508                                    message: format!(
509                                        "Unknown OPTIONS key '{}'. Supported keys: timeout, retry, retry_delay, no_retry, compression",
510                                        key
511                                    ),
512                                    line: Some(section.start_line),
513                                    severity: ErrorSeverity::Warning,
514                                });
515                            }
516                        }
517                    }
518
519                    if parsed_no_retry == Some(true) && parsed_retry.is_some_and(|r| r > 0) {
520                        errors.push(ValidationError {
521                            message:
522                                "OPTIONS.no_retry=true conflicts with OPTIONS.retry>0; retry value will be ignored"
523                                    .to_string(),
524                            line: Some(section.start_line),
525                            severity: ErrorSeverity::Warning,
526                        });
527                    }
528                } else if section_type == SectionType::Bench {
529                    validate_bench_key_values(kv, section.start_line, errors);
530                }
531            }
532        }
533    }
534
535    for section in &document.sections {
536        for attr in &section.attributes {
537            match attr.name.as_str() {
538                "skip" => {
539                    if attr.parse_bool().is_none() {
540                        errors.push(ValidationError {
541                            message: format!(
542                                "Attribute #[skip] must be boolean-compatible, got '{}'",
543                                attr.value
544                            ),
545                            line: Some(section.start_line),
546                            severity: ErrorSeverity::Error,
547                        });
548                    }
549                }
550                "timeout" => {
551                    if attr.parse_u64().is_none_or(|v| v == 0) {
552                        errors.push(ValidationError {
553                            message: format!(
554                                "Attribute #[timeout] must be a positive integer, got '{}'",
555                                attr.value
556                            ),
557                            line: Some(section.start_line),
558                            severity: ErrorSeverity::Error,
559                        });
560                    }
561                }
562                "retry" => {
563                    if attr.parse_u32().is_none() {
564                        errors.push(ValidationError {
565                            message: format!(
566                                "Attribute #[retry] must be a non-negative integer, got '{}'",
567                                attr.value
568                            ),
569                            line: Some(section.start_line),
570                            severity: ErrorSeverity::Error,
571                        });
572                    }
573                }
574                "retry_delay" | "retry-delay" => {
575                    if attr.parse_f64().is_none_or(|v| v < 0.0) {
576                        errors.push(ValidationError {
577                            message: format!(
578                                "Attribute #[{}] must be a non-negative number, got '{}'",
579                                attr.name, attr.value
580                            ),
581                            line: Some(section.start_line),
582                            severity: ErrorSeverity::Error,
583                        });
584                    }
585
586                    if attr.name == "retry-delay" {
587                        errors.push(ValidationError {
588                            message:
589                                "Attribute #[retry-delay] is deprecated; prefer #[retry_delay]"
590                                    .to_string(),
591                            line: Some(section.start_line),
592                            severity: ErrorSeverity::Warning,
593                        });
594                    }
595                }
596                "no_retry" | "no-retry" => {
597                    if attr.parse_bool().is_none() {
598                        errors.push(ValidationError {
599                            message: format!(
600                                "Attribute #[{}] must be boolean-compatible, got '{}'",
601                                attr.name, attr.value
602                            ),
603                            line: Some(section.start_line),
604                            severity: ErrorSeverity::Error,
605                        });
606                    }
607
608                    if attr.name == "no-retry" {
609                        errors.push(ValidationError {
610                            message: "Attribute #[no-retry] is deprecated; prefer #[no_retry]"
611                                .to_string(),
612                            line: Some(section.start_line),
613                            severity: ErrorSeverity::Warning,
614                        });
615                    }
616                }
617                "name" | "tag" | "owner" | "summary" => {}
618                _ => {
619                    errors.push(ValidationError {
620                        message: format!(
621                            "Unknown attribute '#[{}]'. Supported attributes: skip, timeout, retry, retry_delay, no_retry, name, tag, owner, summary",
622                            attr.name
623                        ),
624                        line: Some(section.start_line),
625                        severity: ErrorSeverity::Warning,
626                    });
627                }
628            }
629        }
630
631        let no_retry_attr = section
632            .attributes
633            .iter()
634            .find(|a| a.name == "no_retry" || a.name == "no-retry")
635            .and_then(|a| a.parse_bool());
636        let retry_attr = section
637            .attributes
638            .iter()
639            .find(|a| a.name == "retry")
640            .and_then(|a| a.parse_u32());
641        if no_retry_attr == Some(true) && retry_attr.is_some_and(|r| r > 0) {
642            errors.push(ValidationError {
643                message:
644                    "Attribute conflict: #[no_retry] with #[retry(N>0)] on same section; retry value will be ignored"
645                        .to_string(),
646                line: Some(section.start_line),
647                severity: ErrorSeverity::Warning,
648            });
649        }
650    }
651
652    // Validate assertions
653    for section in document.sections_by_type(SectionType::Asserts) {
654        if let SectionContent::Assertions(assertions) = &section.content {
655            for assertion in assertions {
656                if assertion.is_empty() {
657                    errors.push(ValidationError {
658                        message: "Empty assertion found".to_string(),
659                        line: Some(section.start_line),
660                        severity: ErrorSeverity::Warning,
661                    });
662                }
663            }
664        }
665    }
666}
667
668fn validate_bench_key_values(
669    kv: &std::collections::HashMap<String, String>,
670    start_line: usize,
671    errors: &mut Vec<ValidationError>,
672) {
673    let supported_keys_message = bench_supported_keys_message();
674    for (key, value) in kv {
675        let key_norm = canonical_bench_key(key.as_str()).unwrap_or(key.as_str());
676        match key_norm {
677            "mode" => {
678                if !is_allowed_value(value, BENCH_MODE_VALUES) {
679                    errors.push(ValidationError {
680                        message: format!(
681                            "BENCH.mode must be one of: {} (got '{}')",
682                            allowed_values_message(BENCH_MODE_VALUES),
683                            value
684                        ),
685                        line: Some(start_line),
686                        severity: ErrorSeverity::Error,
687                    });
688                }
689            }
690            "load_schedule" => {
691                if !is_allowed_value(value, BENCH_LOAD_SCHEDULE_VALUES) {
692                    errors.push(ValidationError {
693                        message: format!(
694                            "BENCH.load_schedule must be one of: {} (got '{}')",
695                            allowed_values_message(BENCH_LOAD_SCHEDULE_VALUES),
696                            value
697                        ),
698                        line: Some(start_line),
699                        severity: ErrorSeverity::Error,
700                    });
701                }
702            }
703            k if BENCH_NUMERIC_KEYS.contains(&k) => {
704                if value.trim().parse::<u64>().is_err() {
705                    errors.push(ValidationError {
706                        message: format!(
707                            "BENCH.{} must be a non-negative integer, got '{}'",
708                            key, value
709                        ),
710                        line: Some(start_line),
711                        severity: ErrorSeverity::Error,
712                    });
713                }
714            }
715            k if BENCH_DURATION_KEYS.contains(&k) => {
716                validate_bench_duration(key, value, start_line, errors);
717            }
718            "no_assert" | "count_errors_in_latency" => {
719                let normalized = value.trim().to_ascii_lowercase();
720                if !matches!(normalized.as_str(), "true" | "false" | "1" | "0") {
721                    errors.push(ValidationError {
722                        message: format!(
723                            "BENCH.{} must be a boolean (true/false/1/0), got '{}'",
724                            key, value
725                        ),
726                        line: Some(start_line),
727                        severity: ErrorSeverity::Error,
728                    });
729                }
730            }
731            "duration_stop" => {
732                if !is_allowed_value(value, BENCH_DURATION_STOP_VALUES) {
733                    errors.push(ValidationError {
734                        message: format!(
735                            "BENCH.duration_stop must be one of: {} (got '{}')",
736                            allowed_values_message(BENCH_DURATION_STOP_VALUES),
737                            value
738                        ),
739                        line: Some(start_line),
740                        severity: ErrorSeverity::Error,
741                    });
742                }
743            }
744            "latency_percentiles" => {
745                validate_latency_percentiles(value, start_line, errors);
746            }
747            "sample_rate" => {
748                if value
749                    .trim()
750                    .parse::<f64>()
751                    .ok()
752                    .is_none_or(|v| !(0.0..=1.0).contains(&v))
753                {
754                    errors.push(ValidationError {
755                        message: format!("BENCH.sample_rate must be in [0,1], got '{}'", value),
756                        line: Some(start_line),
757                        severity: ErrorSeverity::Error,
758                    });
759                }
760            }
761            "assert_mode" => {
762                if !is_allowed_value(value, BENCH_ASSERT_MODE_VALUES) {
763                    errors.push(ValidationError {
764                        message: format!(
765                            "BENCH.assert_mode must be one of: {} (got '{}')",
766                            allowed_values_message(BENCH_ASSERT_MODE_VALUES),
767                            value
768                        ),
769                        line: Some(start_line),
770                        severity: ErrorSeverity::Error,
771                    });
772                }
773            }
774            "cache" => {
775                if !is_allowed_value(value, BENCH_CACHE_VALUES) {
776                    errors.push(ValidationError {
777                        message: format!(
778                            "BENCH.cache must be one of: {} (got '{}')",
779                            allowed_values_message(BENCH_CACHE_VALUES),
780                            value
781                        ),
782                        line: Some(start_line),
783                        severity: ErrorSeverity::Error,
784                    });
785                }
786            }
787            "warmup_mode" => {
788                let normalized = value.trim().to_ascii_lowercase();
789                if normalized != "warmup" && normalized != "dry_run" {
790                    errors.push(ValidationError {
791                        message: format!(
792                            "BENCH.warmup_mode must be 'warmup' or 'dry_run', got '{}'",
793                            value
794                        ),
795                        line: Some(start_line),
796                        severity: ErrorSeverity::Error,
797                    });
798                }
799            }
800            _ => {
801                if key == "thresholds" || key.starts_with("thresholds.") {
802                    validate_bench_threshold_key(key, value, start_line, errors);
803                } else {
804                    let hint = canonical_bench_key(key)
805                        .filter(|canonical| *canonical != key)
806                        .map(|canonical| format!(" Hint: use canonical key '{}'.", canonical))
807                        .or_else(|| {
808                            suggest_bench_key(key)
809                                .map(|suggested| format!(" Hint: did you mean '{}' ?", suggested))
810                        })
811                        .unwrap_or_default();
812                    errors.push(ValidationError {
813                        message: format!(
814                            "Unknown BENCH key '{}'. Supported keys: {}{}",
815                            key, supported_keys_message, hint
816                        ),
817                        line: Some(start_line),
818                        severity: ErrorSeverity::Warning,
819                    });
820                }
821            }
822        }
823    }
824}
825
826fn bench_supported_keys_message() -> String {
827    supported_bench_keys().join(", ")
828}
829
830fn validate_bench_duration(
831    key: &str,
832    value: &str,
833    start_line: usize,
834    errors: &mut Vec<ValidationError>,
835) {
836    let trimmed = value.trim();
837    if trimmed.is_empty() {
838        errors.push(ValidationError {
839            message: format!("BENCH.{} must not be empty", key),
840            line: Some(start_line),
841            severity: ErrorSeverity::Error,
842        });
843        return;
844    }
845    let unit = trimmed
846        .strip_suffix("ms")
847        .or_else(|| trimmed.strip_suffix("s"))
848        .or_else(|| trimmed.strip_suffix("m"))
849        .or_else(|| trimmed.strip_suffix("h"))
850        .unwrap_or(trimmed);
851    if unit.parse::<f64>().is_err() {
852        errors.push(ValidationError {
853            message: format!(
854                "BENCH.{} has invalid duration format '{}'; expected e.g. 30s, 5m, 1h, 500ms",
855                key, value
856            ),
857            line: Some(start_line),
858            severity: ErrorSeverity::Error,
859        });
860    }
861}
862
863fn validate_latency_percentiles(value: &str, start_line: usize, errors: &mut Vec<ValidationError>) {
864    for token in value.split(',') {
865        let t = token.trim();
866        if t.is_empty() {
867            continue;
868        }
869        if !t.starts_with('p') {
870            errors.push(ValidationError {
871                message: format!(
872                    "Invalid percentile '{}' in latency_percentiles; expected p50, p90, p95, p99, p99.9, etc.",
873                    t
874                ),
875                line: Some(start_line),
876                severity: ErrorSeverity::Error,
877            });
878            continue;
879        }
880        let num_str = t[1..].trim();
881        if num_str.parse::<f64>().is_err() {
882            errors.push(ValidationError {
883                message: format!(
884                    "Invalid percentile value in '{}'; expected number after 'p'",
885                    t
886                ),
887                line: Some(start_line),
888                severity: ErrorSeverity::Error,
889            });
890        }
891    }
892}
893
894fn validate_bench_threshold_key(
895    key: &str,
896    value: &str,
897    start_line: usize,
898    errors: &mut Vec<ValidationError>,
899) {
900    if !is_valid_threshold_expr(value) {
901        errors.push(ValidationError {
902            message: format!(
903                "BENCH threshold '{}' has invalid expression '{}'; expected one of: <N, <=N, >N, >=N",
904                key, value
905            ),
906            line: Some(start_line),
907            severity: ErrorSeverity::Error,
908        });
909    }
910
911    if let Some(inner) = key.strip_prefix("thresholds.") {
912        validate_percentile_metric_key(inner, start_line, errors);
913    }
914}
915
916fn validate_percentile_metric_key(key: &str, start_line: usize, errors: &mut Vec<ValidationError>) {
917    let p_metric = if key.starts_with("latency_ms.p(") {
918        key.strip_prefix("latency_ms.p(")
919    } else if key.starts_with("p(") {
920        key.strip_prefix("p(")
921    } else {
922        None
923    };
924
925    let Some(rest) = p_metric else {
926        return;
927    };
928
929    let Some(percentile_str) = rest.strip_suffix(')') else {
930        errors.push(ValidationError {
931            message: format!(
932                "Invalid percentile key '{}'; expected syntax p(<value>) or latency_ms.p(<value>)",
933                key
934            ),
935            line: Some(start_line),
936            severity: ErrorSeverity::Error,
937        });
938        return;
939    };
940
941    let Ok(percentile) = percentile_str.parse::<f64>() else {
942        errors.push(ValidationError {
943            message: format!(
944                "Invalid percentile value in key '{}'; expected numeric value",
945                key
946            ),
947            line: Some(start_line),
948            severity: ErrorSeverity::Error,
949        });
950        return;
951    };
952
953    if !(percentile > 0.0 && percentile < 100.0) {
954        errors.push(ValidationError {
955            message: format!("Percentile in key '{}' must be in range (0,100)", key),
956            line: Some(start_line),
957            severity: ErrorSeverity::Error,
958        });
959    }
960}
961
962fn is_valid_threshold_expr(raw: &str) -> bool {
963    let value = raw.trim();
964    let (op, rhs) = if let Some(rest) = value.strip_prefix("<=") {
965        ("<=", rest)
966    } else if let Some(rest) = value.strip_prefix(">=") {
967        (">=", rest)
968    } else if let Some(rest) = value.strip_prefix('<') {
969        ("<", rest)
970    } else if let Some(rest) = value.strip_prefix('>') {
971        (">", rest)
972    } else {
973        return false;
974    };
975
976    let _ = op;
977    rhs.trim().parse::<f64>().is_ok()
978}
979
980/// Validate structure
981fn validate_structure(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
982    // Check for duplicate non-multiple sections
983    let mut seen_sections = std::collections::HashSet::new();
984    let mut meta_count = 0;
985    let mut meta_first_line = None;
986    let mut bench_count = 0;
987    let mut bench_first_line = None;
988
989    for section in &document.sections {
990        if section.section_type == SectionType::Meta {
991            meta_count += 1;
992            if meta_first_line.is_none() {
993                meta_first_line = Some(section.start_line);
994            }
995        }
996        if section.section_type == SectionType::Bench {
997            bench_count += 1;
998            if bench_first_line.is_none() {
999                bench_first_line = Some(section.start_line);
1000            }
1001        }
1002
1003        if !section.section_type.is_multiple_allowed() {
1004            if seen_sections.contains(&section.section_type) {
1005                errors.push(ValidationError {
1006                    message: format!("Duplicate {:?} section found", section.section_type),
1007                    line: Some(section.start_line),
1008                    severity: ErrorSeverity::Error,
1009                });
1010            }
1011            seen_sections.insert(section.section_type);
1012        }
1013    }
1014
1015    // Validate META section: only 0 or 1 per file, must be first if present
1016    if meta_count > 1 {
1017        errors.push(ValidationError {
1018            message: "Only one META section is allowed per file".to_string(),
1019            line: meta_first_line,
1020            severity: ErrorSeverity::Error,
1021        });
1022    }
1023
1024    // Check META is first section (if present)
1025    if meta_count == 1
1026        && let Some(first_section) = document.sections.first()
1027        && first_section.section_type != SectionType::Meta
1028    {
1029        errors.push(ValidationError {
1030            message: "META section must be the first section in the file".to_string(),
1031            line: meta_first_line,
1032            severity: ErrorSeverity::Error,
1033        });
1034    }
1035
1036    // Validate BENCH section: only 0 or 1 per file, should be file-level near top
1037    if bench_count > 1 {
1038        errors.push(ValidationError {
1039            message: "Only one BENCH section is allowed per file".to_string(),
1040            line: bench_first_line,
1041            severity: ErrorSeverity::Error,
1042        });
1043    }
1044
1045    if bench_count == 1
1046        && let Some(bench_idx) = document
1047            .sections
1048            .iter()
1049            .position(|s| s.section_type == SectionType::Bench)
1050    {
1051        let bench_is_valid_position = match bench_idx {
1052            0 => true,
1053            1 => document
1054                .sections
1055                .first()
1056                .is_some_and(|s| s.section_type == SectionType::Meta),
1057            _ => false,
1058        };
1059
1060        if !bench_is_valid_position {
1061            errors.push(ValidationError {
1062                message: "BENCH section must be first, or immediately after META".to_string(),
1063                line: bench_first_line,
1064                severity: ErrorSeverity::Warning,
1065            });
1066        }
1067    }
1068
1069    // Validate section order (optional, but good for readability)
1070    validate_section_order(document, errors);
1071
1072    // Validate BENCH data source files exist
1073    validate_bench_sources_exist(document, errors);
1074
1075    // Validate inline options are only on supported sections
1076    for section in &document.sections {
1077        let has_any_inline_options = section.inline_options.with_asserts
1078            || section.inline_options.partial
1079            || section.inline_options.tolerance.is_some()
1080            || !section.inline_options.redact.is_empty()
1081            || section.inline_options.unordered_arrays;
1082
1083        if !has_any_inline_options {
1084            continue;
1085        }
1086
1087        match section.section_type {
1088            SectionType::Response => {
1089                // All known options are supported for RESPONSE section
1090            }
1091            SectionType::Error => {
1092                if section.inline_options.tolerance.is_some()
1093                    || !section.inline_options.redact.is_empty()
1094                    || section.inline_options.unordered_arrays
1095                {
1096                    errors.push(ValidationError {
1097                        message:
1098                            "ERROR section only supports partial and with_asserts inline options"
1099                                .to_string(),
1100                        line: Some(section.start_line),
1101                        severity: ErrorSeverity::Warning,
1102                    });
1103                }
1104            }
1105            _ => {
1106                errors.push(ValidationError {
1107                    message: format!(
1108                        "Inline options are not supported for {:?} section",
1109                        section.section_type
1110                    ),
1111                    line: Some(section.start_line),
1112                    severity: ErrorSeverity::Warning,
1113                });
1114            }
1115        }
1116    }
1117
1118    // Warn about redundant empty ERROR with with_asserts
1119    for (i, section) in document.sections.iter().enumerate() {
1120        if section.section_type == SectionType::Error
1121            && section.inline_options.with_asserts
1122            && matches!(section.content, SectionContent::Empty)
1123            && document
1124                .sections
1125                .get(i + 1)
1126                .is_some_and(|next| next.section_type == SectionType::Asserts)
1127        {
1128            errors.push(ValidationError {
1129                message:
1130                    "Empty ERROR with with_asserts is redundant; remove ERROR and keep ASSERTS"
1131                        .to_string(),
1132                line: Some(section.start_line),
1133                severity: ErrorSeverity::Warning,
1134            });
1135        }
1136    }
1137}
1138
1139/// Check if validation passed (no errors)
1140pub fn validation_passed(errors: &[ValidationError]) -> bool {
1141    !errors.iter().any(|e| e.severity == ErrorSeverity::Error)
1142}
1143
1144/// Validate that sections appear in a sensible order.
1145/// Not exhaustive — catches obvious issues like RESPONSE before REQUEST.
1146fn validate_section_order(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
1147    use SectionType::*;
1148    let mut seen: Vec<SectionType> = Vec::new();
1149
1150    for section in &document.sections {
1151        let st = &section.section_type;
1152        // Check that RESPONSE/ERROR don't appear before REQUEST
1153        if matches!(st, Response | Error | Asserts) && !seen.contains(&Request) {
1154            errors.push(ValidationError {
1155                message: format!(
1156                    "{:?} section at line {} appears before any REQUEST section",
1157                    st, section.start_line
1158                ),
1159                line: Some(section.start_line),
1160                severity: ErrorSeverity::Warning,
1161            });
1162        }
1163        // Check that EXTRACT doesn't appear before RESPONSE/ERROR
1164        if matches!(st, Extract)
1165            && !seen.contains(&Response)
1166            && !seen.contains(&Error)
1167            && !seen.contains(&Asserts)
1168        {
1169            errors.push(ValidationError {
1170                message: format!(
1171                    "EXTRACT section at line {} appears before RESPONSE, ERROR, or ASSERTS",
1172                    section.start_line
1173                ),
1174                line: Some(section.start_line),
1175                severity: ErrorSeverity::Warning,
1176            });
1177        }
1178        seen.push(*st);
1179    }
1180}
1181
1182/// DFS-based cycle detection in dimension join graph.
1183fn has_cycle<'a>(
1184    adj: &std::collections::BTreeMap<&'a str, Vec<&'a str>>,
1185    node: &'a str,
1186    visited: &mut std::collections::BTreeSet<&'a str>,
1187    stack: &mut std::collections::BTreeSet<&'a str>,
1188) -> bool {
1189    if stack.contains(node) {
1190        return true;
1191    }
1192    if visited.contains(node) {
1193        return false;
1194    }
1195    visited.insert(node);
1196    stack.insert(node);
1197    if let Some(neighbors) = adj.get(node) {
1198        for n in neighbors {
1199            if has_cycle(adj, n, visited, stack) {
1200                return true;
1201            }
1202        }
1203    }
1204    stack.remove(node);
1205    false
1206}
1207
1208/// Validate that BENCH data source files referenced in the BENCH section exist.
1209/// Minimal source config for YAML deserialization in BENCH section validation.
1210/// Only fields needed for validation: file existence check + cycle detection.
1211#[derive(serde::Deserialize)]
1212struct SourceConfig {
1213    file: String,
1214    #[serde(default)]
1215    name: Option<String>,
1216    #[serde(default)]
1217    indexed_by: Option<Vec<String>>,
1218}
1219
1220fn validate_bench_sources_exist(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
1221    for section in &document.sections {
1222        if section.section_type != SectionType::Bench {
1223            continue;
1224        }
1225        let bench_content = match &section.content {
1226            SectionContent::KeyValues(kv) => kv,
1227            _ => continue,
1228        };
1229        let Some(sources_yaml) = bench_content.get("sources") else {
1230            continue;
1231        };
1232        match serde_yaml_ng::from_str::<Vec<SourceConfig>>(sources_yaml) {
1233            Ok(ref defs) => {
1234                // Check file existence
1235                for def in defs {
1236                    let resolved = std::path::Path::new(&document.file_path)
1237                        .parent()
1238                        .unwrap_or(std::path::Path::new("."))
1239                        .join(&def.file);
1240                    if !resolved.exists() {
1241                        errors.push(ValidationError {
1242                            message: format!(
1243                                "BENCH data source file not found: {} (resolved: {})",
1244                                def.file,
1245                                resolved.display()
1246                            ),
1247                            line: Some(section.start_line),
1248                            severity: ErrorSeverity::Warning,
1249                        });
1250                    }
1251                }
1252
1253                // Detect circular dimension joins
1254                if defs.len() > 1 {
1255                    let mut adj: std::collections::BTreeMap<&str, Vec<&str>> =
1256                        std::collections::BTreeMap::new();
1257                    for def in defs {
1258                        let name = def.name.as_deref().unwrap_or("primary");
1259                        if let Some(idx) = &def.indexed_by {
1260                            let cols: Vec<&str> = idx.iter().map(|s| s.as_str()).collect();
1261                            for col in cols {
1262                                // Check if col looks like a reference to another source
1263                                if let Some(target) = col.strip_prefix('@') {
1264                                    adj.entry(name).or_default().push(target);
1265                                }
1266                            }
1267                        }
1268                    }
1269                    // Check for cycles using DFS
1270                    let mut visited: std::collections::BTreeSet<&str> =
1271                        std::collections::BTreeSet::new();
1272                    let mut stack: std::collections::BTreeSet<&str> =
1273                        std::collections::BTreeSet::new();
1274                    for node in adj.keys().copied().collect::<Vec<_>>() {
1275                        if has_cycle(&adj, node, &mut visited, &mut stack) {
1276                            errors.push(ValidationError {
1277                                message: format!(
1278                                    "Circular dimension join detected involving source '{}'",
1279                                    node
1280                                ),
1281                                line: Some(section.start_line),
1282                                severity: ErrorSeverity::Error,
1283                            });
1284                        }
1285                    }
1286                }
1287            }
1288            Err(_) => {
1289                // Sources YAML parse error — already caught by YAML parser
1290            }
1291        }
1292    }
1293}
1294
1295#[cfg(test)]
1296mod tests {
1297    use super::*;
1298
1299    fn create_test_document() -> GctfDocument {
1300        let mut doc = GctfDocument::new("test.gctf".to_string());
1301
1302        doc.sections = vec![
1303            Section {
1304                section_type: SectionType::Address,
1305                content: SectionContent::Single("localhost:4770".to_string()),
1306                inline_options: InlineOptions::default(),
1307                raw_content: "localhost:4770".to_string(),
1308                start_line: 1,
1309                end_line: 1,
1310                attributes: Vec::new(),
1311            },
1312            Section {
1313                section_type: SectionType::Endpoint,
1314                content: SectionContent::Single("my.Service/Method".to_string()),
1315                inline_options: InlineOptions::default(),
1316                raw_content: "my.Service/Method".to_string(),
1317                start_line: 3,
1318                end_line: 3,
1319                attributes: Vec::new(),
1320            },
1321        ];
1322
1323        doc
1324    }
1325
1326    #[test]
1327    fn test_validate_required_sections_pass() {
1328        let doc = create_test_document();
1329        let result = validate_document(&doc);
1330        // Should fail because no REQUEST or ASSERTS
1331        assert!(result.is_err());
1332    }
1333
1334    #[test]
1335    fn test_validate_endpoint_format() {
1336        let mut doc = create_test_document();
1337        doc.sections[1].content = SectionContent::Single("invalid_endpoint".to_string());
1338
1339        let result = validate_document(&doc);
1340        assert!(result.is_err());
1341    }
1342
1343    #[test]
1344    fn test_validate_address_format() {
1345        let mut doc = create_test_document();
1346        doc.sections[0].content = SectionContent::Single("invalid_address".to_string());
1347
1348        let result = validate_document(&doc);
1349        assert!(result.is_err());
1350    }
1351
1352    #[test]
1353    fn test_validation_passed() {
1354        let errors = vec![
1355            ValidationError {
1356                message: "Warning".to_string(),
1357                line: Some(1),
1358                severity: ErrorSeverity::Warning,
1359            },
1360            ValidationError {
1361                message: "Info".to_string(),
1362                line: Some(2),
1363                severity: ErrorSeverity::Info,
1364            },
1365        ];
1366
1367        assert!(validation_passed(&errors));
1368    }
1369
1370    #[test]
1371    fn test_validation_failed() {
1372        let errors = vec![
1373            ValidationError {
1374                message: "Warning".to_string(),
1375                line: Some(1),
1376                severity: ErrorSeverity::Warning,
1377            },
1378            ValidationError {
1379                message: "Error".to_string(),
1380                line: Some(2),
1381                severity: ErrorSeverity::Error,
1382            },
1383        ];
1384
1385        assert!(!validation_passed(&errors));
1386    }
1387
1388    #[test]
1389    fn test_validate_document_diagnostics() {
1390        let doc = create_test_document();
1391        let errors = validate_document_diagnostics(&doc);
1392        // Should have some errors or warnings
1393        assert!(!errors.is_empty());
1394    }
1395
1396    #[test]
1397    fn test_validate_document_with_response() {
1398        let mut doc = create_test_document();
1399        doc.sections.push(Section {
1400            section_type: SectionType::Response,
1401            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1402            inline_options: InlineOptions::default(),
1403            raw_content: "{\"result\": \"ok\"}".to_string(),
1404            start_line: 5,
1405            end_line: 6,
1406            attributes: Vec::new(),
1407        });
1408
1409        let result = validate_document(&doc);
1410        // Should pass with ADDRESS, ENDPOINT, and RESPONSE
1411        assert!(result.is_ok());
1412    }
1413
1414    #[test]
1415    fn test_validate_document_with_error_section() {
1416        let mut doc = create_test_document();
1417        doc.sections.push(Section {
1418            section_type: SectionType::Error,
1419            content: SectionContent::Json(serde_json::json!({"code": 5})),
1420            inline_options: InlineOptions::default(),
1421            raw_content: "{\"code\": 5}".to_string(),
1422            start_line: 5,
1423            end_line: 6,
1424            attributes: Vec::new(),
1425        });
1426
1427        let result = validate_document(&doc);
1428        // Should pass with ADDRESS, ENDPOINT, and ERROR
1429        assert!(result.is_ok());
1430    }
1431
1432    #[test]
1433    fn test_validate_document_error_partial_option_allowed() {
1434        let mut doc = create_test_document();
1435        doc.sections.push(Section {
1436            section_type: SectionType::Error,
1437            content: SectionContent::Json(serde_json::json!({"code": 5})),
1438            inline_options: InlineOptions {
1439                partial: true,
1440                ..InlineOptions::default()
1441            },
1442            raw_content: "{\"code\": 5}".to_string(),
1443            start_line: 5,
1444            end_line: 6,
1445            attributes: Vec::new(),
1446        });
1447
1448        let errors = validate_document_diagnostics(&doc);
1449        assert!(!errors.iter().any(|e| {
1450            e.message
1451                .contains("ERROR section only supports partial and with_asserts")
1452        }));
1453    }
1454
1455    #[test]
1456    fn test_validate_document_error_tolerance_still_warns() {
1457        let mut doc = create_test_document();
1458        doc.sections.push(Section {
1459            section_type: SectionType::Error,
1460            content: SectionContent::Json(serde_json::json!({"code": 5})),
1461            inline_options: InlineOptions {
1462                tolerance: Some(0.1),
1463                ..InlineOptions::default()
1464            },
1465            raw_content: "{\"code\": 5}".to_string(),
1466            start_line: 5,
1467            end_line: 6,
1468            attributes: Vec::new(),
1469        });
1470
1471        let errors = validate_document_diagnostics(&doc);
1472        assert!(errors.iter().any(|e| {
1473            e.message
1474                .contains("ERROR section only supports partial and with_asserts")
1475        }));
1476    }
1477
1478    #[test]
1479    fn test_validate_document_warns_on_empty_error_with_asserts() {
1480        let mut doc = create_test_document();
1481        doc.sections.push(Section {
1482            section_type: SectionType::Error,
1483            content: SectionContent::Empty,
1484            inline_options: InlineOptions {
1485                with_asserts: true,
1486                ..InlineOptions::default()
1487            },
1488            raw_content: "".to_string(),
1489            start_line: 5,
1490            end_line: 5,
1491            attributes: Vec::new(),
1492        });
1493        doc.sections.push(Section {
1494            section_type: SectionType::Asserts,
1495            content: SectionContent::Assertions(vec![".code == 5".to_string()]),
1496            inline_options: InlineOptions::default(),
1497            raw_content: ".code == 5".to_string(),
1498            start_line: 6,
1499            end_line: 6,
1500            attributes: Vec::new(),
1501        });
1502
1503        let errors = validate_document_diagnostics(&doc);
1504        assert!(errors.iter().any(|e| {
1505            e.message
1506                .contains("Empty ERROR with with_asserts is redundant")
1507                && e.severity == ErrorSeverity::Warning
1508        }));
1509    }
1510
1511    #[test]
1512    fn test_validate_document_no_warning_for_non_empty_error_with_asserts() {
1513        let mut doc = create_test_document();
1514        doc.sections.push(Section {
1515            section_type: SectionType::Error,
1516            content: SectionContent::Json(serde_json::json!({"code": 5})),
1517            inline_options: InlineOptions {
1518                with_asserts: true,
1519                ..InlineOptions::default()
1520            },
1521            raw_content: "{\"code\": 5}".to_string(),
1522            start_line: 5,
1523            end_line: 6,
1524            attributes: Vec::new(),
1525        });
1526        doc.sections.push(Section {
1527            section_type: SectionType::Asserts,
1528            content: SectionContent::Assertions(vec![".code == 5".to_string()]),
1529            inline_options: InlineOptions::default(),
1530            raw_content: ".code == 5".to_string(),
1531            start_line: 7,
1532            end_line: 7,
1533            attributes: Vec::new(),
1534        });
1535
1536        let errors = validate_document_diagnostics(&doc);
1537        assert!(!errors.iter().any(|e| {
1538            e.message
1539                .contains("Empty ERROR with with_asserts is redundant")
1540        }));
1541    }
1542
1543    #[test]
1544    fn test_validate_document_no_warning_for_empty_error_without_with_asserts() {
1545        let mut doc = create_test_document();
1546        doc.sections.push(Section {
1547            section_type: SectionType::Error,
1548            content: SectionContent::Empty,
1549            inline_options: InlineOptions::default(),
1550            raw_content: "".to_string(),
1551            start_line: 5,
1552            end_line: 5,
1553            attributes: Vec::new(),
1554        });
1555        doc.sections.push(Section {
1556            section_type: SectionType::Asserts,
1557            content: SectionContent::Assertions(vec![".code == 5".to_string()]),
1558            inline_options: InlineOptions::default(),
1559            raw_content: ".code == 5".to_string(),
1560            start_line: 6,
1561            end_line: 6,
1562            attributes: Vec::new(),
1563        });
1564
1565        let errors = validate_document_diagnostics(&doc);
1566        assert!(!errors.iter().any(|e| {
1567            e.message
1568                .contains("Empty ERROR with with_asserts is redundant")
1569        }));
1570    }
1571
1572    #[test]
1573    fn test_validate_document_no_warning_for_empty_error_with_non_adjacent_asserts() {
1574        let mut doc = create_test_document();
1575        doc.sections.push(Section {
1576            section_type: SectionType::Error,
1577            content: SectionContent::Empty,
1578            inline_options: InlineOptions {
1579                with_asserts: true,
1580                ..InlineOptions::default()
1581            },
1582            raw_content: "".to_string(),
1583            start_line: 5,
1584            end_line: 5,
1585            attributes: Vec::new(),
1586        });
1587        doc.sections.push(Section {
1588            section_type: SectionType::Request,
1589            content: SectionContent::Json(serde_json::json!({"id": 1})),
1590            inline_options: InlineOptions::default(),
1591            raw_content: "{\"id\": 1}".to_string(),
1592            start_line: 6,
1593            end_line: 7,
1594            attributes: Vec::new(),
1595        });
1596        doc.sections.push(Section {
1597            section_type: SectionType::Asserts,
1598            content: SectionContent::Assertions(vec![".code == 5".to_string()]),
1599            inline_options: InlineOptions::default(),
1600            raw_content: ".code == 5".to_string(),
1601            start_line: 8,
1602            end_line: 8,
1603            attributes: Vec::new(),
1604        });
1605
1606        let errors = validate_document_diagnostics(&doc);
1607        assert!(!errors.iter().any(|e| {
1608            e.message
1609                .contains("Empty ERROR with with_asserts is redundant")
1610        }));
1611    }
1612
1613    #[test]
1614    fn test_validate_document_with_asserts() {
1615        let mut doc = create_test_document();
1616        doc.sections.push(Section {
1617            section_type: SectionType::Asserts,
1618            content: SectionContent::Assertions(vec![".id == 1".to_string()]),
1619            inline_options: InlineOptions::default(),
1620            raw_content: ".id == 1".to_string(),
1621            start_line: 5,
1622            end_line: 5,
1623            attributes: Vec::new(),
1624        });
1625
1626        let result = validate_document(&doc);
1627        // Should pass with ADDRESS, ENDPOINT, and ASSERTS
1628        assert!(result.is_ok());
1629    }
1630
1631    #[test]
1632    fn test_validate_document_missing_endpoint() {
1633        let mut doc = create_test_document();
1634        doc.sections.remove(1); // Remove ENDPOINT
1635
1636        let errors = validate_document_diagnostics(&doc);
1637        let has_endpoint_error = errors.iter().any(|e| e.message.contains("ENDPOINT"));
1638        assert!(has_endpoint_error);
1639    }
1640
1641    #[test]
1642    fn test_validate_document_response_error_conflict() {
1643        let mut doc = create_test_document();
1644        doc.sections.push(Section {
1645            section_type: SectionType::Response,
1646            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1647            inline_options: InlineOptions::default(),
1648            raw_content: "{\"result\": \"ok\"}".to_string(),
1649            start_line: 5,
1650            end_line: 6,
1651            attributes: Vec::new(),
1652        });
1653        doc.sections.push(Section {
1654            section_type: SectionType::Error,
1655            content: SectionContent::Json(serde_json::json!({"code": 5})),
1656            inline_options: InlineOptions::default(),
1657            raw_content: "{\"code\": 5}".to_string(),
1658            start_line: 7,
1659            end_line: 8,
1660            attributes: Vec::new(),
1661        });
1662
1663        let errors = validate_document_diagnostics(&doc);
1664        let has_conflict_error = errors
1665            .iter()
1666            .any(|e| e.message.contains("RESPONSE") && e.message.contains("ERROR"));
1667        assert!(has_conflict_error);
1668    }
1669
1670    #[test]
1671    fn test_validate_document_empty_requests() {
1672        let mut doc = create_test_document();
1673        doc.sections.push(Section {
1674            section_type: SectionType::Request,
1675            content: SectionContent::Empty,
1676            inline_options: InlineOptions::default(),
1677            raw_content: "".to_string(),
1678            start_line: 5,
1679            end_line: 5,
1680            attributes: Vec::new(),
1681        });
1682        doc.sections.push(Section {
1683            section_type: SectionType::Response,
1684            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1685            inline_options: InlineOptions::default(),
1686            raw_content: "{\"result\": \"ok\"}".to_string(),
1687            start_line: 6,
1688            end_line: 7,
1689            attributes: Vec::new(),
1690        });
1691
1692        let result = validate_document(&doc);
1693        // Empty REQUEST is allowed
1694        assert!(result.is_ok());
1695    }
1696
1697    #[test]
1698    fn test_validate_document_invalid_request_json() {
1699        let mut doc = create_test_document();
1700        doc.sections.push(Section {
1701            section_type: SectionType::Request,
1702            content: SectionContent::Json(serde_json::json!({"key": "value"})),
1703            inline_options: InlineOptions::default(),
1704            raw_content: "{\"key\": \"value\"}".to_string(),
1705            start_line: 5,
1706            end_line: 6,
1707            attributes: Vec::new(),
1708        });
1709        doc.sections.push(Section {
1710            section_type: SectionType::Response,
1711            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1712            inline_options: InlineOptions::default(),
1713            raw_content: "{\"result\": \"ok\"}".to_string(),
1714            start_line: 7,
1715            end_line: 8,
1716            attributes: Vec::new(),
1717        });
1718
1719        let result = validate_document(&doc);
1720        // Valid JSON should pass
1721        assert!(result.is_ok());
1722    }
1723
1724    #[test]
1725    fn test_validate_document_invalid_response_json() {
1726        let mut doc = create_test_document();
1727        doc.sections.push(Section {
1728            section_type: SectionType::Request,
1729            content: SectionContent::Json(serde_json::json!({"key": "value"})),
1730            inline_options: InlineOptions::default(),
1731            raw_content: "{\"key\": \"value\"}".to_string(),
1732            start_line: 5,
1733            end_line: 6,
1734            attributes: Vec::new(),
1735        });
1736        doc.sections.push(Section {
1737            section_type: SectionType::Response,
1738            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1739            inline_options: InlineOptions::default(),
1740            raw_content: "{\"result\": \"ok\"}".to_string(),
1741            start_line: 7,
1742            end_line: 8,
1743            attributes: Vec::new(),
1744        });
1745
1746        let errors = validate_document_diagnostics(&doc);
1747        // Valid JSON should have no errors
1748        let has_json_errors = errors.iter().any(|e| e.message.contains("JSON"));
1749        assert!(!has_json_errors);
1750    }
1751
1752    #[test]
1753    fn test_validate_error_details_must_be_array() {
1754        let mut doc = create_test_document();
1755        doc.sections.push(Section {
1756            section_type: SectionType::Error,
1757            content: SectionContent::Json(serde_json::json!({
1758                "code": 3,
1759                "details": {"@type": "type.googleapis.com/google.rpc.ErrorInfo"}
1760            })),
1761            inline_options: InlineOptions::default(),
1762            raw_content: "".to_string(),
1763            start_line: 5,
1764            end_line: 8,
1765            attributes: Vec::new(),
1766        });
1767
1768        let errors = validate_document_diagnostics(&doc);
1769        assert!(
1770            errors
1771                .iter()
1772                .any(|e| e.message.contains("field 'details' must be an array"))
1773        );
1774    }
1775
1776    #[test]
1777    fn test_validate_error_details_items_must_be_objects() {
1778        let mut doc = create_test_document();
1779        doc.sections.push(Section {
1780            section_type: SectionType::Error,
1781            content: SectionContent::Json(serde_json::json!({
1782                "code": 3,
1783                "details": ["not-an-object"]
1784            })),
1785            inline_options: InlineOptions::default(),
1786            raw_content: "".to_string(),
1787            start_line: 5,
1788            end_line: 8,
1789            attributes: Vec::new(),
1790        });
1791
1792        let errors = validate_document_diagnostics(&doc);
1793        assert!(
1794            errors
1795                .iter()
1796                .any(|e| e.message.contains("'details' items must be objects"))
1797        );
1798    }
1799
1800    #[test]
1801    fn test_validate_document_address_from_env() {
1802        // Set env var
1803        unsafe {
1804            std::env::set_var("GRPCTESTIFY_ADDRESS", "env:5000");
1805        }
1806
1807        let mut doc = GctfDocument::new("test.gctf".to_string());
1808        doc.sections.push(Section {
1809            section_type: SectionType::Endpoint,
1810            content: SectionContent::Single("Service/Method".to_string()),
1811            inline_options: InlineOptions::default(),
1812            raw_content: "Service/Method".to_string(),
1813            start_line: 1,
1814            end_line: 1,
1815            attributes: Vec::new(),
1816        });
1817        doc.sections.push(Section {
1818            section_type: SectionType::Response,
1819            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1820            inline_options: InlineOptions::default(),
1821            raw_content: "{\"result\": \"ok\"}".to_string(),
1822            start_line: 2,
1823            end_line: 3,
1824            attributes: Vec::new(),
1825        });
1826
1827        let result = validate_document(&doc);
1828        // Should pass because address comes from env
1829        assert!(result.is_ok());
1830
1831        // Clean up
1832        unsafe {
1833            std::env::remove_var("GRPCTESTIFY_ADDRESS");
1834        }
1835    }
1836
1837    #[test]
1838    fn test_validate_options_unknown_key_warning() {
1839        let mut doc = create_test_document();
1840        let mut options = std::collections::HashMap::new();
1841        options.insert("unknown".to_string(), "value".to_string());
1842        doc.sections.push(Section {
1843            section_type: SectionType::Options,
1844            content: SectionContent::KeyValues(options),
1845            inline_options: InlineOptions::default(),
1846            raw_content: "unknown: value".to_string(),
1847            start_line: 5,
1848            end_line: 6,
1849            attributes: Vec::new(),
1850        });
1851        doc.sections.push(Section {
1852            section_type: SectionType::Response,
1853            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1854            inline_options: InlineOptions::default(),
1855            raw_content: "{\"result\": \"ok\"}".to_string(),
1856            start_line: 7,
1857            end_line: 8,
1858            attributes: Vec::new(),
1859        });
1860
1861        let diagnostics = validate_document_diagnostics(&doc);
1862        assert!(diagnostics.iter().any(|d| {
1863            d.severity == ErrorSeverity::Warning && d.message.contains("Unknown OPTIONS key")
1864        }));
1865    }
1866
1867    #[test]
1868    fn test_validate_options_dry_run_is_unknown_key_warning() {
1869        let mut doc = create_test_document();
1870        let mut options = std::collections::HashMap::new();
1871        options.insert("dry_run".to_string(), "true".to_string());
1872        doc.sections.push(Section {
1873            section_type: SectionType::Options,
1874            content: SectionContent::KeyValues(options),
1875            inline_options: InlineOptions::default(),
1876            raw_content: "dry_run: true".to_string(),
1877            start_line: 5,
1878            end_line: 6,
1879            attributes: Vec::new(),
1880        });
1881        doc.sections.push(Section {
1882            section_type: SectionType::Response,
1883            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1884            inline_options: InlineOptions::default(),
1885            raw_content: "{\"result\": \"ok\"}".to_string(),
1886            start_line: 7,
1887            end_line: 8,
1888            attributes: Vec::new(),
1889        });
1890
1891        let diagnostics = validate_document_diagnostics(&doc);
1892        assert!(diagnostics.iter().any(|d| {
1893            d.severity == ErrorSeverity::Warning
1894                && d.message
1895                    .contains("Unknown OPTIONS key 'dry_run'. Supported keys: timeout, retry, retry_delay, no_retry, compression")
1896        }));
1897    }
1898
1899    #[test]
1900    fn test_validate_options_timeout_invalid_error() {
1901        let mut doc = create_test_document();
1902        let mut options = std::collections::HashMap::new();
1903        options.insert("timeout".to_string(), "0".to_string());
1904        doc.sections.push(Section {
1905            section_type: SectionType::Options,
1906            content: SectionContent::KeyValues(options),
1907            inline_options: InlineOptions::default(),
1908            raw_content: "timeout: 0".to_string(),
1909            start_line: 5,
1910            end_line: 6,
1911            attributes: Vec::new(),
1912        });
1913        doc.sections.push(Section {
1914            section_type: SectionType::Response,
1915            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1916            inline_options: InlineOptions::default(),
1917            raw_content: "{\"result\": \"ok\"}".to_string(),
1918            start_line: 7,
1919            end_line: 8,
1920            attributes: Vec::new(),
1921        });
1922
1923        let diagnostics = validate_document_diagnostics(&doc);
1924        assert!(diagnostics.iter().any(|d| {
1925            d.severity == ErrorSeverity::Error
1926                && d.message
1927                    .contains("OPTIONS.timeout must be a positive integer")
1928        }));
1929    }
1930
1931    #[test]
1932    fn test_validate_options_snake_case_keys_are_supported() {
1933        let mut doc = create_test_document();
1934        let mut options = std::collections::HashMap::new();
1935        options.insert("timeout".to_string(), "5".to_string());
1936        options.insert("retry".to_string(), "2".to_string());
1937        options.insert("retry_delay".to_string(), "0.5".to_string());
1938        options.insert("no_retry".to_string(), "false".to_string());
1939        options.insert("compression".to_string(), "gzip".to_string());
1940        doc.sections.push(Section {
1941            section_type: SectionType::Options,
1942            content: SectionContent::KeyValues(options),
1943            inline_options: InlineOptions::default(),
1944            raw_content: "".to_string(),
1945            start_line: 5,
1946            end_line: 8,
1947            attributes: Vec::new(),
1948        });
1949        doc.sections.push(Section {
1950            section_type: SectionType::Response,
1951            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1952            inline_options: InlineOptions::default(),
1953            raw_content: "{\"result\": \"ok\"}".to_string(),
1954            start_line: 9,
1955            end_line: 10,
1956            attributes: Vec::new(),
1957        });
1958
1959        let diagnostics = validate_document_diagnostics(&doc);
1960        assert!(
1961            !diagnostics
1962                .iter()
1963                .any(|d| d.message.contains("Unknown OPTIONS key"))
1964        );
1965        assert!(
1966            !diagnostics
1967                .iter()
1968                .any(|d| d.severity == ErrorSeverity::Error)
1969        );
1970    }
1971
1972    #[test]
1973    fn test_validate_options_compression_invalid_error() {
1974        let mut doc = create_test_document();
1975        let mut options = std::collections::HashMap::new();
1976        options.insert("compression".to_string(), "brotli".to_string());
1977        doc.sections.push(Section {
1978            section_type: SectionType::Options,
1979            content: SectionContent::KeyValues(options),
1980            inline_options: InlineOptions::default(),
1981            raw_content: "compression: brotli".to_string(),
1982            start_line: 5,
1983            end_line: 6,
1984            attributes: Vec::new(),
1985        });
1986        doc.sections.push(Section {
1987            section_type: SectionType::Response,
1988            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1989            inline_options: InlineOptions::default(),
1990            raw_content: "{\"result\": \"ok\"}".to_string(),
1991            start_line: 7,
1992            end_line: 8,
1993            attributes: Vec::new(),
1994        });
1995
1996        let diagnostics = validate_document_diagnostics(&doc);
1997        assert!(diagnostics.iter().any(|d| {
1998            d.severity == ErrorSeverity::Error
1999                && d.message
2000                    .contains("OPTIONS.compression must be one of: none, gzip")
2001        }));
2002    }
2003
2004    #[test]
2005    fn test_validate_options_kebab_case_keys_deprecated_warning() {
2006        let mut doc = create_test_document();
2007        let mut options = std::collections::HashMap::new();
2008        options.insert("retry-delay".to_string(), "0.3".to_string());
2009        options.insert("no-retry".to_string(), "false".to_string());
2010        doc.sections.push(Section {
2011            section_type: SectionType::Options,
2012            content: SectionContent::KeyValues(options),
2013            inline_options: InlineOptions::default(),
2014            raw_content: "retry-delay: 0.3\nno-retry: false".to_string(),
2015            start_line: 5,
2016            end_line: 7,
2017            attributes: Vec::new(),
2018        });
2019        doc.sections.push(Section {
2020            section_type: SectionType::Response,
2021            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
2022            inline_options: InlineOptions::default(),
2023            raw_content: "{\"result\": \"ok\"}".to_string(),
2024            start_line: 8,
2025            end_line: 9,
2026            attributes: Vec::new(),
2027        });
2028
2029        let diagnostics = validate_document_diagnostics(&doc);
2030        assert!(diagnostics.iter().any(|d| {
2031            d.severity == ErrorSeverity::Warning
2032                && d.message.contains("OPTIONS.retry-delay is deprecated")
2033        }));
2034        assert!(diagnostics.iter().any(|d| {
2035            d.severity == ErrorSeverity::Warning
2036                && d.message.contains("OPTIONS.no-retry is deprecated")
2037        }));
2038    }
2039
2040    #[test]
2041    fn test_validate_options_no_retry_retry_conflict_warning() {
2042        let mut doc = create_test_document();
2043        let mut options = std::collections::HashMap::new();
2044        options.insert("retry".to_string(), "3".to_string());
2045        options.insert("no_retry".to_string(), "true".to_string());
2046        doc.sections.push(Section {
2047            section_type: SectionType::Options,
2048            content: SectionContent::KeyValues(options),
2049            inline_options: InlineOptions::default(),
2050            raw_content: "retry: 3\nno_retry: true".to_string(),
2051            start_line: 5,
2052            end_line: 7,
2053            attributes: Vec::new(),
2054        });
2055        doc.sections.push(Section {
2056            section_type: SectionType::Response,
2057            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
2058            inline_options: InlineOptions::default(),
2059            raw_content: "{\"result\": \"ok\"}".to_string(),
2060            start_line: 8,
2061            end_line: 9,
2062            attributes: Vec::new(),
2063        });
2064
2065        let diagnostics = validate_document_diagnostics(&doc);
2066        assert!(diagnostics.iter().any(|d| {
2067            d.severity == ErrorSeverity::Warning
2068                && d.message
2069                    .contains("OPTIONS.no_retry=true conflicts with OPTIONS.retry>0")
2070        }));
2071    }
2072
2073    #[test]
2074    fn test_validate_attribute_retry_delay_kebab_case_deprecated_warning() {
2075        let mut doc = create_test_document();
2076        doc.sections.push(Section {
2077            section_type: SectionType::Request,
2078            content: SectionContent::Json(serde_json::json!({"id": 1})),
2079            inline_options: InlineOptions::default(),
2080            raw_content: "{\"id\":1}".to_string(),
2081            start_line: 5,
2082            end_line: 6,
2083            attributes: vec![GctfAttribute::new("retry-delay", "0.2")],
2084        });
2085        doc.sections.push(Section {
2086            section_type: SectionType::Response,
2087            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
2088            inline_options: InlineOptions::default(),
2089            raw_content: "{\"result\": \"ok\"}".to_string(),
2090            start_line: 7,
2091            end_line: 8,
2092            attributes: Vec::new(),
2093        });
2094
2095        let diagnostics = validate_document_diagnostics(&doc);
2096        assert!(diagnostics.iter().any(|d| {
2097            d.severity == ErrorSeverity::Warning
2098                && d.message.contains("Attribute #[retry-delay] is deprecated")
2099        }));
2100    }
2101
2102    #[test]
2103    fn test_validate_bench_dynamic_percentile_key_ok() {
2104        let mut doc = create_test_document();
2105        let mut bench = std::collections::HashMap::new();
2106        bench.insert(
2107            "thresholds.latency_ms.p(99.9)".to_string(),
2108            "<300".to_string(),
2109        );
2110        bench.insert("thresholds.p(95)".to_string(), "<120".to_string());
2111        doc.sections.insert(
2112            0,
2113            Section {
2114                section_type: SectionType::Bench,
2115                content: SectionContent::KeyValues(bench),
2116                inline_options: InlineOptions::default(),
2117                raw_content: String::new(),
2118                start_line: 0,
2119                end_line: 2,
2120                attributes: Vec::new(),
2121            },
2122        );
2123
2124        let diagnostics = validate_document_diagnostics(&doc);
2125        assert!(!diagnostics.iter().any(
2126            |d| d.message.contains("Invalid percentile") || d.message.contains("range (0,100)")
2127        ));
2128    }
2129
2130    #[test]
2131    fn test_validate_bench_dynamic_percentile_key_invalid_range() {
2132        let mut doc = create_test_document();
2133        let mut bench = std::collections::HashMap::new();
2134        bench.insert("thresholds.p(120)".to_string(), "<300".to_string());
2135        doc.sections.insert(
2136            0,
2137            Section {
2138                section_type: SectionType::Bench,
2139                content: SectionContent::KeyValues(bench),
2140                inline_options: InlineOptions::default(),
2141                raw_content: String::new(),
2142                start_line: 0,
2143                end_line: 2,
2144                attributes: Vec::new(),
2145            },
2146        );
2147
2148        let diagnostics = validate_document_diagnostics(&doc);
2149        assert!(
2150            diagnostics
2151                .iter()
2152                .any(|d| d.message.contains("must be in range (0,100)"))
2153        );
2154    }
2155
2156    #[test]
2157    fn test_validate_bench_threshold_expression_invalid() {
2158        let mut doc = create_test_document();
2159        let mut bench = std::collections::HashMap::new();
2160        bench.insert("thresholds.p(95)".to_string(), "~120".to_string());
2161        doc.sections.insert(
2162            0,
2163            Section {
2164                section_type: SectionType::Bench,
2165                content: SectionContent::KeyValues(bench),
2166                inline_options: InlineOptions::default(),
2167                raw_content: String::new(),
2168                start_line: 0,
2169                end_line: 2,
2170                attributes: Vec::new(),
2171            },
2172        );
2173
2174        let diagnostics = validate_document_diagnostics(&doc);
2175        assert!(
2176            diagnostics
2177                .iter()
2178                .any(|d| d.message.contains("invalid expression"))
2179        );
2180    }
2181
2182    #[test]
2183    fn test_validate_bench_load_schedule_and_progress_keys() {
2184        let mut doc = create_test_document();
2185        let mut bench = std::collections::HashMap::new();
2186        bench.insert("load_schedule".to_string(), "step".to_string());
2187        bench.insert("load_start".to_string(), "10".to_string());
2188        bench.insert("load_step".to_string(), "5".to_string());
2189        bench.insert("load_end".to_string(), "40".to_string());
2190        bench.insert("load_step_duration".to_string(), "3s".to_string());
2191        bench.insert("load_max_duration".to_string(), "30s".to_string());
2192        bench.insert("progress_interval".to_string(), "2s".to_string());
2193        doc.sections.insert(
2194            0,
2195            Section {
2196                section_type: SectionType::Bench,
2197                content: SectionContent::KeyValues(bench),
2198                inline_options: InlineOptions::default(),
2199                raw_content: String::new(),
2200                start_line: 0,
2201                end_line: 2,
2202                attributes: Vec::new(),
2203            },
2204        );
2205
2206        let diagnostics = validate_document_diagnostics(&doc);
2207        assert!(!diagnostics.iter().any(|d| {
2208            d.message.contains("Unknown BENCH key")
2209                || d.message.contains("BENCH.load_schedule must be one of")
2210        }));
2211    }
2212
2213    #[test]
2214    fn test_validate_bench_hyphenated_keys_are_unknown() {
2215        let mut doc = create_test_document();
2216        let mut bench = std::collections::HashMap::new();
2217        bench.insert("load-schedule".to_string(), "line".to_string());
2218        bench.insert("load-step-duration".to_string(), "2s".to_string());
2219        bench.insert("progress-interval".to_string(), "1s".to_string());
2220        bench.insert("assert-mode".to_string(), "sampled".to_string());
2221        bench.insert("duration-stop".to_string(), "wait".to_string());
2222        doc.sections.insert(
2223            0,
2224            Section {
2225                section_type: SectionType::Bench,
2226                content: SectionContent::KeyValues(bench),
2227                inline_options: InlineOptions::default(),
2228                raw_content: String::new(),
2229                start_line: 0,
2230                end_line: 2,
2231                attributes: Vec::new(),
2232            },
2233        );
2234
2235        let diagnostics = validate_document_diagnostics(&doc);
2236        assert!(diagnostics.iter().any(|d| {
2237            d.message.contains("Unknown BENCH key 'load-schedule'")
2238                && d.message.contains("did you mean 'load_schedule'")
2239        }));
2240    }
2241
2242    #[test]
2243    fn test_validate_bench_snake_case_keys_no_deprecation_warning() {
2244        let mut doc = create_test_document();
2245        let mut bench = std::collections::HashMap::new();
2246        bench.insert("load_schedule".to_string(), "line".to_string());
2247        bench.insert("progress_interval".to_string(), "1s".to_string());
2248        doc.sections.insert(
2249            0,
2250            Section {
2251                section_type: SectionType::Bench,
2252                content: SectionContent::KeyValues(bench),
2253                inline_options: InlineOptions::default(),
2254                raw_content: String::new(),
2255                start_line: 0,
2256                end_line: 2,
2257                attributes: Vec::new(),
2258            },
2259        );
2260
2261        let diagnostics = validate_document_diagnostics(&doc);
2262        assert!(
2263            !diagnostics
2264                .iter()
2265                .any(|d| d.message.contains("is deprecated"))
2266        );
2267    }
2268
2269    #[test]
2270    fn test_validate_bench_unknown_key_typo_suggestion() {
2271        let mut doc = create_test_document();
2272        let mut bench = std::collections::HashMap::new();
2273        bench.insert("load_shedule".to_string(), "step".to_string());
2274        doc.sections.insert(
2275            0,
2276            Section {
2277                section_type: SectionType::Bench,
2278                content: SectionContent::KeyValues(bench),
2279                inline_options: InlineOptions::default(),
2280                raw_content: String::new(),
2281                start_line: 0,
2282                end_line: 2,
2283                attributes: Vec::new(),
2284            },
2285        );
2286
2287        let diagnostics = validate_document_diagnostics(&doc);
2288        assert!(diagnostics.iter().any(|d| {
2289            d.message.contains("Unknown BENCH key 'load_shedule'")
2290                && d.message.contains("did you mean 'load_schedule'")
2291        }));
2292    }
2293
2294    #[test]
2295    fn test_validation_error_debug() {
2296        let error = ValidationError {
2297            message: "test error".to_string(),
2298            line: Some(10),
2299            severity: ErrorSeverity::Error,
2300        };
2301        let debug_str = format!("{:?}", error);
2302        assert!(debug_str.contains("ValidationError"));
2303        assert!(debug_str.contains("test error"));
2304    }
2305
2306    #[test]
2307    fn test_error_severity_serialize() {
2308        let error = ErrorSeverity::Error;
2309        let json = serde_json::to_string(&error).unwrap();
2310        assert_eq!(json, "\"error\"");
2311
2312        let warning = ErrorSeverity::Warning;
2313        let json = serde_json::to_string(&warning).unwrap();
2314        assert_eq!(json, "\"warning\"");
2315    }
2316
2317    #[test]
2318    fn test_section_order_response_before_request_warns() {
2319        let mut doc = GctfDocument::new("test.gctf".to_string());
2320        doc.sections.push(Section {
2321            section_type: SectionType::Response,
2322            content: SectionContent::Json(serde_json::json!({})),
2323            inline_options: InlineOptions::default(),
2324            raw_content: "{}".to_string(),
2325            start_line: 1,
2326            end_line: 2,
2327            attributes: Vec::new(),
2328        });
2329        let mut errors = Vec::new();
2330        validate_section_order(&doc, &mut errors);
2331        assert!(errors.iter().any(|e| e.message.contains("Response")));
2332    }
2333
2334    #[test]
2335    fn test_section_order_response_after_request_ok() {
2336        let mut doc = GctfDocument::new("test.gctf".to_string());
2337        doc.sections.push(Section {
2338            section_type: SectionType::Request,
2339            content: SectionContent::Json(serde_json::json!({"x": 1})),
2340            inline_options: InlineOptions::default(),
2341            raw_content: "{\"x\": 1}".to_string(),
2342            start_line: 1,
2343            end_line: 2,
2344            attributes: Vec::new(),
2345        });
2346        doc.sections.push(Section {
2347            section_type: SectionType::Response,
2348            content: SectionContent::Json(serde_json::json!({})),
2349            inline_options: InlineOptions::default(),
2350            raw_content: "{}".to_string(),
2351            start_line: 3,
2352            end_line: 4,
2353            attributes: Vec::new(),
2354        });
2355        let mut errors = Vec::new();
2356        validate_section_order(&doc, &mut errors);
2357        assert!(errors.is_empty());
2358    }
2359
2360    #[test]
2361    fn test_section_order_extract_before_response_warns() {
2362        let mut doc = GctfDocument::new("test.gctf".to_string());
2363        doc.sections.push(Section {
2364            section_type: SectionType::Request,
2365            content: SectionContent::Json(serde_json::json!({})),
2366            inline_options: InlineOptions::default(),
2367            raw_content: "{}".to_string(),
2368            start_line: 1,
2369            end_line: 2,
2370            attributes: Vec::new(),
2371        });
2372        doc.sections.push(Section {
2373            section_type: SectionType::Extract,
2374            content: SectionContent::Single("var = .value".to_string()),
2375            inline_options: InlineOptions::default(),
2376            raw_content: "var = .value".to_string(),
2377            start_line: 3,
2378            end_line: 4,
2379            attributes: Vec::new(),
2380        });
2381        let mut errors = Vec::new();
2382        validate_section_order(&doc, &mut errors);
2383        assert!(errors.iter().any(|e| e.message.contains("EXTRACT")));
2384    }
2385}