1use crate::error::{Error, Result};
2use crate::generator::TemplateEngine;
3use crate::types::{Assertion, TestCase, TestCategory};
4use std::collections::HashMap;
5use std::fs::{self, File};
6use std::io::{BufWriter, Write};
7use std::path::{Path, PathBuf};
8
9pub struct BatsWriter {
11 output_dir: PathBuf,
13
14 #[allow(dead_code)]
16 template_engine: TemplateEngine,
17
18 binary_name: String,
20
21 binary_path: PathBuf,
23}
24
25impl BatsWriter {
26 pub fn new(output_dir: PathBuf, binary_name: String, binary_path: PathBuf) -> Result<Self> {
28 if !output_dir.exists() {
30 fs::create_dir_all(&output_dir)
31 .map_err(|e| Error::Config(format!("Failed to create output directory: {}", e)))?;
32 }
33
34 let mut template_engine = TemplateEngine::new()?;
36 template_engine.load_templates()?;
37
38 Ok(Self {
39 output_dir,
40 template_engine,
41 binary_name,
42 binary_path,
43 })
44 }
45
46 pub fn write_tests(&self, test_cases: &[TestCase]) -> Result<Vec<PathBuf>> {
76 log::info!("Writing {} test cases to BATS files", test_cases.len());
77
78 let mut by_category: HashMap<TestCategory, Vec<&TestCase>> = HashMap::new();
80
81 for test in test_cases {
82 by_category.entry(test.category).or_default().push(test);
83 }
84
85 let mut output_files = Vec::new();
86
87 for (category, tests) in by_category {
89 let output_file = self.write_category_file(category, tests)?;
90 output_files.push(output_file);
91 }
92
93 log::info!("Generated {} BATS files", output_files.len());
94 Ok(output_files)
95 }
96
97 fn write_category_file(
99 &self,
100 category: TestCategory,
101 tests: Vec<&TestCase>,
102 ) -> Result<PathBuf> {
103 let filename = format!("{}.bats", category.as_str());
104 let output_path = self.output_dir.join(&filename);
105
106 log::debug!(
107 "Writing {} tests to {} ({:?})",
108 tests.len(),
109 filename,
110 category
111 );
112
113 let file = File::create(&output_path)
114 .map_err(|e| Error::Config(format!("Failed to create BATS file: {}", e)))?;
115
116 let mut writer = BufWriter::new(file);
117
118 self.write_header(&mut writer, category)?;
120
121 self.write_setup(&mut writer)?;
123
124 self.write_teardown(&mut writer)?;
126
127 for test in tests {
129 self.write_test_case(&mut writer, test)?;
130 }
131
132 writer
133 .flush()
134 .map_err(|e| Error::Config(format!("Failed to flush BATS file: {}", e)))?;
135
136 log::debug!("Successfully wrote {}", filename);
137 Ok(output_path)
138 }
139
140 fn write_header(&self, writer: &mut BufWriter<File>, category: TestCategory) -> Result<()> {
142 writeln!(writer, "#!/usr/bin/env bats")?;
143 writeln!(writer, "#")?;
144 writeln!(
145 writer,
146 "# BATS Test Suite: {}",
147 category.as_str().to_uppercase()
148 )?;
149 writeln!(writer, "# Generated by CLI Testing Specialist")?;
150 writeln!(writer, "# Target CLI: {}", self.binary_name)?;
151 writeln!(writer, "#")?;
152 writeln!(writer)?;
153
154 Ok(())
155 }
156
157 fn write_setup(&self, writer: &mut BufWriter<File>) -> Result<()> {
159 writeln!(writer, "# Setup function (runs before each test)")?;
160 writeln!(writer, "setup() {{")?;
161 writeln!(writer, " # Set CLI binary path")?;
162 writeln!(writer, " CLI_BINARY=\"{}\"", self.binary_path.display())?;
163 writeln!(writer, " BINARY_BASENAME=\"{}\"", self.binary_name)?;
164 writeln!(writer)?;
165 writeln!(
166 writer,
167 " # Export CLI_BINARY for subshell tests (multi-shell compatibility)"
168 )?;
169 writeln!(writer, " export CLI_BINARY")?;
170 writeln!(writer)?;
171 writeln!(
172 writer,
173 " # Create temporary directory for test artifacts"
174 )?;
175 writeln!(writer, " TEST_TEMP_DIR=\"$(mktemp -d)\"")?;
176 writeln!(writer, " export TEST_TEMP_DIR")?;
177 writeln!(writer)?;
178 writeln!(writer, " # Set secure umask")?;
179 writeln!(writer, " umask 077")?;
180 writeln!(writer, "}}")?;
181 writeln!(writer)?;
182
183 Ok(())
184 }
185
186 fn write_teardown(&self, writer: &mut BufWriter<File>) -> Result<()> {
188 writeln!(writer, "# Teardown function (runs after each test)")?;
189 writeln!(writer, "teardown() {{")?;
190 writeln!(writer, " # Cleanup temporary directory")?;
191 writeln!(
192 writer,
193 " if [[ -n \"${{TEST_TEMP_DIR:-}}\" ]] && [[ -d \"$TEST_TEMP_DIR\" ]]; then"
194 )?;
195 writeln!(writer, " rm -rf \"$TEST_TEMP_DIR\"")?;
196 writeln!(writer, " fi")?;
197 writeln!(writer, "}}")?;
198 writeln!(writer)?;
199
200 Ok(())
201 }
202
203 fn write_test_case(&self, writer: &mut BufWriter<File>, test: &TestCase) -> Result<()> {
205 writeln!(
207 writer,
208 "@test \"[{}] {}\" {{",
209 test.category.as_str(),
210 test.name
211 )?;
212
213 writeln!(writer, " # Test ID: {}", test.id)?;
215 if !test.tags.is_empty() {
216 writeln!(writer, " # Tags: {}", test.tags.join(", "))?;
217 }
218 writeln!(writer)?;
219
220 writeln!(writer, " # Execute command")?;
222 writeln!(writer, " run {}", test.command)?;
223 writeln!(writer)?;
224
225 writeln!(writer, " # Assert exit code")?;
227 match test.expected_exit {
228 Some(code) => writeln!(writer, " [ \"$status\" -eq {} ]", code)?,
229 None => writeln!(writer, " [ \"$status\" -ne 0 ]")?,
230 }
231
232 if !test.assertions.is_empty() {
234 writeln!(writer)?;
235 writeln!(writer, " # Additional assertions")?;
236
237 for assertion in &test.assertions {
238 self.write_assertion(writer, assertion)?;
239 }
240 }
241
242 writeln!(writer, "}}")?;
243 writeln!(writer)?;
244
245 Ok(())
246 }
247
248 fn write_assertion(&self, writer: &mut BufWriter<File>, assertion: &Assertion) -> Result<()> {
250 match assertion {
251 Assertion::ExitCode(code) => {
252 writeln!(writer, " [ \"$status\" -eq {} ]", code)?;
253 }
254 Assertion::OutputContains(text) => {
255 if text == "Usage:" {
259 writeln!(
260 writer,
261 " [[ \"$output\" =~ \"Usage:\" ]] || [[ \"$output\" =~ \"usage:\" ]] || [[ \"$stderr\" =~ \"Usage:\" ]] || [[ \"$stderr\" =~ \"usage:\" ]]"
262 )?;
263 } else {
264 writeln!(
265 writer,
266 " [[ \"$output\" =~ \"{}\" ]] || [[ \"$stderr\" =~ \"{}\" ]]",
267 escape_regex(text),
268 escape_regex(text)
269 )?;
270 }
271 }
272 Assertion::OutputMatches(pattern) => {
273 writeln!(
274 writer,
275 " [[ \"$output\" =~ {} ]] || [[ \"$stderr\" =~ {} ]]",
276 pattern, pattern
277 )?;
278 }
279 Assertion::OutputNotContains(text) => {
280 writeln!(
281 writer,
282 " ! [[ \"$output\" =~ \"{}\" ]] && ! [[ \"$stderr\" =~ \"{}\" ]]",
283 escape_regex(text),
284 escape_regex(text)
285 )?;
286 }
287 Assertion::FileExists(path) => {
288 writeln!(writer, " [ -f \"{}\" ]", path.display())?;
289 }
290 Assertion::FileNotExists(path) => {
291 writeln!(writer, " [ ! -f \"{}\" ]", path.display())?;
292 }
293 }
294
295 Ok(())
296 }
297
298 pub fn validate_bats_file(&self, file_path: &Path) -> Result<()> {
300 if !file_path.exists() {
302 return Err(Error::Validation(format!(
303 "BATS file does not exist: {}",
304 file_path.display()
305 )));
306 }
307
308 let content = fs::read_to_string(file_path)
310 .map_err(|e| Error::Config(format!("Failed to read BATS file: {}", e)))?;
311
312 if !content.starts_with("#!/usr/bin/env bats") {
314 return Err(Error::Validation(
315 "BATS file missing shebang line".to_string(),
316 ));
317 }
318
319 if !content.contains("@test") {
321 return Err(Error::Validation(
322 "BATS file contains no test cases".to_string(),
323 ));
324 }
325
326 let open_braces = content.matches('{').count();
328 let close_braces = content.matches('}').count();
329
330 if open_braces != close_braces {
331 return Err(Error::Validation(format!(
332 "Unbalanced braces: {} open, {} close",
333 open_braces, close_braces
334 )));
335 }
336
337 log::debug!("BATS file validation passed: {}", file_path.display());
338 Ok(())
339 }
340}
341
342fn escape_regex(text: &str) -> String {
344 text.replace('\\', "\\\\")
345 .replace('"', "\\\"")
346 .replace('$', "\\$")
347 .replace('`', "\\`")
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use crate::types::TestCategory;
354 use tempfile::TempDir;
355
356 fn create_test_cases() -> Vec<TestCase> {
357 vec![
358 TestCase::new(
359 "basic-001".to_string(),
360 "Help display test".to_string(),
361 TestCategory::Basic,
362 "test-cli --help".to_string(),
363 )
364 .with_exit_code(0)
365 .with_assertion(Assertion::OutputContains("Usage:".to_string()))
366 .with_tag("help".to_string()),
367 TestCase::new(
368 "basic-002".to_string(),
369 "Version display test".to_string(),
370 TestCategory::Basic,
371 "test-cli --version".to_string(),
372 )
373 .with_exit_code(0)
374 .with_tag("version".to_string()),
375 TestCase::new(
376 "security-001".to_string(),
377 "Command injection test".to_string(),
378 TestCategory::Security,
379 "test-cli --name 'test; rm -rf /'".to_string(),
380 )
381 .with_tag("injection".to_string()),
382 ]
383 }
384
385 #[test]
386 fn test_bats_writer_creation() {
387 let temp_dir = TempDir::new().unwrap();
388 let output_dir = temp_dir.path().join("output");
389
390 let result = BatsWriter::new(
391 output_dir.clone(),
392 "test-cli".to_string(),
393 PathBuf::from("/usr/bin/test-cli"),
394 );
395
396 assert!(result.is_ok());
397 assert!(output_dir.exists());
398 }
399
400 #[test]
401 fn test_write_tests() {
402 let temp_dir = TempDir::new().unwrap();
403 let output_dir = temp_dir.path().join("output");
404
405 let writer = BatsWriter::new(
406 output_dir.clone(),
407 "test-cli".to_string(),
408 PathBuf::from("/usr/bin/test-cli"),
409 )
410 .unwrap();
411
412 let test_cases = create_test_cases();
413 let result = writer.write_tests(&test_cases);
414
415 assert!(result.is_ok());
416 let files = result.unwrap();
417 assert_eq!(files.len(), 2); for file in &files {
421 assert!(file.exists());
422 }
423 }
424
425 #[test]
426 fn test_write_category_file() {
427 let temp_dir = TempDir::new().unwrap();
428 let output_dir = temp_dir.path().join("output");
429
430 let writer = BatsWriter::new(
431 output_dir.clone(),
432 "test-cli".to_string(),
433 PathBuf::from("/usr/bin/test-cli"),
434 )
435 .unwrap();
436
437 let test_cases = create_test_cases();
438 let basic_tests: Vec<&TestCase> = test_cases
439 .iter()
440 .filter(|t| t.category == TestCategory::Basic)
441 .collect();
442
443 let result = writer.write_category_file(TestCategory::Basic, basic_tests);
444
445 assert!(result.is_ok());
446 let file_path = result.unwrap();
447 assert!(file_path.exists());
448 assert_eq!(file_path.file_name().unwrap(), "basic.bats");
449 }
450
451 #[test]
452 fn test_validate_bats_file() {
453 let temp_dir = TempDir::new().unwrap();
454 let output_dir = temp_dir.path().join("output");
455
456 let writer = BatsWriter::new(
457 output_dir.clone(),
458 "test-cli".to_string(),
459 PathBuf::from("/usr/bin/test-cli"),
460 )
461 .unwrap();
462
463 let test_cases = create_test_cases();
464 let files = writer.write_tests(&test_cases).unwrap();
465
466 for file in &files {
468 let result = writer.validate_bats_file(file);
469 assert!(result.is_ok());
470 }
471 }
472
473 #[test]
474 fn test_validate_invalid_bats_file() {
475 let temp_dir = TempDir::new().unwrap();
476 let output_dir = temp_dir.path().join("output");
477 fs::create_dir_all(&output_dir).unwrap();
478
479 let writer = BatsWriter::new(
480 output_dir.clone(),
481 "test-cli".to_string(),
482 PathBuf::from("/usr/bin/test-cli"),
483 )
484 .unwrap();
485
486 let invalid_file = output_dir.join("invalid.bats");
488 fs::write(&invalid_file, "@test \"test\" { echo \"test\" }").unwrap();
489
490 let result = writer.validate_bats_file(&invalid_file);
491 assert!(result.is_err());
492 }
493
494 #[test]
495 fn test_escape_regex() {
496 assert_eq!(escape_regex("test"), "test");
497 assert_eq!(escape_regex("test$var"), "test\\$var");
498 assert_eq!(escape_regex("test\"quote\""), "test\\\"quote\\\"");
499 assert_eq!(escape_regex("test\\path"), "test\\\\path");
500 }
501
502 #[test]
503 fn test_bats_file_content() {
504 let temp_dir = TempDir::new().unwrap();
505 let output_dir = temp_dir.path().join("output");
506
507 let writer = BatsWriter::new(
508 output_dir.clone(),
509 "test-cli".to_string(),
510 PathBuf::from("/usr/bin/test-cli"),
511 )
512 .unwrap();
513
514 let test_cases = vec![TestCase::new(
515 "basic-001".to_string(),
516 "Help test".to_string(),
517 TestCategory::Basic,
518 "test-cli --help".to_string(),
519 )
520 .with_exit_code(0)
521 .with_assertion(Assertion::OutputContains("Usage".to_string()))];
522
523 let files = writer.write_tests(&test_cases).unwrap();
524 let content = fs::read_to_string(&files[0]).unwrap();
525
526 assert!(content.contains("#!/usr/bin/env bats"));
528 assert!(content.contains("setup()"));
529 assert!(content.contains("teardown()"));
530 assert!(content.contains("@test"));
531 assert!(content.contains("Help test"));
532 assert!(content.contains("test-cli --help"));
533 assert!(content.contains("[ \"$status\" -eq 0 ]"));
534 }
535}