use crate::checker::parse_tests;
use std::path::Path;
pub fn validate(file_path: Option<&str>) {
let test_file_path = file_path.unwrap_or(".catalyst/tests.toml");
let test_file_dir = Path::new(test_file_path).parent().unwrap_or(Path::new("."));
match parse_tests(file_path) {
Ok(test_suite) => {
if test_suite.tests.is_empty() {
println!("Validation failed: No tests found in `tests.toml`.");
} else {
println!(
"Validation successful: Found {} tests.",
test_suite.tests.len()
);
}
if let Some(setup_steps) = &test_suite.setup {
validate_command_steps(setup_steps, "setup", test_suite.config.allowed_commands.as_deref());
}
if let Some(teardown_steps) = &test_suite.teardown {
validate_command_steps(teardown_steps, "teardown", test_suite.config.allowed_commands.as_deref());
}
for test in &test_suite.tests {
if test.name.is_empty() {
println!("Error: A test is missing a name.");
}
if test.method.is_empty() {
println!("Error: Test `{}` is missing an HTTP method.", test.name);
}
if test.endpoint.is_empty() {
println!("Error: Test `{}` is missing an endpoint.", test.name);
}
if !["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]
.contains(&test.method.to_uppercase().as_str())
{
println!(
"Error: Test `{}` has an invalid HTTP method `{}`.",
test.name, test.method
);
}
if test.body.is_some() && test.body_file.is_some() {
println!(
"Error: Test `{}` cannot have both `body` and `body_file` specified.",
test.name
);
}
if let Some(body_file) = &test.body_file {
validate_body_file(&test.name, body_file, test_file_dir);
}
if let Some(before_steps) = &test.before {
validate_command_steps(before_steps, &format!("test '{}' before", test.name), test_suite.config.allowed_commands.as_deref());
}
if let Some(after_steps) = &test.after {
validate_command_steps(after_steps, &format!("test '{}' after", test.name), test_suite.config.allowed_commands.as_deref());
for step in after_steps {
if let Some(on_condition) = &step.on
&& !["success", "failure", "always"].contains(&on_condition.as_str()) {
println!(
"Error: Test `{}` after hook has invalid 'on' condition '{}'. Must be one of: success, failure, always",
test.name, on_condition
);
}
}
}
}
}
Err(err) => println!("Validation failed: {err}"),
}
}
fn validate_body_file(test_name: &str, body_file: &str, test_file_dir: &Path) {
if body_file.is_empty() {
println!("Error: Test `{test_name}` has an empty `body_file` path.");
return;
}
if body_file.contains("..") {
println!(
"Error: Test `{test_name}` has an invalid `body_file` path `{body_file}` - path traversal is not allowed."
);
return;
}
if Path::new(body_file).is_absolute() {
println!(
"Error: Test `{test_name}` has an invalid `body_file` path `{body_file}` - absolute paths are not allowed."
);
return;
}
let full_path = test_file_dir.join(body_file);
if !full_path.exists() {
println!(
"Error: Test `{test_name}` references a non-existent `body_file`: `{body_file}`"
);
return;
}
if !full_path.is_file() {
println!(
"Error: Test `{test_name}` references a `body_file` that is not a file: `{body_file}`"
);
return;
}
if std::path::Path::new(body_file)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
&& let Err(e) = validate_json_file(&full_path) {
println!(
"Error: Test `{test_name}` references an invalid JSON file `{body_file}`: {e}"
);
}
}
fn validate_json_file(file_path: &Path) -> Result<(), String> {
use std::fs;
let content = fs::read_to_string(file_path).map_err(|e| format!("Cannot read file: {e}"))?;
serde_json::from_str::<serde_json::Value>(&content)
.map_err(|e| format!("Invalid JSON syntax: {e}"))?;
Ok(())
}
fn validate_command_steps(
steps: &[crate::models::command::CommandStep],
context: &str,
allowed_commands: Option<&[String]>,
) {
for (i, step) in steps.iter().enumerate() {
let step_context = format!("{} step {}", context, i + 1);
if step.run.is_empty() {
println!("Error: {step_context} has empty 'run' field.");
}
if let Some(allowed) = allowed_commands {
let command_to_check = if step.should_use_shell() {
step.args
.as_ref()
.and_then(|args| args.get(1))
.unwrap_or(&step.run)
} else {
&step.run
};
let command_name = command_to_check.split_whitespace().next().unwrap_or(command_to_check);
if !allowed.contains(&command_name.to_string()) {
println!(
"Error: {step_context} uses command '{command_name}' which is not in allowed_commands list."
);
}
}
if let Some(dir) = &step.dir {
if dir.contains("..") {
println!(
"Error: {step_context} has invalid 'dir' path '{dir}' - path traversal is not allowed."
);
}
if Path::new(dir).is_absolute() {
println!(
"Warning: {step_context} uses absolute path '{dir}' - consider using relative paths for portability."
);
}
}
if let Some(timeout) = step.timeout_ms {
if timeout == 0 {
println!(
"Warning: {step_context} has timeout_ms set to 0, which may cause immediate timeout."
);
}
if timeout > 600_000 { println!(
"Warning: {step_context} has very large timeout_ms ({timeout}ms), consider reducing it."
);
}
}
if step.export.is_some() && step.capture.is_none() {
println!(
"Warning: {step_context} uses 'export' without 'capture'. Export requires JSON stdout which may not be captured."
);
}
if step.on.is_some() && !context.contains("after") {
println!(
"Error: {step_context} uses 'on' field which is only valid for 'after' hooks."
);
}
}
}