cli_testing_specialist/generator/
bats_writer.rs

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
9/// BATS file writer for generating test files
10pub struct BatsWriter {
11    /// Output directory for generated BATS files
12    output_dir: PathBuf,
13
14    /// Template engine for rendering templates (currently unused, reserved for future use)
15    #[allow(dead_code)]
16    template_engine: TemplateEngine,
17
18    /// Binary name for test execution
19    binary_name: String,
20
21    /// Binary path for test execution
22    binary_path: PathBuf,
23}
24
25impl BatsWriter {
26    /// Create a new BATS writer
27    pub fn new(output_dir: PathBuf, binary_name: String, binary_path: PathBuf) -> Result<Self> {
28        // Create output directory if it doesn't exist
29        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        // Initialize template engine
35        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    /// Write test cases to BATS files, organized by category
47    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        // Group tests by category
51        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        // Write one BATS file per category
60        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    /// Write a single BATS file for a category
70    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        // Write file header
91        self.write_header(&mut writer, category)?;
92
93        // Write setup function
94        self.write_setup(&mut writer)?;
95
96        // Write teardown function
97        self.write_teardown(&mut writer)?;
98
99        // Write test cases
100        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    /// Write BATS file header
113    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    /// Write setup function
130    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    /// Write teardown function
153    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    /// Write a single test case
170    fn write_test_case(&self, writer: &mut BufWriter<File>, test: &TestCase) -> Result<()> {
171        // Write test annotation
172        writeln!(
173            writer,
174            "@test \"[{}] {}\" {{",
175            test.category.as_str(),
176            test.name
177        )?;
178
179        // Write test description comment
180        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        // Write command execution
187        writeln!(writer, "    # Execute command")?;
188        writeln!(writer, "    run {}", test.command)?;
189        writeln!(writer)?;
190
191        // Write exit code assertion
192        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        // Write additional assertions
199        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    /// Write an assertion
215    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    /// Validate generated BATS file syntax
255    pub fn validate_bats_file(&self, file_path: &Path) -> Result<()> {
256        // Check if file exists
257        if !file_path.exists() {
258            return Err(Error::Validation(format!(
259                "BATS file does not exist: {}",
260                file_path.display()
261            )));
262        }
263
264        // Read file content
265        let content = fs::read_to_string(file_path)
266            .map_err(|e| Error::Config(format!("Failed to read BATS file: {}", e)))?;
267
268        // Basic validation checks
269        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        // Check for at least one @test block
276        if !content.contains("@test") {
277            return Err(Error::Validation(
278                "BATS file contains no test cases".to_string(),
279            ));
280        }
281
282        // Check for balanced braces (simple check)
283        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
298/// Escape special regex characters for bash pattern matching
299fn 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); // basic.bats and security.bats
374
375        // Check that files exist
376        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        // Validate each generated file
423        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        // Create invalid BATS file (missing shebang)
443        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        // Verify content structure
483        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}