1use anyhow::{Result, bail};
5use apif_ast::*;
6use serde::Serialize;
7
8#[derive(Debug, Clone, Serialize)]
10pub struct ValidationError {
11 pub message: String,
12 pub line: Option<usize>,
13 pub severity: ErrorSeverity,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
18#[serde(rename_all = "lowercase")]
19pub enum ErrorSeverity {
20 Error,
21 Warning,
22 Info,
23}
24
25pub 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
166pub fn validate_document_diagnostics(document: &GctfDocument) -> Vec<ValidationError> {
168 let mut errors = Vec::new();
169
170 validate_required_sections(document, &mut errors);
172
173 validate_conflicts(document, &mut errors);
175
176 validate_content(document, &mut errors);
178
179 validate_structure(document, &mut errors);
181
182 errors
183}
184
185pub 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
203fn validate_required_sections(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
205 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 let env_addr = std::env::var("GRPCTESTIFY_ADDRESS").ok();
221 if document.get_address(env_addr.as_deref()).is_none() {
222 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 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
248fn validate_conflicts(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
250 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
260fn validate_content(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
262 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 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 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 §ion.content {
302 SectionContent::Json(json) => {
303 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 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) = §ion.content {
397 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 §ion.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 for section in document.sections_by_type(SectionType::Asserts) {
654 if let SectionContent::Assertions(assertions) = §ion.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
980fn validate_structure(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
982 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(§ion.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 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 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 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(document, errors);
1071
1072 validate_bench_sources_exist(document, errors);
1074
1075 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 }
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 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
1139pub fn validation_passed(errors: &[ValidationError]) -> bool {
1141 !errors.iter().any(|e| e.severity == ErrorSeverity::Error)
1142}
1143
1144fn 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 = §ion.section_type;
1152 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 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
1182fn 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#[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 §ion.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 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 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 if let Some(target) = col.strip_prefix('@') {
1264 adj.entry(name).or_default().push(target);
1265 }
1266 }
1267 }
1268 }
1269 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 }
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 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 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 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 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 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); 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 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 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 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 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 assert!(result.is_ok());
1830
1831 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}