1use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use std::time::Duration;
6use uuid::Uuid;
7
8pub mod config;
10pub mod runner;
11pub mod assertions;
12pub mod reporter;
13pub mod coverage;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub enum TestResult {
18 Passed,
19 Failed,
20 Skipped,
21 Pending,
22 Timeout,
23}
24
25impl std::fmt::Display for TestResult {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 match self {
28 TestResult::Passed => write!(f, "PASS"),
29 TestResult::Failed => write!(f, "FAIL"),
30 TestResult::Skipped => write!(f, "SKIP"),
31 TestResult::Pending => write!(f, "PEND"),
32 TestResult::Timeout => write!(f, "TIMEOUT"),
33 }
34 }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct TestCase {
40 pub id: String,
41 pub name: String,
42 pub description: Option<String>,
43 pub file_path: PathBuf,
44 pub line: usize,
45 pub result: TestResult,
46 pub duration: Duration,
47 pub error_message: Option<String>,
48}
49
50impl TestCase {
51 pub fn new(name: String, file_path: PathBuf, line: usize) -> Self {
52 Self {
53 id: Uuid::new_v4().to_string(),
54 name,
55 description: None,
56 file_path,
57 line,
58 result: TestResult::Pending,
59 duration: Duration::default(),
60 error_message: None,
61 }
62 }
63
64 pub fn pass(&mut self, duration: Duration) {
65 self.result = TestResult::Passed;
66 self.duration = duration;
67 }
68
69 pub fn fail(&mut self, error_message: String, duration: Duration) {
70 self.result = TestResult::Failed;
71 self.error_message = Some(error_message);
72 self.duration = duration;
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct TestSuite {
79 pub id: String,
80 pub name: String,
81 pub description: Option<String>,
82 pub file_path: PathBuf,
83 pub test_cases: Vec<TestCase>,
84 pub duration: Duration,
85}
86
87impl TestSuite {
88 pub fn new(name: String, file_path: PathBuf) -> Self {
89 Self {
90 id: Uuid::new_v4().to_string(),
91 name,
92 description: None,
93 file_path,
94 test_cases: Vec::new(),
95 duration: Duration::default(),
96 }
97 }
98
99 pub fn add_test_case(&mut self, test_case: TestCase) {
100 self.test_cases.push(test_case);
101 }
102
103 pub fn passed_count(&self) -> usize {
104 self.test_cases.iter().filter(|tc| tc.result == TestResult::Passed).count()
105 }
106
107 pub fn failed_count(&self) -> usize {
108 self.test_cases.iter().filter(|tc| tc.result == TestResult::Failed).count()
109 }
110
111 pub fn skipped_count(&self) -> usize {
112 self.test_cases.iter().filter(|tc| tc.result == TestResult::Skipped).count()
113 }
114
115 pub fn total_count(&self) -> usize {
116 self.test_cases.len()
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct TestRunResult {
123 pub id: String,
124 pub test_suites: Vec<TestSuite>,
125 pub start_time: chrono::DateTime<chrono::Utc>,
126 pub end_time: Option<chrono::DateTime<chrono::Utc>>,
127 pub total_duration: Duration,
128}
129
130impl TestRunResult {
131 pub fn new() -> Self {
132 Self {
133 id: Uuid::new_v4().to_string(),
134 test_suites: Vec::new(),
135 start_time: chrono::Utc::now(),
136 end_time: None,
137 total_duration: Duration::default(),
138 }
139 }
140
141 pub fn add_test_suite(&mut self, test_suite: TestSuite) {
142 self.test_suites.push(test_suite);
143 }
144
145 pub fn complete(&mut self) {
146 self.end_time = Some(chrono::Utc::now());
147 if let Some(end_time) = self.end_time {
148 self.total_duration = end_time.signed_duration_since(self.start_time).to_std().unwrap_or_default();
149 }
150 }
151
152 pub fn total_tests(&self) -> usize {
153 self.test_suites.iter().map(|suite| suite.total_count()).sum()
154 }
155
156 pub fn passed_tests(&self) -> usize {
157 self.test_suites.iter().map(|suite| suite.passed_count()).sum()
158 }
159
160 pub fn failed_tests(&self) -> usize {
161 self.test_suites.iter().map(|suite| suite.failed_count()).sum()
162 }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct TestConfig {
168 pub timeout: u64,
169 pub concurrency: usize,
170 pub filter: Option<String>,
171 pub verbose: bool,
172 pub coverage: bool,
173 pub exclude_patterns: Vec<String>,
174}
175
176impl Default for TestConfig {
177 fn default() -> Self {
178 Self {
179 timeout: 30,
180 concurrency: num_cpus::get(),
181 filter: None,
182 verbose: false,
183 coverage: false,
184 exclude_patterns: vec![
185 "node_modules".to_string(),
186 ".git".to_string(),
187 "target".to_string(),
188 "build".to_string(),
189 ],
190 }
191 }
192}
193
194#[derive(Debug)]
196pub struct TestRunner {
197 config: TestConfig,
198}
199
200impl TestRunner {
201 pub fn new(config: TestConfig) -> Self {
202 Self { config }
203 }
204
205 pub fn default() -> Self {
206 Self::new(TestConfig::default())
207 }
208
209 pub async fn run_tests(&self, patterns: Vec<String>) -> Result<TestRunResult, Box<dyn std::error::Error>> {
210 let mut result = TestRunResult::new();
211 let start_time = std::time::Instant::now();
212
213 let test_files = self.discover_test_files(&patterns).await?;
215
216 if test_files.is_empty() {
217 println!("No test files found.");
218 result.complete();
219 return Ok(result);
220 }
221
222 println!("Found {} test files", test_files.len());
223
224 for test_file in test_files {
226 let suite_result = self.run_test_file(&test_file).await?;
227 result.add_test_suite(suite_result);
228 }
229
230 result.total_duration = start_time.elapsed();
231 result.complete();
232
233 Ok(result)
234 }
235
236 async fn discover_test_files(&self, patterns: &[String]) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
237 let mut test_files = Vec::new();
238
239 if patterns.is_empty() {
240 test_files.extend(self.find_test_files(".", &["test_*.kotoba", "*_test.kotoba", "tests/**/*.kotoba"]).await?);
242 } else {
243 for pattern in patterns {
244 if std::path::Path::new(pattern).is_dir() {
245 test_files.extend(self.find_test_files(pattern, &["test_*.kotoba", "*_test.kotoba", "**/*.kotoba"]).await?);
246 } else {
247 test_files.push(PathBuf::from(pattern));
248 }
249 }
250 }
251
252 if let Some(filter) = &self.config.filter {
254 test_files.retain(|path| {
255 path.to_string_lossy().contains(filter)
256 });
257 }
258
259 Ok(test_files)
260 }
261
262 async fn find_test_files(&self, dir: &str, patterns: &[&str]) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
263 let mut test_files = Vec::new();
264
265 for pattern in patterns {
266 let glob_pattern = if dir == "." {
267 pattern.to_string()
268 } else {
269 format!("{}/{}", dir, pattern)
270 };
271
272 for entry in glob::glob(&glob_pattern).unwrap() {
274 if let Ok(path) = entry {
275 if path.is_file() && self.is_test_file(&path) {
276 test_files.push(path);
277 }
278 }
279 }
280 }
281
282 test_files.sort();
284 test_files.dedup();
285
286 Ok(test_files)
287 }
288
289 fn is_test_file(&self, path: &PathBuf) -> bool {
290 if let Some(file_name) = path.file_name() {
291 let file_name_str = file_name.to_string_lossy();
292
293 for pattern in &self.config.exclude_patterns {
295 if file_name_str.contains(pattern) {
296 return false;
297 }
298 }
299
300 path.extension().map_or(false, |ext| ext == "kotoba")
302 } else {
303 false
304 }
305 }
306
307 async fn run_test_file(&self, test_file: &PathBuf) -> Result<TestSuite, Box<dyn std::error::Error>> {
308 let suite_name = test_file.file_stem()
309 .unwrap_or_default()
310 .to_string_lossy()
311 .to_string();
312
313 let mut suite = TestSuite::new(suite_name, test_file.clone());
314
315 let content = tokio::fs::read_to_string(test_file).await?;
317
318 let test_cases = self.extract_and_run_tests(&content, test_file).await?;
320 suite.test_cases = test_cases;
321
322 Ok(suite)
323 }
324
325 async fn extract_and_run_tests(&self, content: &str, file_path: &PathBuf) -> Result<Vec<TestCase>, Box<dyn std::error::Error>> {
326 let mut test_cases = Vec::new();
327
328 let test_pattern = regex::Regex::new("(test_\\w+|describe|it)\\s*\\(\\s*[\"']([^\"']+)[\"']\\)?;")?;
330
331 for (line_num, line) in content.lines().enumerate() {
332 for cap in test_pattern.captures_iter(line) {
333 if let Some(test_name) = cap.get(2) {
334 let mut test_case = TestCase::new(
335 test_name.as_str().to_string(),
336 file_path.clone(),
337 line_num + 1
338 );
339
340 let start_time = std::time::Instant::now();
342 match self.run_single_test(&test_case).await {
343 Ok(_) => {
344 test_case.pass(start_time.elapsed());
345 }
346 Err(e) => {
347 test_case.fail(e.to_string(), start_time.elapsed());
348 }
349 }
350
351 test_cases.push(test_case);
352 }
353 }
354 }
355
356 if test_cases.is_empty() {
358 let mut test_case = TestCase::new(
359 "default_test".to_string(),
360 file_path.clone(),
361 1
362 );
363
364 let start_time = std::time::Instant::now();
365 test_case.pass(start_time.elapsed());
366 test_cases.push(test_case);
367 }
368
369 Ok(test_cases)
370 }
371
372 async fn run_single_test(&self, test_case: &TestCase) -> Result<(), Box<dyn std::error::Error>> {
373 tokio::time::timeout(
375 Duration::from_secs(self.config.timeout),
376 self.execute_test_logic(test_case)
377 ).await??;
378
379 Ok(())
380 }
381
382 async fn execute_test_logic(&self, test_case: &TestCase) -> Result<(), Box<dyn std::error::Error>> {
383 use std::collections::hash_map::DefaultHasher;
385 use std::hash::{Hash, Hasher};
386
387 let mut hasher = DefaultHasher::new();
388 test_case.name.hash(&mut hasher);
389 let hash = hasher.finish();
390
391 if hash % 10 == 0 {
392 return Err("Simulated test failure".into());
394 }
395
396 Ok(())
397 }
398}
399
400pub async fn run_tests(patterns: Vec<String>) -> Result<TestRunResult, Box<dyn std::error::Error>> {
402 let runner = TestRunner::default();
403 runner.run_tests(patterns).await
404}
405
406