use crate::error::{Error, Result};
use crate::generator::TemplateEngine;
use crate::types::{Assertion, TestCase, TestCategory};
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
pub struct BatsWriter {
output_dir: PathBuf,
#[allow(dead_code)]
template_engine: TemplateEngine,
binary_name: String,
binary_path: PathBuf,
}
impl BatsWriter {
pub fn new(output_dir: PathBuf, binary_name: String, binary_path: PathBuf) -> Result<Self> {
if !output_dir.exists() {
fs::create_dir_all(&output_dir)
.map_err(|e| Error::Config(format!("Failed to create output directory: {}", e)))?;
}
let mut template_engine = TemplateEngine::new()?;
template_engine.load_templates()?;
Ok(Self {
output_dir,
template_engine,
binary_name,
binary_path,
})
}
pub fn write_tests(&self, test_cases: &[TestCase]) -> Result<Vec<PathBuf>> {
log::info!("Writing {} test cases to BATS files", test_cases.len());
let mut by_category: HashMap<TestCategory, Vec<&TestCase>> = HashMap::new();
for test in test_cases {
by_category.entry(test.category).or_default().push(test);
}
let mut output_files = Vec::new();
for (category, tests) in by_category {
let output_file = self.write_category_file(category, tests)?;
output_files.push(output_file);
}
log::info!("Generated {} BATS files", output_files.len());
Ok(output_files)
}
fn write_category_file(
&self,
category: TestCategory,
tests: Vec<&TestCase>,
) -> Result<PathBuf> {
let filename = format!("{}.bats", category.as_str());
let output_path = self.output_dir.join(&filename);
log::debug!(
"Writing {} tests to {} ({:?})",
tests.len(),
filename,
category
);
let file = File::create(&output_path)
.map_err(|e| Error::Config(format!("Failed to create BATS file: {}", e)))?;
let mut writer = BufWriter::new(file);
self.write_header(&mut writer, category)?;
self.write_setup(&mut writer)?;
self.write_teardown(&mut writer)?;
for test in tests {
self.write_test_case(&mut writer, test)?;
}
writer
.flush()
.map_err(|e| Error::Config(format!("Failed to flush BATS file: {}", e)))?;
log::debug!("Successfully wrote {}", filename);
Ok(output_path)
}
fn write_header(&self, writer: &mut BufWriter<File>, category: TestCategory) -> Result<()> {
writeln!(writer, "#!/usr/bin/env bats")?;
writeln!(writer, "#")?;
writeln!(
writer,
"# BATS Test Suite: {}",
category.as_str().to_uppercase()
)?;
writeln!(writer, "# Generated by CLI Testing Specialist")?;
writeln!(writer, "# Target CLI: {}", self.binary_name)?;
writeln!(writer, "#")?;
writeln!(writer)?;
Ok(())
}
fn write_setup(&self, writer: &mut BufWriter<File>) -> Result<()> {
writeln!(writer, "# Setup function (runs before each test)")?;
writeln!(writer, "setup() {{")?;
writeln!(writer, " # Set CLI binary path")?;
writeln!(writer, " CLI_BINARY=\"{}\"", self.binary_path.display())?;
writeln!(writer, " BINARY_BASENAME=\"{}\"", self.binary_name)?;
writeln!(writer)?;
writeln!(
writer,
" # Export CLI_BINARY for subshell tests (multi-shell compatibility)"
)?;
writeln!(writer, " export CLI_BINARY")?;
writeln!(writer)?;
writeln!(
writer,
" # Create temporary directory for test artifacts"
)?;
writeln!(writer, " TEST_TEMP_DIR=\"$(mktemp -d)\"")?;
writeln!(writer, " export TEST_TEMP_DIR")?;
writeln!(writer)?;
writeln!(writer, " # Set secure umask")?;
writeln!(writer, " umask 077")?;
writeln!(writer, "}}")?;
writeln!(writer)?;
Ok(())
}
fn write_teardown(&self, writer: &mut BufWriter<File>) -> Result<()> {
writeln!(writer, "# Teardown function (runs after each test)")?;
writeln!(writer, "teardown() {{")?;
writeln!(writer, " # Cleanup temporary directory")?;
writeln!(
writer,
" if [[ -n \"${{TEST_TEMP_DIR:-}}\" ]] && [[ -d \"$TEST_TEMP_DIR\" ]]; then"
)?;
writeln!(writer, " rm -rf \"$TEST_TEMP_DIR\"")?;
writeln!(writer, " fi")?;
writeln!(writer, "}}")?;
writeln!(writer)?;
Ok(())
}
fn write_test_case(&self, writer: &mut BufWriter<File>, test: &TestCase) -> Result<()> {
writeln!(
writer,
"@test \"[{}] {}\" {{",
test.category.as_str(),
test.name
)?;
writeln!(writer, " # Test ID: {}", test.id)?;
if !test.tags.is_empty() {
writeln!(writer, " # Tags: {}", test.tags.join(", "))?;
}
writeln!(writer)?;
writeln!(writer, " # Execute command")?;
writeln!(writer, " run {}", test.command)?;
writeln!(writer)?;
writeln!(writer, " # Assert exit code")?;
match test.expected_exit {
Some(code) => writeln!(writer, " [ \"$status\" -eq {} ]", code)?,
None => writeln!(writer, " [ \"$status\" -ne 0 ]")?,
}
if !test.assertions.is_empty() {
writeln!(writer)?;
writeln!(writer, " # Additional assertions")?;
for assertion in &test.assertions {
self.write_assertion(writer, assertion)?;
}
}
writeln!(writer, "}}")?;
writeln!(writer)?;
Ok(())
}
fn write_assertion(&self, writer: &mut BufWriter<File>, assertion: &Assertion) -> Result<()> {
match assertion {
Assertion::ExitCode(code) => {
writeln!(writer, " [ \"$status\" -eq {} ]", code)?;
}
Assertion::OutputContains(text) => {
if text == "Usage:" {
writeln!(
writer,
" [[ \"$output\" =~ \"Usage:\" ]] || [[ \"$output\" =~ \"usage:\" ]] || [[ \"$stderr\" =~ \"Usage:\" ]] || [[ \"$stderr\" =~ \"usage:\" ]]"
)?;
} else {
writeln!(
writer,
" [[ \"$output\" =~ \"{}\" ]] || [[ \"$stderr\" =~ \"{}\" ]]",
escape_regex(text),
escape_regex(text)
)?;
}
}
Assertion::OutputMatches(pattern) => {
writeln!(
writer,
" [[ \"$output\" =~ {} ]] || [[ \"$stderr\" =~ {} ]]",
pattern, pattern
)?;
}
Assertion::OutputNotContains(text) => {
writeln!(
writer,
" ! [[ \"$output\" =~ \"{}\" ]] && ! [[ \"$stderr\" =~ \"{}\" ]]",
escape_regex(text),
escape_regex(text)
)?;
}
Assertion::FileExists(path) => {
writeln!(writer, " [ -f \"{}\" ]", path.display())?;
}
Assertion::FileNotExists(path) => {
writeln!(writer, " [ ! -f \"{}\" ]", path.display())?;
}
}
Ok(())
}
pub fn validate_bats_file(&self, file_path: &Path) -> Result<()> {
if !file_path.exists() {
return Err(Error::Validation(format!(
"BATS file does not exist: {}",
file_path.display()
)));
}
let content = fs::read_to_string(file_path)
.map_err(|e| Error::Config(format!("Failed to read BATS file: {}", e)))?;
if !content.starts_with("#!/usr/bin/env bats") {
return Err(Error::Validation(
"BATS file missing shebang line".to_string(),
));
}
if !content.contains("@test") {
return Err(Error::Validation(
"BATS file contains no test cases".to_string(),
));
}
let open_braces = content.matches('{').count();
let close_braces = content.matches('}').count();
if open_braces != close_braces {
return Err(Error::Validation(format!(
"Unbalanced braces: {} open, {} close",
open_braces, close_braces
)));
}
log::debug!("BATS file validation passed: {}", file_path.display());
Ok(())
}
}
fn escape_regex(text: &str) -> String {
text.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "\\$")
.replace('`', "\\`")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::TestCategory;
use tempfile::TempDir;
fn create_test_cases() -> Vec<TestCase> {
vec![
TestCase::new(
"basic-001".to_string(),
"Help display test".to_string(),
TestCategory::Basic,
"test-cli --help".to_string(),
)
.with_exit_code(0)
.with_assertion(Assertion::OutputContains("Usage:".to_string()))
.with_tag("help".to_string()),
TestCase::new(
"basic-002".to_string(),
"Version display test".to_string(),
TestCategory::Basic,
"test-cli --version".to_string(),
)
.with_exit_code(0)
.with_tag("version".to_string()),
TestCase::new(
"security-001".to_string(),
"Command injection test".to_string(),
TestCategory::Security,
"test-cli --name 'test; rm -rf /'".to_string(),
)
.with_tag("injection".to_string()),
]
}
#[test]
fn test_bats_writer_creation() {
let temp_dir = TempDir::new().unwrap();
let output_dir = temp_dir.path().join("output");
let result = BatsWriter::new(
output_dir.clone(),
"test-cli".to_string(),
PathBuf::from("/usr/bin/test-cli"),
);
assert!(result.is_ok());
assert!(output_dir.exists());
}
#[test]
fn test_write_tests() {
let temp_dir = TempDir::new().unwrap();
let output_dir = temp_dir.path().join("output");
let writer = BatsWriter::new(
output_dir.clone(),
"test-cli".to_string(),
PathBuf::from("/usr/bin/test-cli"),
)
.unwrap();
let test_cases = create_test_cases();
let result = writer.write_tests(&test_cases);
assert!(result.is_ok());
let files = result.unwrap();
assert_eq!(files.len(), 2);
for file in &files {
assert!(file.exists());
}
}
#[test]
fn test_write_category_file() {
let temp_dir = TempDir::new().unwrap();
let output_dir = temp_dir.path().join("output");
let writer = BatsWriter::new(
output_dir.clone(),
"test-cli".to_string(),
PathBuf::from("/usr/bin/test-cli"),
)
.unwrap();
let test_cases = create_test_cases();
let basic_tests: Vec<&TestCase> = test_cases
.iter()
.filter(|t| t.category == TestCategory::Basic)
.collect();
let result = writer.write_category_file(TestCategory::Basic, basic_tests);
assert!(result.is_ok());
let file_path = result.unwrap();
assert!(file_path.exists());
assert_eq!(file_path.file_name().unwrap(), "basic.bats");
}
#[test]
fn test_validate_bats_file() {
let temp_dir = TempDir::new().unwrap();
let output_dir = temp_dir.path().join("output");
let writer = BatsWriter::new(
output_dir.clone(),
"test-cli".to_string(),
PathBuf::from("/usr/bin/test-cli"),
)
.unwrap();
let test_cases = create_test_cases();
let files = writer.write_tests(&test_cases).unwrap();
for file in &files {
let result = writer.validate_bats_file(file);
assert!(result.is_ok());
}
}
#[test]
fn test_validate_invalid_bats_file() {
let temp_dir = TempDir::new().unwrap();
let output_dir = temp_dir.path().join("output");
fs::create_dir_all(&output_dir).unwrap();
let writer = BatsWriter::new(
output_dir.clone(),
"test-cli".to_string(),
PathBuf::from("/usr/bin/test-cli"),
)
.unwrap();
let invalid_file = output_dir.join("invalid.bats");
fs::write(&invalid_file, "@test \"test\" { echo \"test\" }").unwrap();
let result = writer.validate_bats_file(&invalid_file);
assert!(result.is_err());
}
#[test]
fn test_escape_regex() {
assert_eq!(escape_regex("test"), "test");
assert_eq!(escape_regex("test$var"), "test\\$var");
assert_eq!(escape_regex("test\"quote\""), "test\\\"quote\\\"");
assert_eq!(escape_regex("test\\path"), "test\\\\path");
}
#[test]
fn test_bats_file_content() {
let temp_dir = TempDir::new().unwrap();
let output_dir = temp_dir.path().join("output");
let writer = BatsWriter::new(
output_dir.clone(),
"test-cli".to_string(),
PathBuf::from("/usr/bin/test-cli"),
)
.unwrap();
let test_cases = vec![TestCase::new(
"basic-001".to_string(),
"Help test".to_string(),
TestCategory::Basic,
"test-cli --help".to_string(),
)
.with_exit_code(0)
.with_assertion(Assertion::OutputContains("Usage".to_string()))];
let files = writer.write_tests(&test_cases).unwrap();
let content = fs::read_to_string(&files[0]).unwrap();
assert!(content.contains("#!/usr/bin/env bats"));
assert!(content.contains("setup()"));
assert!(content.contains("teardown()"));
assert!(content.contains("@test"));
assert!(content.contains("Help test"));
assert!(content.contains("test-cli --help"));
assert!(content.contains("[ \"$status\" -eq 0 ]"));
}
}