Skip to main content

seqc/
test_runner.rs

1//! Test runner for Seq test files
2//!
3//! Discovers and executes tests in `test-*.seq` files, reporting results.
4
5use crate::parser::Parser;
6use crate::{CompilerConfig, compile_file_with_config};
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::time::Instant;
11
12/// Result of running a single test
13#[derive(Debug)]
14pub struct TestResult {
15    /// Name of the test function
16    pub name: String,
17    /// Whether the test passed
18    pub passed: bool,
19    /// Duration in milliseconds
20    pub duration_ms: u64,
21    /// Error output if test failed
22    pub error_output: Option<String>,
23}
24
25/// Summary of all test results
26#[derive(Debug, Default)]
27pub struct TestSummary {
28    /// Total tests run
29    pub total: usize,
30    /// Tests passed
31    pub passed: usize,
32    /// Tests failed
33    pub failed: usize,
34    /// Files that failed to compile
35    pub compile_failures: usize,
36    /// Results by file
37    pub file_results: Vec<FileTestResults>,
38}
39
40impl TestSummary {
41    /// Returns true if any tests failed or any files failed to compile
42    pub fn has_failures(&self) -> bool {
43        self.failed > 0 || self.compile_failures > 0
44    }
45}
46
47/// Results for a single test file
48#[derive(Debug)]
49pub struct FileTestResults {
50    /// Path to the test file
51    pub path: PathBuf,
52    /// Individual test results
53    pub tests: Vec<TestResult>,
54    /// Compilation error if file failed to compile
55    pub compile_error: Option<String>,
56}
57
58/// Test runner configuration
59pub struct TestRunner {
60    /// Show verbose output
61    pub verbose: bool,
62    /// Filter pattern for test names
63    pub filter: Option<String>,
64    /// Compiler configuration
65    pub config: CompilerConfig,
66}
67
68impl TestRunner {
69    pub fn new(verbose: bool, filter: Option<String>) -> Self {
70        Self {
71            verbose,
72            filter,
73            config: CompilerConfig::default(),
74        }
75    }
76
77    /// Discover test files in the given paths
78    pub fn discover_test_files(&self, paths: &[PathBuf]) -> Vec<PathBuf> {
79        let mut test_files = Vec::new();
80
81        for path in paths {
82            if path.is_file() {
83                if self.is_test_file(path) {
84                    test_files.push(path.clone());
85                }
86            } else if path.is_dir() {
87                self.discover_in_directory(path, &mut test_files);
88            }
89        }
90
91        test_files.sort();
92        test_files
93    }
94
95    fn is_test_file(&self, path: &Path) -> bool {
96        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
97            name.starts_with("test-") && name.ends_with(".seq")
98        } else {
99            false
100        }
101    }
102
103    fn discover_in_directory(&self, dir: &Path, files: &mut Vec<PathBuf>) {
104        if let Ok(entries) = fs::read_dir(dir) {
105            for entry in entries.flatten() {
106                let path = entry.path();
107                if path.is_file() && self.is_test_file(&path) {
108                    files.push(path);
109                } else if path.is_dir() {
110                    self.discover_in_directory(&path, files);
111                }
112            }
113        }
114    }
115
116    /// Discover test functions in a source file
117    /// Returns (test_names, has_main) - test names and whether file has its own main
118    pub fn discover_test_functions(&self, source: &str) -> Result<(Vec<String>, bool), String> {
119        let mut parser = Parser::new(source);
120        let program = parser.parse()?;
121
122        let has_main = program.words.iter().any(|w| w.name == "main");
123
124        let mut test_names: Vec<String> = program
125            .words
126            .iter()
127            .filter(|w| w.name.starts_with("test-"))
128            .filter(|w| self.matches_filter(&w.name))
129            .map(|w| w.name.clone())
130            .collect();
131
132        test_names.sort();
133        Ok((test_names, has_main))
134    }
135
136    fn matches_filter(&self, name: &str) -> bool {
137        match &self.filter {
138            Some(pattern) => name.contains(pattern),
139            None => true,
140        }
141    }
142
143    /// Run all tests in a file
144    pub fn run_file(&self, path: &Path) -> FileTestResults {
145        let source = match fs::read_to_string(path) {
146            Ok(s) => s,
147            Err(e) => {
148                return FileTestResults {
149                    path: path.to_path_buf(),
150                    tests: vec![],
151                    compile_error: Some(format!("Failed to read file: {}", e)),
152                };
153            }
154        };
155
156        let (test_names, has_main) = match self.discover_test_functions(&source) {
157            Ok(result) => result,
158            Err(e) => {
159                return FileTestResults {
160                    path: path.to_path_buf(),
161                    tests: vec![],
162                    compile_error: Some(format!("Parse error: {}", e)),
163                };
164            }
165        };
166
167        // Skip files that have their own main - they are standalone test suites
168        if has_main {
169            return FileTestResults {
170                path: path.to_path_buf(),
171                tests: vec![],
172                compile_error: None,
173            };
174        }
175
176        if test_names.is_empty() {
177            return FileTestResults {
178                path: path.to_path_buf(),
179                tests: vec![],
180                compile_error: None,
181            };
182        }
183
184        // Compile once and run all tests in the file
185        self.run_all_tests_in_file(path, &source, &test_names)
186    }
187
188    fn run_all_tests_in_file(
189        &self,
190        path: &Path,
191        source: &str,
192        test_names: &[String],
193    ) -> FileTestResults {
194        let start = Instant::now();
195
196        // Generate wrapper main that runs ALL tests in sequence.
197        //
198        // `test.set-name` after the user's test word guarantees the
199        // `test.finish` header matches the word name the parser discovered,
200        // even if the user called `test.init` with a different friendly
201        // name inside their test word. Without this, `collect_failure_block`
202        // (which keys on the word name) would orphan detail lines.
203        let mut test_calls = String::new();
204        for test_name in test_names {
205            test_calls.push_str(&format!(
206                "  \"{0}\" test.init {0} \"{0}\" test.set-name test.finish\n",
207                test_name
208            ));
209        }
210
211        let wrapper = format!(
212            r#"{}
213
214: main ( -- )
215{}  test.has-failures [ 1 os.exit ] [ ] if
216;
217"#,
218            source, test_calls
219        );
220
221        // Create temp file for the wrapper
222        let temp_dir = std::env::temp_dir();
223        let file_id = sanitize_name(&path.to_string_lossy());
224        let wrapper_path = temp_dir.join(format!("seq_test_{}.seq", file_id));
225        let binary_path = temp_dir.join(format!("seq_test_{}", file_id));
226
227        if let Err(e) = fs::write(&wrapper_path, &wrapper) {
228            return FileTestResults {
229                path: path.to_path_buf(),
230                tests: vec![],
231                compile_error: Some(format!("Failed to write temp file: {}", e)),
232            };
233        }
234
235        // Compile the wrapper (ONE compilation for all tests in file)
236        if let Err(e) = compile_file_with_config(&wrapper_path, &binary_path, false, &self.config) {
237            let _ = fs::remove_file(&wrapper_path);
238            return FileTestResults {
239                path: path.to_path_buf(),
240                tests: vec![],
241                compile_error: Some(format!("Compilation error: {}", e)),
242            };
243        }
244
245        // Run the compiled tests
246        let output = Command::new(&binary_path).output();
247
248        // Clean up temp files
249        let _ = fs::remove_file(&wrapper_path);
250        let _ = fs::remove_file(&binary_path);
251
252        let compile_time = start.elapsed().as_millis() as u64;
253
254        match output {
255            Ok(output) => {
256                let stdout = String::from_utf8_lossy(&output.stdout);
257                let stderr = String::from_utf8_lossy(&output.stderr);
258
259                // Parse output to determine which tests passed/failed
260                // Output format: "test-name ... ok" or "test-name ... FAILED"
261                let results = self.parse_test_output(&stdout, test_names, compile_time);
262
263                // If we couldn't parse results but process failed, mark all as failed
264                if results.iter().all(|r| r.passed) && !output.status.success() {
265                    return FileTestResults {
266                        path: path.to_path_buf(),
267                        tests: test_names
268                            .iter()
269                            .map(|name| TestResult {
270                                name: name.clone(),
271                                passed: false,
272                                duration_ms: 0,
273                                error_output: Some(format!("{}{}", stderr, stdout)),
274                            })
275                            .collect(),
276                        compile_error: None,
277                    };
278                }
279
280                FileTestResults {
281                    path: path.to_path_buf(),
282                    tests: results,
283                    compile_error: None,
284                }
285            }
286            Err(e) => FileTestResults {
287                path: path.to_path_buf(),
288                tests: vec![],
289                compile_error: Some(format!("Failed to run tests: {}", e)),
290            },
291        }
292    }
293
294    fn parse_test_output(
295        &self,
296        output: &str,
297        test_names: &[String],
298        _compile_time: u64,
299    ) -> Vec<TestResult> {
300        let mut results = Vec::new();
301
302        for test_name in test_names {
303            // Look for "test-name ... ok" or "test-name ... FAILED"
304            let passed = output
305                .lines()
306                .any(|line| line.contains(test_name) && line.contains("... ok"));
307
308            // For failures, capture the FAILED header line plus any
309            // indented detail lines that immediately follow it (runtime
310            // emits `expected X, got Y`-style lines indented under the
311            // header on the same stdout stream).
312            let error_output = if !passed {
313                collect_failure_block(output, test_name)
314            } else {
315                None
316            };
317
318            results.push(TestResult {
319                name: test_name.clone(),
320                passed,
321                duration_ms: 0, // Individual timing not available in batch mode
322                error_output,
323            });
324        }
325
326        results
327    }
328
329    /// Run tests and return summary
330    pub fn run(&self, paths: &[PathBuf]) -> TestSummary {
331        let test_files = self.discover_test_files(paths);
332        let mut summary = TestSummary::default();
333
334        for path in test_files {
335            let file_results = self.run_file(&path);
336
337            // Track compilation failures
338            if file_results.compile_error.is_some() {
339                summary.compile_failures += 1;
340            }
341
342            for test in &file_results.tests {
343                summary.total += 1;
344                if test.passed {
345                    summary.passed += 1;
346                } else {
347                    summary.failed += 1;
348                }
349            }
350
351            summary.file_results.push(file_results);
352        }
353
354        summary
355    }
356
357    /// Print test results
358    pub fn print_results(&self, summary: &TestSummary) {
359        for file_result in &summary.file_results {
360            if let Some(ref error) = file_result.compile_error {
361                eprintln!("\nFailed to process {}:", file_result.path.display());
362                eprintln!("  {}", error);
363                continue;
364            }
365
366            if file_result.tests.is_empty() {
367                continue;
368            }
369
370            println!("\nRunning tests in {}...", file_result.path.display());
371
372            for test in &file_result.tests {
373                let status = if test.passed { "ok" } else { "FAILED" };
374                if self.verbose {
375                    println!("  {} ... {} ({}ms)", test.name, status, test.duration_ms);
376                } else {
377                    println!("  {} ... {}", test.name, status);
378                }
379            }
380        }
381
382        // Print summary
383        println!("\n========================================");
384        if summary.compile_failures > 0 {
385            println!(
386                "Results: {} passed, {} failed, {} failed to compile",
387                summary.passed, summary.failed, summary.compile_failures
388            );
389        } else {
390            println!(
391                "Results: {} passed, {} failed",
392                summary.passed, summary.failed
393            );
394        }
395
396        // Print test failures in detail
397        let failures: Vec<_> = summary
398            .file_results
399            .iter()
400            .flat_map(|fr| fr.tests.iter().filter(|t| !t.passed).map(|t| (&fr.path, t)))
401            .collect();
402
403        if !failures.is_empty() {
404            println!("\nTEST FAILURES:\n");
405            for (path, test) in failures {
406                println!("{}::{}", path.display(), test.name);
407                if let Some(ref error) = test.error_output {
408                    for line in error.lines() {
409                        println!("  {}", line);
410                    }
411                }
412                println!();
413            }
414        }
415
416        // Print compilation failures in detail
417        let compile_failures: Vec<_> = summary
418            .file_results
419            .iter()
420            .filter(|fr| fr.compile_error.is_some())
421            .collect();
422
423        if !compile_failures.is_empty() {
424            println!("\nCOMPILATION FAILURES:\n");
425            for fr in compile_failures {
426                println!("{}:", fr.path.display());
427                if let Some(ref error) = fr.compile_error {
428                    for line in error.lines() {
429                        println!("  {}", line);
430                    }
431                }
432                println!();
433            }
434        }
435    }
436}
437
438/// Sanitize a test name for use as a filename
439fn sanitize_name(name: &str) -> String {
440    name.chars()
441        .map(|c| if c.is_alphanumeric() { c } else { '_' })
442        .collect()
443}
444
445/// Given the full test-wrapper stdout and a test name, find the
446/// `<name> ... FAILED` header line plus any indented detail lines
447/// that immediately follow it, and return them as a single block.
448///
449/// An indented detail line is any line that begins with whitespace.
450/// Collection stops at the next non-indented line (typically the next
451/// test's header, or the pass/fail summary).
452///
453/// Matches the header exactly (`{name} ... FAILED`) so one test name
454/// being a substring of another (e.g. `add` vs `add-overflow`) cannot
455/// cross-attribute the block.
456fn collect_failure_block(output: &str, test_name: &str) -> Option<String> {
457    let header = format!("{} ... FAILED", test_name);
458    let mut lines = output.lines().peekable();
459    while let Some(line) = lines.next() {
460        if line == header {
461            let mut block = String::from(line);
462            while let Some(next) = lines.peek() {
463                if next.starts_with(char::is_whitespace) {
464                    block.push('\n');
465                    block.push_str(next);
466                    lines.next();
467                } else {
468                    break;
469                }
470            }
471            return Some(block);
472        }
473    }
474    None
475}
476
477#[cfg(test)]
478mod tests;