1use 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#[derive(Debug)]
14pub struct TestResult {
15 pub name: String,
17 pub passed: bool,
19 pub duration_ms: u64,
21 pub error_output: Option<String>,
23}
24
25#[derive(Debug, Default)]
27pub struct TestSummary {
28 pub total: usize,
30 pub passed: usize,
32 pub failed: usize,
34 pub compile_failures: usize,
36 pub file_results: Vec<FileTestResults>,
38}
39
40impl TestSummary {
41 pub fn has_failures(&self) -> bool {
43 self.failed > 0 || self.compile_failures > 0
44 }
45}
46
47#[derive(Debug)]
49pub struct FileTestResults {
50 pub path: PathBuf,
52 pub tests: Vec<TestResult>,
54 pub compile_error: Option<String>,
56}
57
58pub struct TestRunner {
60 pub verbose: bool,
62 pub filter: Option<String>,
64 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 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 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 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 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 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 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 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 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 let output = Command::new(&binary_path).output();
249
250 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 let results = self.parse_test_output(&stdout, test_names, compile_time);
264
265 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 let passed = output
307 .lines()
308 .any(|line| line.contains(test_name) && line.contains("... ok"));
309
310 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, error_output,
325 });
326 }
327
328 results
329 }
330
331 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 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 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 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 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 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
440fn sanitize_name(name: &str) -> String {
442 name.chars()
443 .map(|c| if c.is_alphanumeric() { c } else { '_' })
444 .collect()
445}
446
447fn 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;