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>> {
48 log::info!("Writing {} test cases to BATS files", test_cases.len());
49
50 let mut by_category: HashMap<TestCategory, Vec<&TestCase>> = HashMap::new();
52
53 for test in test_cases {
54 by_category.entry(test.category).or_default().push(test);
55 }
56
57 let mut output_files = Vec::new();
58
59 for (category, tests) in by_category {
61 let output_file = self.write_category_file(category, tests)?;
62 output_files.push(output_file);
63 }
64
65 log::info!("Generated {} BATS files", output_files.len());
66 Ok(output_files)
67 }
68
69 fn write_category_file(
71 &self,
72 category: TestCategory,
73 tests: Vec<&TestCase>,
74 ) -> Result<PathBuf> {
75 let filename = format!("{}.bats", category.as_str());
76 let output_path = self.output_dir.join(&filename);
77
78 log::debug!(
79 "Writing {} tests to {} ({:?})",
80 tests.len(),
81 filename,
82 category
83 );
84
85 let file = File::create(&output_path)
86 .map_err(|e| Error::Config(format!("Failed to create BATS file: {}", e)))?;
87
88 let mut writer = BufWriter::new(file);
89
90 self.write_header(&mut writer, category)?;
92
93 self.write_setup(&mut writer)?;
95
96 self.write_teardown(&mut writer)?;
98
99 for test in tests {
101 self.write_test_case(&mut writer, test)?;
102 }
103
104 writer
105 .flush()
106 .map_err(|e| Error::Config(format!("Failed to flush BATS file: {}", e)))?;
107
108 log::debug!("Successfully wrote {}", filename);
109 Ok(output_path)
110 }
111
112 fn write_header(&self, writer: &mut BufWriter<File>, category: TestCategory) -> Result<()> {
114 writeln!(writer, "#!/usr/bin/env bats")?;
115 writeln!(writer, "#")?;
116 writeln!(
117 writer,
118 "# BATS Test Suite: {}",
119 category.as_str().to_uppercase()
120 )?;
121 writeln!(writer, "# Generated by CLI Testing Specialist")?;
122 writeln!(writer, "# Target CLI: {}", self.binary_name)?;
123 writeln!(writer, "#")?;
124 writeln!(writer)?;
125
126 Ok(())
127 }
128
129 fn write_setup(&self, writer: &mut BufWriter<File>) -> Result<()> {
131 writeln!(writer, "# Setup function (runs before each test)")?;
132 writeln!(writer, "setup() {{")?;
133 writeln!(writer, " # Set CLI binary path")?;
134 writeln!(writer, " CLI_BINARY=\"{}\"", self.binary_path.display())?;
135 writeln!(writer, " BINARY_BASENAME=\"{}\"", self.binary_name)?;
136 writeln!(writer)?;
137 writeln!(
138 writer,
139 " # Create temporary directory for test artifacts"
140 )?;
141 writeln!(writer, " TEST_TEMP_DIR=\"$(mktemp -d)\"")?;
142 writeln!(writer, " export TEST_TEMP_DIR")?;
143 writeln!(writer)?;
144 writeln!(writer, " # Set secure umask")?;
145 writeln!(writer, " umask 077")?;
146 writeln!(writer, "}}")?;
147 writeln!(writer)?;
148
149 Ok(())
150 }
151
152 fn write_teardown(&self, writer: &mut BufWriter<File>) -> Result<()> {
154 writeln!(writer, "# Teardown function (runs after each test)")?;
155 writeln!(writer, "teardown() {{")?;
156 writeln!(writer, " # Cleanup temporary directory")?;
157 writeln!(
158 writer,
159 " if [[ -n \"${{TEST_TEMP_DIR:-}}\" ]] && [[ -d \"$TEST_TEMP_DIR\" ]]; then"
160 )?;
161 writeln!(writer, " rm -rf \"$TEST_TEMP_DIR\"")?;
162 writeln!(writer, " fi")?;
163 writeln!(writer, "}}")?;
164 writeln!(writer)?;
165
166 Ok(())
167 }
168
169 fn write_test_case(&self, writer: &mut BufWriter<File>, test: &TestCase) -> Result<()> {
171 writeln!(
173 writer,
174 "@test \"[{}] {}\" {{",
175 test.category.as_str(),
176 test.name
177 )?;
178
179 writeln!(writer, " # Test ID: {}", test.id)?;
181 if !test.tags.is_empty() {
182 writeln!(writer, " # Tags: {}", test.tags.join(", "))?;
183 }
184 writeln!(writer)?;
185
186 writeln!(writer, " # Execute command")?;
188 writeln!(writer, " run {}", test.command)?;
189 writeln!(writer)?;
190
191 writeln!(writer, " # Assert exit code")?;
193 match test.expected_exit {
194 Some(code) => writeln!(writer, " [ \"$status\" -eq {} ]", code)?,
195 None => writeln!(writer, " [ \"$status\" -ne 0 ]")?,
196 }
197
198 if !test.assertions.is_empty() {
200 writeln!(writer)?;
201 writeln!(writer, " # Additional assertions")?;
202
203 for assertion in &test.assertions {
204 self.write_assertion(writer, assertion)?;
205 }
206 }
207
208 writeln!(writer, "}}")?;
209 writeln!(writer)?;
210
211 Ok(())
212 }
213
214 fn write_assertion(&self, writer: &mut BufWriter<File>, assertion: &Assertion) -> Result<()> {
216 match assertion {
217 Assertion::ExitCode(code) => {
218 writeln!(writer, " [ \"$status\" -eq {} ]", code)?;
219 }
220 Assertion::OutputContains(text) => {
221 writeln!(
222 writer,
223 " [[ \"$output\" =~ \"{}\" ]] || [[ \"$stderr\" =~ \"{}\" ]]",
224 escape_regex(text),
225 escape_regex(text)
226 )?;
227 }
228 Assertion::OutputMatches(pattern) => {
229 writeln!(
230 writer,
231 " [[ \"$output\" =~ {} ]] || [[ \"$stderr\" =~ {} ]]",
232 pattern, pattern
233 )?;
234 }
235 Assertion::OutputNotContains(text) => {
236 writeln!(
237 writer,
238 " ! [[ \"$output\" =~ \"{}\" ]] && ! [[ \"$stderr\" =~ \"{}\" ]]",
239 escape_regex(text),
240 escape_regex(text)
241 )?;
242 }
243 Assertion::FileExists(path) => {
244 writeln!(writer, " [ -f \"{}\" ]", path.display())?;
245 }
246 Assertion::FileNotExists(path) => {
247 writeln!(writer, " [ ! -f \"{}\" ]", path.display())?;
248 }
249 }
250
251 Ok(())
252 }
253
254 pub fn validate_bats_file(&self, file_path: &Path) -> Result<()> {
256 if !file_path.exists() {
258 return Err(Error::Validation(format!(
259 "BATS file does not exist: {}",
260 file_path.display()
261 )));
262 }
263
264 let content = fs::read_to_string(file_path)
266 .map_err(|e| Error::Config(format!("Failed to read BATS file: {}", e)))?;
267
268 if !content.starts_with("#!/usr/bin/env bats") {
270 return Err(Error::Validation(
271 "BATS file missing shebang line".to_string(),
272 ));
273 }
274
275 if !content.contains("@test") {
277 return Err(Error::Validation(
278 "BATS file contains no test cases".to_string(),
279 ));
280 }
281
282 let open_braces = content.matches('{').count();
284 let close_braces = content.matches('}').count();
285
286 if open_braces != close_braces {
287 return Err(Error::Validation(format!(
288 "Unbalanced braces: {} open, {} close",
289 open_braces, close_braces
290 )));
291 }
292
293 log::debug!("BATS file validation passed: {}", file_path.display());
294 Ok(())
295 }
296}
297
298fn escape_regex(text: &str) -> String {
300 text.replace('\\', "\\\\")
301 .replace('"', "\\\"")
302 .replace('$', "\\$")
303 .replace('`', "\\`")
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::types::TestCategory;
310 use tempfile::TempDir;
311
312 fn create_test_cases() -> Vec<TestCase> {
313 vec![
314 TestCase::new(
315 "basic-001".to_string(),
316 "Help display test".to_string(),
317 TestCategory::Basic,
318 "test-cli --help".to_string(),
319 )
320 .with_exit_code(0)
321 .with_assertion(Assertion::OutputContains("Usage:".to_string()))
322 .with_tag("help".to_string()),
323 TestCase::new(
324 "basic-002".to_string(),
325 "Version display test".to_string(),
326 TestCategory::Basic,
327 "test-cli --version".to_string(),
328 )
329 .with_exit_code(0)
330 .with_tag("version".to_string()),
331 TestCase::new(
332 "security-001".to_string(),
333 "Command injection test".to_string(),
334 TestCategory::Security,
335 "test-cli --name 'test; rm -rf /'".to_string(),
336 )
337 .with_tag("injection".to_string()),
338 ]
339 }
340
341 #[test]
342 fn test_bats_writer_creation() {
343 let temp_dir = TempDir::new().unwrap();
344 let output_dir = temp_dir.path().join("output");
345
346 let result = BatsWriter::new(
347 output_dir.clone(),
348 "test-cli".to_string(),
349 PathBuf::from("/usr/bin/test-cli"),
350 );
351
352 assert!(result.is_ok());
353 assert!(output_dir.exists());
354 }
355
356 #[test]
357 fn test_write_tests() {
358 let temp_dir = TempDir::new().unwrap();
359 let output_dir = temp_dir.path().join("output");
360
361 let writer = BatsWriter::new(
362 output_dir.clone(),
363 "test-cli".to_string(),
364 PathBuf::from("/usr/bin/test-cli"),
365 )
366 .unwrap();
367
368 let test_cases = create_test_cases();
369 let result = writer.write_tests(&test_cases);
370
371 assert!(result.is_ok());
372 let files = result.unwrap();
373 assert_eq!(files.len(), 2); for file in &files {
377 assert!(file.exists());
378 }
379 }
380
381 #[test]
382 fn test_write_category_file() {
383 let temp_dir = TempDir::new().unwrap();
384 let output_dir = temp_dir.path().join("output");
385
386 let writer = BatsWriter::new(
387 output_dir.clone(),
388 "test-cli".to_string(),
389 PathBuf::from("/usr/bin/test-cli"),
390 )
391 .unwrap();
392
393 let test_cases = create_test_cases();
394 let basic_tests: Vec<&TestCase> = test_cases
395 .iter()
396 .filter(|t| t.category == TestCategory::Basic)
397 .collect();
398
399 let result = writer.write_category_file(TestCategory::Basic, basic_tests);
400
401 assert!(result.is_ok());
402 let file_path = result.unwrap();
403 assert!(file_path.exists());
404 assert_eq!(file_path.file_name().unwrap(), "basic.bats");
405 }
406
407 #[test]
408 fn test_validate_bats_file() {
409 let temp_dir = TempDir::new().unwrap();
410 let output_dir = temp_dir.path().join("output");
411
412 let writer = BatsWriter::new(
413 output_dir.clone(),
414 "test-cli".to_string(),
415 PathBuf::from("/usr/bin/test-cli"),
416 )
417 .unwrap();
418
419 let test_cases = create_test_cases();
420 let files = writer.write_tests(&test_cases).unwrap();
421
422 for file in &files {
424 let result = writer.validate_bats_file(file);
425 assert!(result.is_ok());
426 }
427 }
428
429 #[test]
430 fn test_validate_invalid_bats_file() {
431 let temp_dir = TempDir::new().unwrap();
432 let output_dir = temp_dir.path().join("output");
433 fs::create_dir_all(&output_dir).unwrap();
434
435 let writer = BatsWriter::new(
436 output_dir.clone(),
437 "test-cli".to_string(),
438 PathBuf::from("/usr/bin/test-cli"),
439 )
440 .unwrap();
441
442 let invalid_file = output_dir.join("invalid.bats");
444 fs::write(&invalid_file, "@test \"test\" { echo \"test\" }").unwrap();
445
446 let result = writer.validate_bats_file(&invalid_file);
447 assert!(result.is_err());
448 }
449
450 #[test]
451 fn test_escape_regex() {
452 assert_eq!(escape_regex("test"), "test");
453 assert_eq!(escape_regex("test$var"), "test\\$var");
454 assert_eq!(escape_regex("test\"quote\""), "test\\\"quote\\\"");
455 assert_eq!(escape_regex("test\\path"), "test\\\\path");
456 }
457
458 #[test]
459 fn test_bats_file_content() {
460 let temp_dir = TempDir::new().unwrap();
461 let output_dir = temp_dir.path().join("output");
462
463 let writer = BatsWriter::new(
464 output_dir.clone(),
465 "test-cli".to_string(),
466 PathBuf::from("/usr/bin/test-cli"),
467 )
468 .unwrap();
469
470 let test_cases = vec![TestCase::new(
471 "basic-001".to_string(),
472 "Help test".to_string(),
473 TestCategory::Basic,
474 "test-cli --help".to_string(),
475 )
476 .with_exit_code(0)
477 .with_assertion(Assertion::OutputContains("Usage".to_string()))];
478
479 let files = writer.write_tests(&test_cases).unwrap();
480 let content = fs::read_to_string(&files[0]).unwrap();
481
482 assert!(content.contains("#!/usr/bin/env bats"));
484 assert!(content.contains("setup()"));
485 assert!(content.contains("teardown()"));
486 assert!(content.contains("@test"));
487 assert!(content.contains("Help test"));
488 assert!(content.contains("test-cli --help"));
489 assert!(content.contains("[ \"$status\" -eq 0 ]"));
490 }
491}