1use crate::parser::Parser;
6use crate::types::{Effect, StackType};
7use crate::{CompilerConfig, compile_file_with_config};
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::time::Instant;
12
13#[derive(Debug)]
15pub struct TestResult {
16 pub name: String,
18 pub passed: bool,
20 pub duration_ms: u64,
22 pub error_output: Option<String>,
24}
25
26#[derive(Debug, Default)]
28pub struct TestSummary {
29 pub total: usize,
31 pub passed: usize,
33 pub failed: usize,
35 pub compile_failures: usize,
37 pub file_results: Vec<FileTestResults>,
39}
40
41impl TestSummary {
42 pub fn has_failures(&self) -> bool {
44 self.failed > 0 || self.compile_failures > 0
45 }
46}
47
48#[derive(Debug)]
50pub struct FileTestResults {
51 pub path: PathBuf,
53 pub tests: Vec<TestResult>,
55 pub skipped: Vec<SkippedTest>,
60 pub compile_error: Option<String>,
62}
63
64#[derive(Debug, Clone)]
67pub struct SkippedTest {
68 pub name: String,
70 pub reason: String,
73}
74
75pub struct TestRunner {
77 pub verbose: bool,
79 pub filter: Option<String>,
81 pub config: CompilerConfig,
83}
84
85impl TestRunner {
86 pub fn new(verbose: bool, filter: Option<String>) -> Self {
87 Self {
88 verbose,
89 filter,
90 config: CompilerConfig::default(),
91 }
92 }
93
94 pub fn discover_test_files(&self, paths: &[PathBuf]) -> Vec<PathBuf> {
96 let mut test_files = Vec::new();
97
98 for path in paths {
99 if path.is_file() {
100 if self.is_test_file(path) {
101 test_files.push(path.clone());
102 }
103 } else if path.is_dir() {
104 self.discover_in_directory(path, &mut test_files);
105 }
106 }
107
108 test_files.sort();
109 test_files
110 }
111
112 pub fn validate_paths(&self, paths: &[PathBuf]) -> Result<(), String> {
120 for path in paths {
121 let looks_like_seq_file = path.extension().and_then(|e| e.to_str()) == Some("seq");
122 if looks_like_seq_file && !self.is_test_file(path) {
123 return Err(format!(
124 "Test files must be named `test-*.seq`. Got: `{}`",
125 path.display()
126 ));
127 }
128 }
129 Ok(())
130 }
131
132 fn is_test_file(&self, path: &Path) -> bool {
133 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
134 name.starts_with("test-") && name.ends_with(".seq")
135 } else {
136 false
137 }
138 }
139
140 fn discover_in_directory(&self, dir: &Path, files: &mut Vec<PathBuf>) {
141 if let Ok(entries) = fs::read_dir(dir) {
142 for entry in entries.flatten() {
143 let path = entry.path();
144 if path.is_file() && self.is_test_file(&path) {
145 files.push(path);
146 } else if path.is_dir() {
147 self.discover_in_directory(&path, files);
148 }
149 }
150 }
151 }
152
153 pub fn discover_test_functions(
164 &self,
165 source: &str,
166 ) -> Result<(Vec<String>, Vec<SkippedTest>, bool), String> {
167 let mut parser = Parser::new(source);
168 let program = parser.parse()?;
169
170 let has_main = program.words.iter().any(|w| w.name == "main");
171
172 let mut test_names: Vec<String> = Vec::new();
173 let mut skipped: Vec<SkippedTest> = Vec::new();
174
175 for w in &program.words {
176 if !w.name.starts_with("test-") {
177 continue;
178 }
179 if !self.matches_filter(&w.name) {
180 continue;
181 }
182 match &w.effect {
183 Some(eff) if is_unit_effect(eff) => {
184 test_names.push(w.name.clone());
185 }
186 Some(eff) => {
187 skipped.push(SkippedTest {
188 name: w.name.clone(),
189 reason: format_effect_surface(eff),
190 });
191 }
192 None => {
193 skipped.push(SkippedTest {
194 name: w.name.clone(),
195 reason: "no stack effect declared".to_string(),
196 });
197 }
198 }
199 }
200
201 test_names.sort();
202 skipped.sort_by(|a, b| a.name.cmp(&b.name));
203 Ok((test_names, skipped, has_main))
204 }
205
206 fn matches_filter(&self, name: &str) -> bool {
207 match &self.filter {
208 Some(pattern) => name.contains(pattern),
209 None => true,
210 }
211 }
212
213 pub fn run_file(&self, path: &Path) -> FileTestResults {
215 let source = match fs::read_to_string(path) {
216 Ok(s) => s,
217 Err(e) => {
218 return FileTestResults {
219 path: path.to_path_buf(),
220 tests: vec![],
221 skipped: vec![],
222 compile_error: Some(format!("Failed to read file: {}", e)),
223 };
224 }
225 };
226
227 let (test_names, skipped, has_main) = match self.discover_test_functions(&source) {
228 Ok(result) => result,
229 Err(e) => {
230 return FileTestResults {
231 path: path.to_path_buf(),
232 tests: vec![],
233 skipped: vec![],
234 compile_error: Some(format!("Parse error: {}", e)),
235 };
236 }
237 };
238
239 if has_main {
241 return FileTestResults {
242 path: path.to_path_buf(),
243 tests: vec![],
244 skipped,
245 compile_error: None,
246 };
247 }
248
249 if test_names.is_empty() {
250 return FileTestResults {
251 path: path.to_path_buf(),
252 tests: vec![],
253 skipped,
254 compile_error: None,
255 };
256 }
257
258 let mut results = self.run_all_tests_in_file(path, &source, &test_names);
260 results.skipped = skipped;
261 results
262 }
263
264 fn run_all_tests_in_file(
265 &self,
266 path: &Path,
267 source: &str,
268 test_names: &[String],
269 ) -> FileTestResults {
270 let start = Instant::now();
271
272 let mut test_calls = String::new();
280 for test_name in test_names {
281 test_calls.push_str(&format!(
282 " \"{0}\" test.init {0} \"{0}\" test.set-name test.finish\n",
283 test_name
284 ));
285 }
286
287 let wrapper = format!(
288 r#"{}
289
290: main ( -- )
291{} test.has-failures [ 1 os.exit ] [ ] if
292;
293"#,
294 source, test_calls
295 );
296
297 let temp_dir = std::env::temp_dir();
299 let file_id = sanitize_name(&path.to_string_lossy());
300 let wrapper_path = temp_dir.join(format!("seq_test_{}.seq", file_id));
301 let binary_path = temp_dir.join(format!("seq_test_{}", file_id));
302
303 if let Err(e) = fs::write(&wrapper_path, &wrapper) {
304 return FileTestResults {
305 path: path.to_path_buf(),
306 tests: vec![],
307 skipped: vec![],
308 compile_error: Some(format!("Failed to write temp file: {}", e)),
309 };
310 }
311
312 if let Err(e) = compile_file_with_config(&wrapper_path, &binary_path, false, &self.config) {
314 let _ = fs::remove_file(&wrapper_path);
315 return FileTestResults {
316 path: path.to_path_buf(),
317 tests: vec![],
318 skipped: vec![],
319 compile_error: Some(format!("Compilation error: {}", e)),
320 };
321 }
322
323 let output = Command::new(&binary_path).output();
325
326 let _ = fs::remove_file(&wrapper_path);
328 let _ = fs::remove_file(&binary_path);
329
330 let compile_time = start.elapsed().as_millis() as u64;
331
332 match output {
333 Ok(output) => {
334 let stdout = String::from_utf8_lossy(&output.stdout);
335 let stderr = String::from_utf8_lossy(&output.stderr);
336
337 let results = self.parse_test_output(&stdout, test_names, compile_time);
340
341 if results.iter().all(|r| r.passed) && !output.status.success() {
343 return FileTestResults {
344 path: path.to_path_buf(),
345 tests: test_names
346 .iter()
347 .map(|name| TestResult {
348 name: name.clone(),
349 passed: false,
350 duration_ms: 0,
351 error_output: Some(format!("{}{}", stderr, stdout)),
352 })
353 .collect(),
354 skipped: vec![],
355 compile_error: None,
356 };
357 }
358
359 FileTestResults {
360 path: path.to_path_buf(),
361 tests: results,
362 skipped: vec![],
363 compile_error: None,
364 }
365 }
366 Err(e) => FileTestResults {
367 path: path.to_path_buf(),
368 tests: vec![],
369 skipped: vec![],
370 compile_error: Some(format!("Failed to run tests: {}", e)),
371 },
372 }
373 }
374
375 fn parse_test_output(
376 &self,
377 output: &str,
378 test_names: &[String],
379 _compile_time: u64,
380 ) -> Vec<TestResult> {
381 let mut results = Vec::new();
382
383 for test_name in test_names {
384 let passed = output
386 .lines()
387 .any(|line| line.contains(test_name) && line.contains("... ok"));
388
389 let error_output = if !passed {
394 collect_failure_block(output, test_name)
395 } else {
396 None
397 };
398
399 results.push(TestResult {
400 name: test_name.clone(),
401 passed,
402 duration_ms: 0, error_output,
404 });
405 }
406
407 results
408 }
409
410 pub fn run(&self, paths: &[PathBuf]) -> TestSummary {
412 let test_files = self.discover_test_files(paths);
413 let mut summary = TestSummary::default();
414
415 for path in test_files {
416 let file_results = self.run_file(&path);
417
418 if file_results.compile_error.is_some() {
420 summary.compile_failures += 1;
421 }
422
423 for test in &file_results.tests {
424 summary.total += 1;
425 if test.passed {
426 summary.passed += 1;
427 } else {
428 summary.failed += 1;
429 }
430 }
431
432 summary.file_results.push(file_results);
433 }
434
435 summary
436 }
437
438 pub fn print_results(&self, summary: &TestSummary) {
440 for file_result in &summary.file_results {
441 if let Some(ref error) = file_result.compile_error {
442 eprintln!("\nFailed to process {}:", file_result.path.display());
443 eprintln!(" {}", error);
444 continue;
445 }
446
447 if file_result.tests.is_empty() && file_result.skipped.is_empty() {
448 continue;
449 }
450
451 println!("\nRunning tests in {}...", file_result.path.display());
452
453 for test in &file_result.tests {
454 let status = if test.passed { "ok" } else { "FAILED" };
455 if self.verbose {
456 println!(" {} ... {} ({}ms)", test.name, status, test.duration_ms);
457 } else {
458 println!(" {} ... {}", test.name, status);
459 }
460 }
461
462 for s in &file_result.skipped {
463 println!(
464 " {} ... skipped — name starts with `test-` but stack effect is {}, not ( -- ). Rename if it's a helper; fix the signature if it's a test.",
465 s.name, s.reason
466 );
467 }
468 }
469
470 println!("\n========================================");
472 if summary.compile_failures > 0 {
473 println!(
474 "Results: {} passed, {} failed, {} failed to compile",
475 summary.passed, summary.failed, summary.compile_failures
476 );
477 } else {
478 println!(
479 "Results: {} passed, {} failed",
480 summary.passed, summary.failed
481 );
482 }
483
484 let failures: Vec<_> = summary
486 .file_results
487 .iter()
488 .flat_map(|fr| fr.tests.iter().filter(|t| !t.passed).map(|t| (&fr.path, t)))
489 .collect();
490
491 if !failures.is_empty() {
492 println!("\nTEST FAILURES:\n");
493 for (path, test) in failures {
494 println!("{}::{}", path.display(), test.name);
495 if let Some(ref error) = test.error_output {
496 for line in error.lines() {
497 println!(" {}", line);
498 }
499 }
500 println!();
501 }
502 }
503
504 let compile_failures: Vec<_> = summary
506 .file_results
507 .iter()
508 .filter(|fr| fr.compile_error.is_some())
509 .collect();
510
511 if !compile_failures.is_empty() {
512 println!("\nCOMPILATION FAILURES:\n");
513 for fr in compile_failures {
514 println!("{}:", fr.path.display());
515 if let Some(ref error) = fr.compile_error {
516 for line in error.lines() {
517 println!(" {}", line);
518 }
519 }
520 println!();
521 }
522 }
523 }
524}
525
526fn sanitize_name(name: &str) -> String {
528 name.chars()
529 .map(|c| if c.is_alphanumeric() { c } else { '_' })
530 .collect()
531}
532
533fn is_unit_effect(eff: &Effect) -> bool {
539 fn no_concrete_types(st: &StackType) -> bool {
540 !matches!(st, StackType::Cons { .. })
541 }
542 no_concrete_types(&eff.inputs) && no_concrete_types(&eff.outputs) && eff.effects.is_empty()
543}
544
545fn format_effect_surface(eff: &Effect) -> String {
552 fn split(st: &StackType) -> (Option<&str>, Vec<String>) {
555 let mut types: Vec<String> = Vec::new();
556 let mut cur = st;
557 loop {
558 match cur {
559 StackType::Empty => return (None, types_bottom_first(types)),
560 StackType::RowVar(name) => {
561 return (Some(name.as_str()), types_bottom_first(types));
562 }
563 StackType::Cons { rest, top } => {
564 types.push(format!("{}", top));
565 cur = rest;
566 }
567 }
568 }
569 }
570 fn types_bottom_first(mut top_down: Vec<String>) -> Vec<String> {
571 top_down.reverse();
572 top_down
573 }
574 let (in_rv, in_types) = split(&eff.inputs);
575 let (out_rv, out_types) = split(&eff.outputs);
576 let show_row = in_rv != out_rv;
579
580 let render = |rv: Option<&str>, types: &[String]| -> String {
581 let mut parts: Vec<String> = Vec::new();
582 if show_row && let Some(name) = rv {
583 parts.push(format!("..{}", name));
584 }
585 parts.extend(types.iter().cloned());
586 parts.join(" ")
587 };
588 let inp = render(in_rv, &in_types);
589 let out = render(out_rv, &out_types);
590 let inp_sep = if inp.is_empty() { "" } else { " " };
591 let out_sep = if out.is_empty() { "" } else { " " };
592 if eff.effects.is_empty() {
593 format!("( {}{}-- {}{})", inp, inp_sep, out, out_sep)
594 } else {
595 let effs: Vec<String> = eff.effects.iter().map(|e| format!("{}", e)).collect();
596 format!(
597 "( {}{}-- {}{}| {} )",
598 inp,
599 inp_sep,
600 out,
601 out_sep,
602 effs.join(" ")
603 )
604 }
605}
606
607fn collect_failure_block(output: &str, test_name: &str) -> Option<String> {
619 let header = format!("{} ... FAILED", test_name);
620 let mut lines = output.lines().peekable();
621 while let Some(line) = lines.next() {
622 if line == header {
623 let mut block = String::from(line);
624 while let Some(next) = lines.peek() {
625 if next.starts_with(char::is_whitespace) {
626 block.push('\n');
627 block.push_str(next);
628 lines.next();
629 } else {
630 break;
631 }
632 }
633 return Some(block);
634 }
635 }
636 None
637}
638
639#[cfg(test)]
640mod tests;