use super::ast::*;
use anyhow::{Result, bail};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct ValidationError {
pub message: String,
pub line: Option<usize>,
pub severity: ErrorSeverity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ErrorSeverity {
Error,
Warning,
Info,
}
pub fn validate_document_diagnostics(document: &GctfDocument) -> Vec<ValidationError> {
let mut errors = Vec::new();
validate_required_sections(document, &mut errors);
validate_conflicts(document, &mut errors);
validate_content(document, &mut errors);
validate_structure(document, &mut errors);
errors
}
pub fn validate_document(document: &GctfDocument) -> Result<Vec<ValidationError>> {
let errors = validate_document_diagnostics(document);
let has_errors = errors.iter().any(|e| e.severity == ErrorSeverity::Error);
if has_errors {
let error_messages: Vec<String> = errors
.iter()
.filter(|e| e.severity == ErrorSeverity::Error)
.map(|e| format!("Line {}: {}", e.line.unwrap_or(0), e.message))
.collect();
bail!("Validation failed:\n{}", error_messages.join("\n"));
}
Ok(errors)
}
fn validate_required_sections(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
if document.get_endpoint().is_none() {
errors.push(ValidationError {
message: "ENDPOINT section is required".to_string(),
line: None,
severity: ErrorSeverity::Error,
});
}
let env_addr = std::env::var(crate::config::ENV_GRPCTESTIFY_ADDRESS).ok();
if document.get_address(env_addr.as_deref()).is_none() {
errors.push(ValidationError {
message: format!(
"ADDRESS section missing (ensure {} is set or passed via --address)",
crate::config::ENV_GRPCTESTIFY_ADDRESS
),
line: None,
severity: ErrorSeverity::Warning,
});
}
let has_response = document.first_section(SectionType::Response).is_some();
let has_error = document.first_section(SectionType::Error).is_some();
let has_asserts = document.first_section(SectionType::Asserts).is_some();
if !has_response && !has_error && !has_asserts {
errors.push(ValidationError {
message: "At least one verification section (RESPONSE, ERROR, or ASSERTS) is required"
.to_string(),
line: None,
severity: ErrorSeverity::Error,
});
}
}
fn validate_conflicts(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
if document.has_response_error_conflict() {
errors.push(ValidationError {
message: "Cannot have both RESPONSE and ERROR sections".to_string(),
line: None,
severity: ErrorSeverity::Error,
});
}
}
fn validate_content(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
if let Some(endpoint) = document.get_endpoint()
&& !endpoint.contains('/')
{
errors.push(ValidationError {
message: format!(
"Invalid endpoint format: {}. Expected format: package.Service/Method",
endpoint
),
line: document
.first_section(SectionType::Endpoint)
.map(|s| s.start_line),
severity: ErrorSeverity::Error,
});
}
if let Some(address) = document.get_address(None)
&& !address.contains(':')
{
errors.push(ValidationError {
message: format!(
"Invalid address format: {}. Expected format: host:port",
address
),
line: document
.first_section(SectionType::Address)
.map(|s| s.start_line),
severity: ErrorSeverity::Error,
});
}
for section_type in [
SectionType::Request,
SectionType::Response,
SectionType::Error,
] {
for section in document.sections_by_type(section_type) {
match §ion.content {
SectionContent::Json(json) => {
let is_valid = if section_type == SectionType::Error {
json.is_object() || json.is_array() || json.is_string()
} else {
json.is_object() || json.is_array()
};
if !is_valid {
errors.push(ValidationError {
message: format!(
"{:?} section must contain valid JSON object or array{}",
section_type,
if section_type == SectionType::Error {
" or string"
} else {
""
}
),
line: Some(section.start_line),
severity: ErrorSeverity::Error,
});
}
if section_type == SectionType::Error
&& let Some(details) = json.get("details")
{
if !details.is_array() {
errors.push(ValidationError {
message: "ERROR section field 'details' must be an array"
.to_string(),
line: Some(section.start_line),
severity: ErrorSeverity::Error,
});
} else if let Some(detail_items) = details.as_array() {
for detail in detail_items {
if !detail.is_object() {
errors.push(ValidationError {
message: "ERROR section 'details' items must be objects"
.to_string(),
line: Some(section.start_line),
severity: ErrorSeverity::Error,
});
break;
}
if let Some(type_value) = detail.get("@type")
&& !type_value.is_string()
{
errors.push(ValidationError {
message:
"ERROR.details item field '@type' must be a string"
.to_string(),
line: Some(section.start_line),
severity: ErrorSeverity::Error,
});
}
}
}
}
}
SectionContent::JsonLines(values) => {
if section_type != SectionType::Response {
errors.push(ValidationError {
message: format!(
"{:?} section does not support newline-delimited JSON messages",
section_type
),
line: Some(section.start_line),
severity: ErrorSeverity::Error,
});
} else if values.is_empty() {
errors.push(ValidationError {
message: "RESPONSE section contains no JSON messages".to_string(),
line: Some(section.start_line),
severity: ErrorSeverity::Error,
});
}
}
_ => {}
}
}
}
for section_type in [
SectionType::RequestHeaders,
SectionType::Tls,
SectionType::Proto,
SectionType::Options,
] {
for section in document.sections_by_type(section_type) {
if let SectionContent::KeyValues(kv) = §ion.content {
for key in kv.keys() {
if key.is_empty() {
errors.push(ValidationError {
message: format!("Empty key in {:?} section", section_type),
line: Some(section.start_line),
severity: ErrorSeverity::Error,
});
}
}
if section_type == SectionType::Options {
for (key, value) in kv {
match key.as_str() {
"timeout" => {
if value.trim().parse::<u64>().ok().is_none_or(|v| v == 0) {
errors.push(ValidationError {
message: format!(
"OPTIONS.timeout must be a positive integer, got '{}'",
value
),
line: Some(section.start_line),
severity: ErrorSeverity::Error,
});
}
}
"no-retry" | "no_retry" => {
let normalized = value.trim().to_ascii_lowercase();
let is_bool = matches!(
normalized.as_str(),
"true" | "1" | "yes" | "on" | "false" | "0" | "no" | "off"
);
if !is_bool {
errors.push(ValidationError {
message: format!(
"OPTIONS.{} must be a boolean, got '{}'",
key, value
),
line: Some(section.start_line),
severity: ErrorSeverity::Error,
});
}
}
"retry" => {
if value.trim().parse::<u32>().is_err() {
errors.push(ValidationError {
message: format!(
"OPTIONS.retry must be a non-negative integer, got '{}'",
value
),
line: Some(section.start_line),
severity: ErrorSeverity::Error,
});
}
}
"retry-delay" | "retry_delay" => {
if value.trim().parse::<f64>().ok().is_none_or(|v| v < 0.0) {
errors.push(ValidationError {
message: format!(
"OPTIONS.retry-delay must be a non-negative number, got '{}'",
value
),
line: Some(section.start_line),
severity: ErrorSeverity::Error,
});
}
}
"compression" => {
let normalized = value.trim().to_ascii_lowercase();
if !matches!(normalized.as_str(), "none" | "gzip") {
errors.push(ValidationError {
message: format!(
"OPTIONS.compression must be one of: none, gzip (got '{}')",
value
),
line: Some(section.start_line),
severity: ErrorSeverity::Error,
});
}
}
_ => {
errors.push(ValidationError {
message: format!(
"Unknown OPTIONS key '{}'. Supported keys: timeout, retry, retry-delay, no-retry, compression",
key
),
line: Some(section.start_line),
severity: ErrorSeverity::Warning,
});
}
}
}
}
}
}
}
for section in document.sections_by_type(SectionType::Asserts) {
if let SectionContent::Assertions(assertions) = §ion.content {
for assertion in assertions {
if assertion.is_empty() {
errors.push(ValidationError {
message: "Empty assertion found".to_string(),
line: Some(section.start_line),
severity: ErrorSeverity::Warning,
});
}
}
}
}
}
fn validate_structure(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
let mut seen_sections = std::collections::HashSet::new();
let mut meta_count = 0;
let mut meta_first_line = None;
for section in &document.sections {
if section.section_type == SectionType::Meta {
meta_count += 1;
if meta_first_line.is_none() {
meta_first_line = Some(section.start_line);
}
}
if !section.section_type.is_multiple_allowed() {
if seen_sections.contains(§ion.section_type) {
errors.push(ValidationError {
message: format!("Duplicate {:?} section found", section.section_type),
line: Some(section.start_line),
severity: ErrorSeverity::Error,
});
}
seen_sections.insert(section.section_type);
}
}
if meta_count > 1 {
errors.push(ValidationError {
message: "Only one META section is allowed per file".to_string(),
line: meta_first_line,
severity: ErrorSeverity::Error,
});
}
if meta_count == 1
&& let Some(first_section) = document.sections.first()
&& first_section.section_type != SectionType::Meta
{
errors.push(ValidationError {
message: "META section must be the first section in the file".to_string(),
line: meta_first_line,
severity: ErrorSeverity::Error,
});
}
for section in &document.sections {
let has_any_inline_options = section.inline_options.with_asserts
|| section.inline_options.partial
|| section.inline_options.tolerance.is_some()
|| !section.inline_options.redact.is_empty()
|| section.inline_options.unordered_arrays;
if !has_any_inline_options {
continue;
}
match section.section_type {
SectionType::Response => {
}
SectionType::Error => {
if section.inline_options.partial
|| section.inline_options.tolerance.is_some()
|| !section.inline_options.redact.is_empty()
|| section.inline_options.unordered_arrays
{
errors.push(ValidationError {
message: "ERROR section only supports with_asserts inline option"
.to_string(),
line: Some(section.start_line),
severity: ErrorSeverity::Warning,
});
}
}
_ => {
errors.push(ValidationError {
message: format!(
"Inline options are not supported for {:?} section",
section.section_type
),
line: Some(section.start_line),
severity: ErrorSeverity::Warning,
});
}
}
}
}
pub fn validation_passed(errors: &[ValidationError]) -> bool {
!errors.iter().any(|e| e.severity == ErrorSeverity::Error)
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_document() -> GctfDocument {
let mut doc = GctfDocument::new("test.gctf".to_string());
doc.sections = vec![
Section {
section_type: SectionType::Address,
content: SectionContent::Single("localhost:4770".to_string()),
inline_options: InlineOptions::default(),
raw_content: "localhost:4770".to_string(),
start_line: 1,
end_line: 1,
},
Section {
section_type: SectionType::Endpoint,
content: SectionContent::Single("my.Service/Method".to_string()),
inline_options: InlineOptions::default(),
raw_content: "my.Service/Method".to_string(),
start_line: 3,
end_line: 3,
},
];
doc
}
#[test]
fn test_validate_required_sections_pass() {
let doc = create_test_document();
let result = validate_document(&doc);
assert!(result.is_err());
}
#[test]
fn test_validate_endpoint_format() {
let mut doc = create_test_document();
doc.sections[1].content = SectionContent::Single("invalid_endpoint".to_string());
let result = validate_document(&doc);
assert!(result.is_err());
}
#[test]
fn test_validate_address_format() {
let mut doc = create_test_document();
doc.sections[0].content = SectionContent::Single("invalid_address".to_string());
let result = validate_document(&doc);
assert!(result.is_err());
}
#[test]
fn test_validation_passed() {
let errors = vec![
ValidationError {
message: "Warning".to_string(),
line: Some(1),
severity: ErrorSeverity::Warning,
},
ValidationError {
message: "Info".to_string(),
line: Some(2),
severity: ErrorSeverity::Info,
},
];
assert!(validation_passed(&errors));
}
#[test]
fn test_validation_failed() {
let errors = vec![
ValidationError {
message: "Warning".to_string(),
line: Some(1),
severity: ErrorSeverity::Warning,
},
ValidationError {
message: "Error".to_string(),
line: Some(2),
severity: ErrorSeverity::Error,
},
];
assert!(!validation_passed(&errors));
}
#[test]
fn test_validate_document_diagnostics() {
let doc = create_test_document();
let errors = validate_document_diagnostics(&doc);
assert!(!errors.is_empty());
}
#[test]
fn test_validate_document_with_response() {
let mut doc = create_test_document();
doc.sections.push(Section {
section_type: SectionType::Response,
content: SectionContent::Json(serde_json::json!({"result": "ok"})),
inline_options: InlineOptions::default(),
raw_content: "{\"result\": \"ok\"}".to_string(),
start_line: 5,
end_line: 6,
});
let result = validate_document(&doc);
assert!(result.is_ok());
}
#[test]
fn test_validate_document_with_error_section() {
let mut doc = create_test_document();
doc.sections.push(Section {
section_type: SectionType::Error,
content: SectionContent::Json(serde_json::json!({"code": 5})),
inline_options: InlineOptions::default(),
raw_content: "{\"code\": 5}".to_string(),
start_line: 5,
end_line: 6,
});
let result = validate_document(&doc);
assert!(result.is_ok());
}
#[test]
fn test_validate_document_with_asserts() {
let mut doc = create_test_document();
doc.sections.push(Section {
section_type: SectionType::Asserts,
content: SectionContent::Assertions(vec![".id == 1".to_string()]),
inline_options: InlineOptions::default(),
raw_content: ".id == 1".to_string(),
start_line: 5,
end_line: 5,
});
let result = validate_document(&doc);
assert!(result.is_ok());
}
#[test]
fn test_validate_document_missing_endpoint() {
let mut doc = create_test_document();
doc.sections.remove(1);
let errors = validate_document_diagnostics(&doc);
let has_endpoint_error = errors.iter().any(|e| e.message.contains("ENDPOINT"));
assert!(has_endpoint_error);
}
#[test]
fn test_validate_document_response_error_conflict() {
let mut doc = create_test_document();
doc.sections.push(Section {
section_type: SectionType::Response,
content: SectionContent::Json(serde_json::json!({"result": "ok"})),
inline_options: InlineOptions::default(),
raw_content: "{\"result\": \"ok\"}".to_string(),
start_line: 5,
end_line: 6,
});
doc.sections.push(Section {
section_type: SectionType::Error,
content: SectionContent::Json(serde_json::json!({"code": 5})),
inline_options: InlineOptions::default(),
raw_content: "{\"code\": 5}".to_string(),
start_line: 7,
end_line: 8,
});
let errors = validate_document_diagnostics(&doc);
let has_conflict_error = errors
.iter()
.any(|e| e.message.contains("RESPONSE") && e.message.contains("ERROR"));
assert!(has_conflict_error);
}
#[test]
fn test_validate_document_empty_requests() {
let mut doc = create_test_document();
doc.sections.push(Section {
section_type: SectionType::Request,
content: SectionContent::Empty,
inline_options: InlineOptions::default(),
raw_content: "".to_string(),
start_line: 5,
end_line: 5,
});
doc.sections.push(Section {
section_type: SectionType::Response,
content: SectionContent::Json(serde_json::json!({"result": "ok"})),
inline_options: InlineOptions::default(),
raw_content: "{\"result\": \"ok\"}".to_string(),
start_line: 6,
end_line: 7,
});
let result = validate_document(&doc);
assert!(result.is_ok());
}
#[test]
fn test_validate_document_invalid_request_json() {
let mut doc = create_test_document();
doc.sections.push(Section {
section_type: SectionType::Request,
content: SectionContent::Json(serde_json::json!({"key": "value"})),
inline_options: InlineOptions::default(),
raw_content: "{\"key\": \"value\"}".to_string(),
start_line: 5,
end_line: 6,
});
doc.sections.push(Section {
section_type: SectionType::Response,
content: SectionContent::Json(serde_json::json!({"result": "ok"})),
inline_options: InlineOptions::default(),
raw_content: "{\"result\": \"ok\"}".to_string(),
start_line: 7,
end_line: 8,
});
let result = validate_document(&doc);
assert!(result.is_ok());
}
#[test]
fn test_validate_document_invalid_response_json() {
let mut doc = create_test_document();
doc.sections.push(Section {
section_type: SectionType::Request,
content: SectionContent::Json(serde_json::json!({"key": "value"})),
inline_options: InlineOptions::default(),
raw_content: "{\"key\": \"value\"}".to_string(),
start_line: 5,
end_line: 6,
});
doc.sections.push(Section {
section_type: SectionType::Response,
content: SectionContent::Json(serde_json::json!({"result": "ok"})),
inline_options: InlineOptions::default(),
raw_content: "{\"result\": \"ok\"}".to_string(),
start_line: 7,
end_line: 8,
});
let errors = validate_document_diagnostics(&doc);
let has_json_errors = errors.iter().any(|e| e.message.contains("JSON"));
assert!(!has_json_errors);
}
#[test]
fn test_validate_error_details_must_be_array() {
let mut doc = create_test_document();
doc.sections.push(Section {
section_type: SectionType::Error,
content: SectionContent::Json(serde_json::json!({
"code": 3,
"details": {"@type": "type.googleapis.com/google.rpc.ErrorInfo"}
})),
inline_options: InlineOptions::default(),
raw_content: "".to_string(),
start_line: 5,
end_line: 8,
});
let errors = validate_document_diagnostics(&doc);
assert!(
errors
.iter()
.any(|e| e.message.contains("field 'details' must be an array"))
);
}
#[test]
fn test_validate_error_details_items_must_be_objects() {
let mut doc = create_test_document();
doc.sections.push(Section {
section_type: SectionType::Error,
content: SectionContent::Json(serde_json::json!({
"code": 3,
"details": ["not-an-object"]
})),
inline_options: InlineOptions::default(),
raw_content: "".to_string(),
start_line: 5,
end_line: 8,
});
let errors = validate_document_diagnostics(&doc);
assert!(
errors
.iter()
.any(|e| e.message.contains("'details' items must be objects"))
);
}
#[test]
fn test_validate_document_address_from_env() {
unsafe {
std::env::set_var(crate::config::ENV_GRPCTESTIFY_ADDRESS, "env:5000");
}
let mut doc = GctfDocument::new("test.gctf".to_string());
doc.sections.push(Section {
section_type: SectionType::Endpoint,
content: SectionContent::Single("Service/Method".to_string()),
inline_options: InlineOptions::default(),
raw_content: "Service/Method".to_string(),
start_line: 1,
end_line: 1,
});
doc.sections.push(Section {
section_type: SectionType::Response,
content: SectionContent::Json(serde_json::json!({"result": "ok"})),
inline_options: InlineOptions::default(),
raw_content: "{\"result\": \"ok\"}".to_string(),
start_line: 2,
end_line: 3,
});
let result = validate_document(&doc);
assert!(result.is_ok());
unsafe {
std::env::remove_var(crate::config::ENV_GRPCTESTIFY_ADDRESS);
}
}
#[test]
fn test_validate_options_unknown_key_warning() {
let mut doc = create_test_document();
let mut options = std::collections::HashMap::new();
options.insert("unknown".to_string(), "value".to_string());
doc.sections.push(Section {
section_type: SectionType::Options,
content: SectionContent::KeyValues(options),
inline_options: InlineOptions::default(),
raw_content: "unknown: value".to_string(),
start_line: 5,
end_line: 6,
});
doc.sections.push(Section {
section_type: SectionType::Response,
content: SectionContent::Json(serde_json::json!({"result": "ok"})),
inline_options: InlineOptions::default(),
raw_content: "{\"result\": \"ok\"}".to_string(),
start_line: 7,
end_line: 8,
});
let diagnostics = validate_document_diagnostics(&doc);
assert!(diagnostics.iter().any(|d| {
d.severity == ErrorSeverity::Warning && d.message.contains("Unknown OPTIONS key")
}));
}
#[test]
fn test_validate_options_dry_run_is_unknown_key_warning() {
let mut doc = create_test_document();
let mut options = std::collections::HashMap::new();
options.insert("dry_run".to_string(), "true".to_string());
doc.sections.push(Section {
section_type: SectionType::Options,
content: SectionContent::KeyValues(options),
inline_options: InlineOptions::default(),
raw_content: "dry_run: true".to_string(),
start_line: 5,
end_line: 6,
});
doc.sections.push(Section {
section_type: SectionType::Response,
content: SectionContent::Json(serde_json::json!({"result": "ok"})),
inline_options: InlineOptions::default(),
raw_content: "{\"result\": \"ok\"}".to_string(),
start_line: 7,
end_line: 8,
});
let diagnostics = validate_document_diagnostics(&doc);
assert!(diagnostics.iter().any(|d| {
d.severity == ErrorSeverity::Warning
&& d.message
.contains("Unknown OPTIONS key 'dry_run'. Supported keys: timeout, retry, retry-delay, no-retry, compression")
}));
}
#[test]
fn test_validate_options_timeout_invalid_error() {
let mut doc = create_test_document();
let mut options = std::collections::HashMap::new();
options.insert("timeout".to_string(), "0".to_string());
doc.sections.push(Section {
section_type: SectionType::Options,
content: SectionContent::KeyValues(options),
inline_options: InlineOptions::default(),
raw_content: "timeout: 0".to_string(),
start_line: 5,
end_line: 6,
});
doc.sections.push(Section {
section_type: SectionType::Response,
content: SectionContent::Json(serde_json::json!({"result": "ok"})),
inline_options: InlineOptions::default(),
raw_content: "{\"result\": \"ok\"}".to_string(),
start_line: 7,
end_line: 8,
});
let diagnostics = validate_document_diagnostics(&doc);
assert!(diagnostics.iter().any(|d| {
d.severity == ErrorSeverity::Error
&& d.message
.contains("OPTIONS.timeout must be a positive integer")
}));
}
#[test]
fn test_validate_options_kebab_case_keys_are_supported() {
let mut doc = create_test_document();
let mut options = std::collections::HashMap::new();
options.insert("timeout".to_string(), "5".to_string());
options.insert("retry".to_string(), "2".to_string());
options.insert("retry-delay".to_string(), "0.5".to_string());
options.insert("no-retry".to_string(), "false".to_string());
options.insert("compression".to_string(), "gzip".to_string());
doc.sections.push(Section {
section_type: SectionType::Options,
content: SectionContent::KeyValues(options),
inline_options: InlineOptions::default(),
raw_content: "".to_string(),
start_line: 5,
end_line: 8,
});
doc.sections.push(Section {
section_type: SectionType::Response,
content: SectionContent::Json(serde_json::json!({"result": "ok"})),
inline_options: InlineOptions::default(),
raw_content: "{\"result\": \"ok\"}".to_string(),
start_line: 9,
end_line: 10,
});
let diagnostics = validate_document_diagnostics(&doc);
assert!(
!diagnostics
.iter()
.any(|d| d.message.contains("Unknown OPTIONS key"))
);
assert!(
!diagnostics
.iter()
.any(|d| d.severity == ErrorSeverity::Error)
);
}
#[test]
fn test_validate_options_compression_invalid_error() {
let mut doc = create_test_document();
let mut options = std::collections::HashMap::new();
options.insert("compression".to_string(), "brotli".to_string());
doc.sections.push(Section {
section_type: SectionType::Options,
content: SectionContent::KeyValues(options),
inline_options: InlineOptions::default(),
raw_content: "compression: brotli".to_string(),
start_line: 5,
end_line: 6,
});
doc.sections.push(Section {
section_type: SectionType::Response,
content: SectionContent::Json(serde_json::json!({"result": "ok"})),
inline_options: InlineOptions::default(),
raw_content: "{\"result\": \"ok\"}".to_string(),
start_line: 7,
end_line: 8,
});
let diagnostics = validate_document_diagnostics(&doc);
assert!(diagnostics.iter().any(|d| {
d.severity == ErrorSeverity::Error
&& d.message
.contains("OPTIONS.compression must be one of: none, gzip")
}));
}
#[test]
fn test_validation_error_debug() {
let error = ValidationError {
message: "test error".to_string(),
line: Some(10),
severity: ErrorSeverity::Error,
};
let debug_str = format!("{:?}", error);
assert!(debug_str.contains("ValidationError"));
assert!(debug_str.contains("test error"));
}
#[test]
fn test_error_severity_serialize() {
let error = ErrorSeverity::Error;
let json = serde_json::to_string(&error).unwrap();
assert_eq!(json, "\"error\"");
let warning = ErrorSeverity::Warning;
let json = serde_json::to_string(&warning).unwrap();
assert_eq!(json, "\"warning\"");
}
}