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    ///
48    /// # Examples
49    ///
50    /// ```no_run
51    /// use cli_testing_specialist::generator::{TestGenerator, BatsWriter};
52    /// use cli_testing_specialist::analyzer::CliParser;
53    /// use cli_testing_specialist::types::TestCategory;
54    /// use std::path::{Path, PathBuf};
55    ///
56    /// let parser = CliParser::new();
57    /// let analysis = parser.analyze(Path::new("/usr/bin/curl"))?;
58    ///
59    /// let generator = TestGenerator::new(
60    ///     analysis.clone(),
61    ///     vec![TestCategory::Basic, TestCategory::Security]
62    /// );
63    /// let tests = generator.generate()?;
64    ///
65    /// let writer = BatsWriter::new(
66    ///     PathBuf::from("tests"),
67    ///     analysis.binary_name.clone(),
68    ///     analysis.binary_path.clone()
69    /// )?;
70    ///
71    /// let bats_files = writer.write_tests(&tests)?;
72    /// println!("Generated {} BATS files", bats_files.len());
73    /// # Ok::<(), cli_testing_specialist::error::CliTestError>(())
74    /// ```
75    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        // Group tests by category
79        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        // Write one BATS file per category
88        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    /// Write a single BATS file for a category
98    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        // Write file header
119        self.write_header(&mut writer, category)?;
120
121        // Write setup function
122        self.write_setup(&mut writer)?;
123
124        // Write teardown function
125        self.write_teardown(&mut writer)?;
126
127        // Write test cases
128        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    /// Write BATS file header
141    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    /// Write setup function
158    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    /// Write teardown function
187    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    /// Write a single test case
204    fn write_test_case(&self, writer: &mut BufWriter<File>, test: &TestCase) -> Result<()> {
205        // Write test annotation
206        writeln!(
207            writer,
208            "@test \"[{}] {}\" {{",
209            test.category.as_str(),
210            test.name
211        )?;
212
213        // Write test description comment
214        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        // Write command execution
221        writeln!(writer, "    # Execute command")?;
222        writeln!(writer, "    run {}", test.command)?;
223        writeln!(writer)?;
224
225        // Write exit code assertion
226        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        // Write additional assertions
233        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    /// Write an assertion
249    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                // Special case for "Usage:" - support both uppercase and lowercase
256                // Python argparse uses "usage:" (lowercase)
257                // Most other CLIs use "Usage:" (uppercase)
258                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    /// Validate generated BATS file syntax
299    pub fn validate_bats_file(&self, file_path: &Path) -> Result<()> {
300        // Check if file exists
301        if !file_path.exists() {
302            return Err(Error::Validation(format!(
303                "BATS file does not exist: {}",
304                file_path.display()
305            )));
306        }
307
308        // Read file content
309        let content = fs::read_to_string(file_path)
310            .map_err(|e| Error::Config(format!("Failed to read BATS file: {}", e)))?;
311
312        // Basic validation checks
313        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        // Check for at least one @test block
320        if !content.contains("@test") {
321            return Err(Error::Validation(
322                "BATS file contains no test cases".to_string(),
323            ));
324        }
325
326        // Check for balanced braces (simple check)
327        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
342/// Escape special regex characters for bash pattern matching
343fn 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); // basic.bats and security.bats
418
419        // Check that files exist
420        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        // Validate each generated file
467        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        // Create invalid BATS file (missing shebang)
487        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        // Verify content structure
527        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}