1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use crate::adapters::{TestRunResult, TestStatus};
12use crate::error::TestxError;
13
14pub mod analytics;
15pub mod display;
16
17pub struct TestHistory {
19 data_dir: PathBuf,
21 runs: Vec<RunRecord>,
23 max_runs: usize,
25}
26
27#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
29pub struct RunRecord {
30 pub timestamp: String,
32 pub total: usize,
34 pub passed: usize,
36 pub failed: usize,
38 pub skipped: usize,
40 pub duration_ms: u64,
42 pub exit_code: i32,
44 pub tests: Vec<TestRecord>,
46}
47
48#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
50pub struct TestRecord {
51 pub name: String,
53 pub status: String,
55 pub duration_ms: u64,
57 pub error: Option<String>,
59}
60
61#[derive(Debug, Clone)]
63pub struct FlakyTest {
64 pub name: String,
66 pub pass_rate: f64,
68 pub total_runs: usize,
70 pub failures: usize,
72 pub recent_pattern: String,
74}
75
76#[derive(Debug, Clone)]
78pub struct SlowTest {
79 pub name: String,
81 pub avg_duration: Duration,
83 pub latest_duration: Duration,
85 pub trend: DurationTrend,
87 pub change_pct: f64,
89}
90
91#[derive(Debug, Clone, PartialEq)]
93pub enum DurationTrend {
94 Faster,
95 Slower,
96 Stable,
97}
98
99#[derive(Debug, Clone)]
101pub struct TestTrend {
102 pub timestamp: String,
104 pub status: String,
106 pub duration_ms: u64,
108}
109
110impl TestHistory {
111 pub fn open(dir: &Path) -> crate::error::Result<Self> {
113 let data_dir = dir.join(".testx");
114 let history_file = data_dir.join("history.json");
115
116 let runs = if history_file.exists() {
117 let content =
118 std::fs::read_to_string(&history_file).map_err(|e| TestxError::HistoryError {
119 message: format!("Failed to read history: {e}"),
120 })?;
121 serde_json::from_str(&content).unwrap_or_default()
122 } else {
123 Vec::new()
124 };
125
126 Ok(Self {
127 data_dir,
128 runs,
129 max_runs: 500,
130 })
131 }
132
133 pub fn new_in_memory() -> Self {
135 Self {
136 data_dir: PathBuf::from("/tmp/testx-history"),
137 runs: Vec::new(),
138 max_runs: 500,
139 }
140 }
141
142 pub fn record(&mut self, result: &TestRunResult) -> crate::error::Result<()> {
144 let record = RunRecord::from_result(result);
145 self.runs.push(record);
146
147 if self.runs.len() > self.max_runs {
149 let excess = self.runs.len() - self.max_runs;
150 self.runs.drain(..excess);
151 }
152
153 self.save()
154 }
155
156 fn save(&self) -> crate::error::Result<()> {
158 std::fs::create_dir_all(&self.data_dir).map_err(|e| TestxError::HistoryError {
159 message: format!("Failed to create history dir: {e}"),
160 })?;
161
162 let history_file = self.data_dir.join("history.json");
163 let content =
164 serde_json::to_string_pretty(&self.runs).map_err(|e| TestxError::HistoryError {
165 message: format!("Failed to serialize history: {e}"),
166 })?;
167
168 std::fs::write(&history_file, content).map_err(|e| TestxError::HistoryError {
169 message: format!("Failed to write history: {e}"),
170 })?;
171
172 Ok(())
173 }
174
175 pub fn run_count(&self) -> usize {
177 self.runs.len()
178 }
179
180 pub fn runs(&self) -> &[RunRecord] {
182 &self.runs
183 }
184
185 pub fn recent_runs(&self, n: usize) -> &[RunRecord] {
187 let start = self.runs.len().saturating_sub(n);
188 &self.runs[start..]
189 }
190
191 pub fn get_trend(&self, test_name: &str, last_n: usize) -> Vec<TestTrend> {
193 let runs = self.recent_runs(last_n);
194 let mut trend = Vec::new();
195
196 for run in runs {
197 if let Some(test) = run.tests.iter().find(|t| t.name == test_name) {
198 trend.push(TestTrend {
199 timestamp: run.timestamp.clone(),
200 status: test.status.clone(),
201 duration_ms: test.duration_ms,
202 });
203 }
204 }
205
206 trend
207 }
208
209 pub fn get_flaky_tests(&self, min_runs: usize, max_pass_rate: f64) -> Vec<FlakyTest> {
211 let recent = self.recent_runs(50);
212 let mut test_history: HashMap<String, Vec<bool>> = HashMap::new();
213
214 for run in recent {
215 for test in &run.tests {
216 let passed = test.status == "passed";
217 test_history
218 .entry(test.name.clone())
219 .or_default()
220 .push(passed);
221 }
222 }
223
224 let mut flaky = Vec::new();
225 for (name, results) in &test_history {
226 if results.len() < min_runs {
227 continue;
228 }
229
230 let passes = results.iter().filter(|&&r| r).count();
231 let pass_rate = passes as f64 / results.len() as f64;
232
233 if pass_rate > 0.0 && pass_rate < max_pass_rate {
235 let recent: String = results
236 .iter()
237 .rev()
238 .take(10)
239 .map(|&r| if r { 'P' } else { 'F' })
240 .collect();
241
242 flaky.push(FlakyTest {
243 name: name.clone(),
244 pass_rate,
245 total_runs: results.len(),
246 failures: results.len() - passes,
247 recent_pattern: recent,
248 });
249 }
250 }
251
252 flaky.sort_by(|a, b| {
253 a.pass_rate
254 .partial_cmp(&b.pass_rate)
255 .unwrap_or(std::cmp::Ordering::Equal)
256 });
257
258 flaky
259 }
260
261 pub fn get_slowest_trending(&self, last_n: usize, min_runs: usize) -> Vec<SlowTest> {
263 let recent = self.recent_runs(last_n);
264 let mut test_durations: HashMap<String, Vec<u64>> = HashMap::new();
265
266 for run in recent {
267 for test in &run.tests {
268 if test.status == "passed" {
269 test_durations
270 .entry(test.name.clone())
271 .or_default()
272 .push(test.duration_ms);
273 }
274 }
275 }
276
277 let mut slow_tests = Vec::new();
278 for (name, durations) in &test_durations {
279 if durations.len() < min_runs {
280 continue;
281 }
282
283 let avg: u64 = durations.iter().sum::<u64>() / durations.len() as u64;
284 let latest = *durations.last().unwrap_or(&0);
285
286 let change_pct = if avg > 0 {
287 (latest as f64 - avg as f64) / avg as f64 * 100.0
288 } else {
289 0.0
290 };
291
292 let trend = if change_pct > 20.0 {
293 DurationTrend::Slower
294 } else if change_pct < -20.0 {
295 DurationTrend::Faster
296 } else {
297 DurationTrend::Stable
298 };
299
300 slow_tests.push(SlowTest {
301 name: name.clone(),
302 avg_duration: Duration::from_millis(avg),
303 latest_duration: Duration::from_millis(latest),
304 trend,
305 change_pct,
306 });
307 }
308
309 slow_tests.sort_by(|a, b| {
310 b.change_pct
311 .partial_cmp(&a.change_pct)
312 .unwrap_or(std::cmp::Ordering::Equal)
313 });
314
315 slow_tests
316 }
317
318 pub fn prune(&mut self, keep: usize) -> crate::error::Result<usize> {
320 if self.runs.len() <= keep {
321 return Ok(0);
322 }
323 let removed = self.runs.len() - keep;
324 self.runs.drain(..removed);
325 self.save()?;
326 Ok(removed)
327 }
328
329 pub fn pass_rate(&self, last_n: usize) -> f64 {
331 let recent = self.recent_runs(last_n);
332 if recent.is_empty() {
333 return 0.0;
334 }
335
336 let total_passed: usize = recent.iter().map(|r| r.passed).sum();
337 let total_tests: usize = recent.iter().map(|r| r.total).sum();
338
339 if total_tests > 0 {
340 total_passed as f64 / total_tests as f64 * 100.0
341 } else {
342 0.0
343 }
344 }
345
346 pub fn avg_duration(&self, last_n: usize) -> Duration {
348 let recent = self.recent_runs(last_n);
349 if recent.is_empty() {
350 return Duration::ZERO;
351 }
352
353 let total_ms: u64 = recent.iter().map(|r| r.duration_ms).sum();
354 Duration::from_millis(total_ms / recent.len() as u64)
355 }
356}
357
358impl RunRecord {
359 pub fn from_result(result: &TestRunResult) -> Self {
361 let tests: Vec<TestRecord> = result
362 .suites
363 .iter()
364 .flat_map(|suite| {
365 suite.tests.iter().map(|test| {
366 let status = match test.status {
367 TestStatus::Passed => "passed",
368 TestStatus::Failed => "failed",
369 TestStatus::Skipped => "skipped",
370 };
371 TestRecord {
372 name: format!("{}::{}", suite.name, test.name),
373 status: status.to_string(),
374 duration_ms: test.duration.as_millis() as u64,
375 error: test.error.as_ref().map(|e| e.message.clone()),
376 }
377 })
378 })
379 .collect();
380
381 Self {
382 timestamp: chrono_now(),
383 total: result.total_tests(),
384 passed: result.total_passed(),
385 failed: result.total_failed(),
386 skipped: result.total_skipped(),
387 duration_ms: result.duration.as_millis() as u64,
388 exit_code: result.raw_exit_code,
389 tests,
390 }
391 }
392}
393
394fn chrono_now() -> String {
396 let duration = std::time::SystemTime::now()
397 .duration_since(std::time::UNIX_EPOCH)
398 .unwrap_or_default();
399 let secs = duration.as_secs();
400
401 let days = secs / 86400;
403 let time_secs = secs % 86400;
404 let hours = time_secs / 3600;
405 let minutes = (time_secs % 3600) / 60;
406 let seconds = time_secs % 60;
407
408 let (year, month, day) = days_to_date(days);
410
411 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
412}
413
414fn days_to_date(mut days: u64) -> (u64, u64, u64) {
416 let mut year = 1970;
417
418 loop {
419 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
420 if days < days_in_year {
421 break;
422 }
423 days -= days_in_year;
424 year += 1;
425 }
426
427 let month_days = if is_leap_year(year) {
428 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
429 } else {
430 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
431 };
432
433 let mut month = 1;
434 for &md in &month_days {
435 if days < md {
436 break;
437 }
438 days -= md;
439 month += 1;
440 }
441
442 (year, month, days + 1)
443}
444
445fn is_leap_year(year: u64) -> bool {
446 (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use crate::adapters::{TestCase, TestError, TestSuite};
453
454 fn make_test(name: &str, status: TestStatus, ms: u64) -> TestCase {
455 TestCase {
456 name: name.into(),
457 status,
458 duration: Duration::from_millis(ms),
459 error: None,
460 }
461 }
462
463 fn make_failed_test(name: &str, ms: u64, msg: &str) -> TestCase {
464 TestCase {
465 name: name.into(),
466 status: TestStatus::Failed,
467 duration: Duration::from_millis(ms),
468 error: Some(TestError {
469 message: msg.into(),
470 location: None,
471 }),
472 }
473 }
474
475 fn make_result(passed: usize, failed: usize, skipped: usize) -> TestRunResult {
476 let mut tests = Vec::new();
477 for i in 0..passed {
478 tests.push(make_test(
479 &format!("pass_{i}"),
480 TestStatus::Passed,
481 10 + i as u64,
482 ));
483 }
484 for i in 0..failed {
485 tests.push(make_failed_test(
486 &format!("fail_{i}"),
487 5,
488 "assertion failed",
489 ));
490 }
491 for i in 0..skipped {
492 tests.push(make_test(&format!("skip_{i}"), TestStatus::Skipped, 0));
493 }
494
495 TestRunResult {
496 suites: vec![TestSuite {
497 name: "suite".into(),
498 tests,
499 }],
500 duration: Duration::from_millis(100),
501 raw_exit_code: if failed > 0 { 1 } else { 0 },
502 }
503 }
504
505 #[test]
506 fn new_in_memory() {
507 let history = TestHistory::new_in_memory();
508 assert_eq!(history.run_count(), 0);
509 }
510
511 #[test]
512 fn record_run() {
513 let mut history = TestHistory::new_in_memory();
514 history
516 .runs
517 .push(RunRecord::from_result(&make_result(5, 1, 0)));
518 assert_eq!(history.run_count(), 1);
519 }
520
521 #[test]
522 fn run_record_from_result() {
523 let result = make_result(3, 1, 1);
524 let record = RunRecord::from_result(&result);
525 assert_eq!(record.total, 5);
526 assert_eq!(record.passed, 3);
527 assert_eq!(record.failed, 1);
528 assert_eq!(record.skipped, 1);
529 assert_eq!(record.tests.len(), 5);
530 }
531
532 #[test]
533 fn run_record_test_names() {
534 let result = make_result(2, 0, 0);
535 let record = RunRecord::from_result(&result);
536 assert_eq!(record.tests[0].name, "suite::pass_0");
537 assert_eq!(record.tests[1].name, "suite::pass_1");
538 }
539
540 #[test]
541 fn run_record_error_captured() {
542 let result = make_result(0, 1, 0);
543 let record = RunRecord::from_result(&result);
544 assert_eq!(record.tests[0].error.as_deref(), Some("assertion failed"));
545 }
546
547 #[test]
548 fn recent_runs() {
549 let mut history = TestHistory::new_in_memory();
550 for _ in 0..10 {
551 history
552 .runs
553 .push(RunRecord::from_result(&make_result(5, 0, 0)));
554 }
555 assert_eq!(history.recent_runs(3).len(), 3);
556 assert_eq!(history.recent_runs(20).len(), 10);
557 }
558
559 #[test]
560 fn get_trend() {
561 let mut history = TestHistory::new_in_memory();
562 for i in 0..5 {
563 let mut record = RunRecord::from_result(&make_result(3, 0, 0));
564 record.tests[0].duration_ms = 10 + i * 5;
565 history.runs.push(record);
566 }
567
568 let trend = history.get_trend("suite::pass_0", 10);
569 assert_eq!(trend.len(), 5);
570 assert_eq!(trend[0].duration_ms, 10);
571 assert_eq!(trend[4].duration_ms, 30);
572 }
573
574 #[test]
575 fn get_flaky_tests() {
576 let mut history = TestHistory::new_in_memory();
577
578 for i in 0..10 {
580 let status = if i % 2 == 0 {
581 TestStatus::Passed
582 } else {
583 TestStatus::Failed
584 };
585 let result = TestRunResult {
586 suites: vec![TestSuite {
587 name: "suite".into(),
588 tests: vec![TestCase {
589 name: "flaky_test".into(),
590 status,
591 duration: Duration::from_millis(10),
592 error: None,
593 }],
594 }],
595 duration: Duration::from_millis(50),
596 raw_exit_code: 0,
597 };
598 history.runs.push(RunRecord::from_result(&result));
599 }
600
601 let flaky = history.get_flaky_tests(5, 0.95);
602 assert!(!flaky.is_empty());
604 }
605
606 #[test]
607 fn get_flaky_no_flaky() {
608 let mut history = TestHistory::new_in_memory();
609 for _ in 0..10 {
610 history
611 .runs
612 .push(RunRecord::from_result(&make_result(5, 0, 0)));
613 }
614
615 let flaky = history.get_flaky_tests(5, 0.95);
616 assert!(flaky.is_empty());
617 }
618
619 #[test]
620 fn get_slowest_trending() {
621 let mut history = TestHistory::new_in_memory();
622
623 for i in 0..10 {
624 let mut record = RunRecord::from_result(&make_result(2, 0, 0));
625 record.tests[0].duration_ms = 100 + i * 50;
627 record.tests[1].duration_ms = 50; history.runs.push(record);
629 }
630
631 let slow = history.get_slowest_trending(10, 5);
632 assert!(!slow.is_empty());
633 let first = slow.iter().find(|s| s.name.contains("pass_0"));
635 assert!(first.is_some());
636 }
637
638 #[test]
639 fn pass_rate_all_pass() {
640 let mut history = TestHistory::new_in_memory();
641 for _ in 0..5 {
642 history
643 .runs
644 .push(RunRecord::from_result(&make_result(10, 0, 0)));
645 }
646 assert_eq!(history.pass_rate(10), 100.0);
647 }
648
649 #[test]
650 fn pass_rate_mixed() {
651 let mut history = TestHistory::new_in_memory();
652 history
653 .runs
654 .push(RunRecord::from_result(&make_result(8, 2, 0)));
655 assert!((history.pass_rate(10) - 80.0).abs() < 0.1);
656 }
657
658 #[test]
659 fn pass_rate_empty() {
660 let history = TestHistory::new_in_memory();
661 assert_eq!(history.pass_rate(10), 0.0);
662 }
663
664 #[test]
665 fn avg_duration() {
666 let mut history = TestHistory::new_in_memory();
667 for _ in 0..4 {
668 let mut record = RunRecord::from_result(&make_result(1, 0, 0));
669 record.duration_ms = 100;
670 history.runs.push(record);
671 }
672 assert_eq!(history.avg_duration(10), Duration::from_millis(100));
673 }
674
675 #[test]
676 fn prune_runs() {
677 let mut history = TestHistory::new_in_memory();
678 for _ in 0..20 {
679 history
680 .runs
681 .push(RunRecord::from_result(&make_result(1, 0, 0)));
682 }
683 let before = history.run_count();
685 history.runs.drain(..10);
686 assert_eq!(history.run_count(), before - 10);
687 }
688
689 #[test]
690 fn days_to_date_epoch() {
691 let (y, m, d) = days_to_date(0);
692 assert_eq!((y, m, d), (1970, 1, 1));
693 }
694
695 #[test]
696 fn days_to_date_known() {
697 let (y, m, d) = days_to_date(19723);
699 assert_eq!(y, 2024);
700 assert_eq!(m, 1);
701 assert_eq!(d, 1);
702 }
703
704 #[test]
705 fn leap_year() {
706 assert!(is_leap_year(2000));
707 assert!(is_leap_year(2024));
708 assert!(!is_leap_year(1900));
709 assert!(!is_leap_year(2023));
710 }
711
712 #[test]
713 fn chrono_now_format() {
714 let ts = chrono_now();
715 assert!(ts.contains('T'));
716 assert!(ts.ends_with('Z'));
717 assert_eq!(ts.len(), 20);
718 }
719
720 #[test]
721 fn duration_trend_variants() {
722 assert_eq!(DurationTrend::Faster, DurationTrend::Faster);
723 assert_ne!(DurationTrend::Faster, DurationTrend::Slower);
724 }
725}