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 if
216    1 os.exit
217  then
218;
219"#,
220            source, test_calls
221        );
222
223        // Create temp file for the wrapper
224        let temp_dir = std::env::temp_dir();
225        let file_id = sanitize_name(&path.to_string_lossy());
226        let wrapper_path = temp_dir.join(format!("seq_test_{}.seq", file_id));
227        let binary_path = temp_dir.join(format!("seq_test_{}", file_id));
228
229        if let Err(e) = fs::write(&wrapper_path, &wrapper) {
230            return FileTestResults {
231                path: path.to_path_buf(),
232                tests: vec![],
233                compile_error: Some(format!("Failed to write temp file: {}", e)),
234            };
235        }
236
237        // Compile the wrapper (ONE compilation for all tests in file)
238        if let Err(e) = compile_file_with_config(&wrapper_path, &binary_path, false, &self.config) {
239            let _ = fs::remove_file(&wrapper_path);
240            return FileTestResults {
241                path: path.to_path_buf(),
242                tests: vec![],
243                compile_error: Some(format!("Compilation error: {}", e)),
244            };
245        }
246
247        // Run the compiled tests
248        let output = Command::new(&binary_path).output();
249
250        // Clean up temp files
251        let _ = fs::remove_file(&wrapper_path);
252        let _ = fs::remove_file(&binary_path);
253
254        let compile_time = start.elapsed().as_millis() as u64;
255
256        match output {
257            Ok(output) => {
258                let stdout = String::from_utf8_lossy(&output.stdout);
259                let stderr = String::from_utf8_lossy(&output.stderr);
260
261                // Parse output to determine which tests passed/failed
262                // Output format: "test-name ... ok" or "test-name ... FAILED"
263                let results = self.parse_test_output(&stdout, test_names, compile_time);
264
265                // If we couldn't parse results but process failed, mark all as failed
266                if results.iter().all(|r| r.passed) && !output.status.success() {
267                    return FileTestResults {
268                        path: path.to_path_buf(),
269                        tests: test_names
270                            .iter()
271                            .map(|name| TestResult {
272                                name: name.clone(),
273                                passed: false,
274                                duration_ms: 0,
275                                error_output: Some(format!("{}{}", stderr, stdout)),
276                            })
277                            .collect(),
278                        compile_error: None,
279                    };
280                }
281
282                FileTestResults {
283                    path: path.to_path_buf(),
284                    tests: results,
285                    compile_error: None,
286                }
287            }
288            Err(e) => FileTestResults {
289                path: path.to_path_buf(),
290                tests: vec![],
291                compile_error: Some(format!("Failed to run tests: {}", e)),
292            },
293        }
294    }
295
296    fn parse_test_output(
297        &self,
298        output: &str,
299        test_names: &[String],
300        _compile_time: u64,
301    ) -> Vec<TestResult> {
302        let mut results = Vec::new();
303
304        for test_name in test_names {
305            // Look for "test-name ... ok" or "test-name ... FAILED"
306            let passed = output
307                .lines()
308                .any(|line| line.contains(test_name) && line.contains("... ok"));
309
310            // For failures, capture the FAILED header line plus any
311            // indented detail lines that immediately follow it (runtime
312            // emits `expected X, got Y`-style lines indented under the
313            // header on the same stdout stream).
314            let error_output = if !passed {
315                collect_failure_block(output, test_name)
316            } else {
317                None
318            };
319
320            results.push(TestResult {
321                name: test_name.clone(),
322                passed,
323                duration_ms: 0, // Individual timing not available in batch mode
324                error_output,
325            });
326        }
327
328        results
329    }
330
331    /// Run tests and return summary
332    pub fn run(&self, paths: &[PathBuf]) -> TestSummary {
333        let test_files = self.discover_test_files(paths);
334        let mut summary = TestSummary::default();
335
336        for path in test_files {
337            let file_results = self.run_file(&path);
338
339            // Track compilation failures
340            if file_results.compile_error.is_some() {
341                summary.compile_failures += 1;
342            }
343
344            for test in &file_results.tests {
345                summary.total += 1;
346                if test.passed {
347                    summary.passed += 1;
348                } else {
349                    summary.failed += 1;
350                }
351            }
352
353            summary.file_results.push(file_results);
354        }
355
356        summary
357    }
358
359    /// Print test results
360    pub fn print_results(&self, summary: &TestSummary) {
361        for file_result in &summary.file_results {
362            if let Some(ref error) = file_result.compile_error {
363                eprintln!("\nFailed to process {}:", file_result.path.display());
364                eprintln!("  {}", error);
365                continue;
366            }
367
368            if file_result.tests.is_empty() {
369                continue;
370            }
371
372            println!("\nRunning tests in {}...", file_result.path.display());
373
374            for test in &file_result.tests {
375                let status = if test.passed { "ok" } else { "FAILED" };
376                if self.verbose {
377                    println!("  {} ... {} ({}ms)", test.name, status, test.duration_ms);
378                } else {
379                    println!("  {} ... {}", test.name, status);
380                }
381            }
382        }
383
384        // Print summary
385        println!("\n========================================");
386        if summary.compile_failures > 0 {
387            println!(
388                "Results: {} passed, {} failed, {} failed to compile",
389                summary.passed, summary.failed, summary.compile_failures
390            );
391        } else {
392            println!(
393                "Results: {} passed, {} failed",
394                summary.passed, summary.failed
395            );
396        }
397
398        // Print test failures in detail
399        let failures: Vec<_> = summary
400            .file_results
401            .iter()
402            .flat_map(|fr| fr.tests.iter().filter(|t| !t.passed).map(|t| (&fr.path, t)))
403            .collect();
404
405        if !failures.is_empty() {
406            println!("\nTEST FAILURES:\n");
407            for (path, test) in failures {
408                println!("{}::{}", path.display(), test.name);
409                if let Some(ref error) = test.error_output {
410                    for line in error.lines() {
411                        println!("  {}", line);
412                    }
413                }
414                println!();
415            }
416        }
417
418        // Print compilation failures in detail
419        let compile_failures: Vec<_> = summary
420            .file_results
421            .iter()
422            .filter(|fr| fr.compile_error.is_some())
423            .collect();
424
425        if !compile_failures.is_empty() {
426            println!("\nCOMPILATION FAILURES:\n");
427            for fr in compile_failures {
428                println!("{}:", fr.path.display());
429                if let Some(ref error) = fr.compile_error {
430                    for line in error.lines() {
431                        println!("  {}", line);
432                    }
433                }
434                println!();
435            }
436        }
437    }
438}
439
440/// Sanitize a test name for use as a filename
441fn sanitize_name(name: &str) -> String {
442    name.chars()
443        .map(|c| if c.is_alphanumeric() { c } else { '_' })
444        .collect()
445}
446
447/// Given the full test-wrapper stdout and a test name, find the
448/// `<name> ... FAILED` header line plus any indented detail lines
449/// that immediately follow it, and return them as a single block.
450///
451/// An indented detail line is any line that begins with whitespace.
452/// Collection stops at the next non-indented line (typically the next
453/// test's header, or the pass/fail summary).
454///
455/// Matches the header exactly (`{name} ... FAILED`) so one test name
456/// being a substring of another (e.g. `add` vs `add-overflow`) cannot
457/// cross-attribute the block.
458fn collect_failure_block(output: &str, test_name: &str) -> Option<String> {
459    let header = format!("{} ... FAILED", test_name);
460    let mut lines = output.lines().peekable();
461    while let Some(line) = lines.next() {
462        if line == header {
463            let mut block = String::from(line);
464            while let Some(next) = lines.peek() {
465                if next.starts_with(char::is_whitespace) {
466                    block.push('\n');
467                    block.push_str(next);
468                    lines.next();
469                } else {
470                    break;
471                }
472            }
473            return Some(block);
474        }
475    }
476    None
477}
478
479#[cfg(test)]
480mod tests;