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