1use serde::{Deserialize, Serialize};
20use std::fmt;
21use std::fs::{File, OpenOptions};
22use std::io::Write;
23use std::path::{Path, PathBuf};
24use std::time::{Duration, Instant};
25
26pub fn normalize_pci_bus_id(raw: &str) -> Result<String, String> {
50 let mut s = raw.trim().to_lowercase();
51 s = s.split_whitespace().next().unwrap_or("").to_string();
53 if s.starts_with("0000:") {
55 s = s[5..].to_string();
56 }
57 if is_valid_pci_bus_id(&s) {
59 Ok(s)
60 } else {
61 Err(format!("Invalid PCI bus ID: '{s}'"))
62 }
63}
64
65fn is_valid_pci_bus_id(s: &str) -> bool {
67 let bytes = s.as_bytes();
68 if bytes.len() != 7 {
69 return false;
70 }
71 is_hex_digit(bytes[0])
74 && is_hex_digit(bytes[1])
75 && bytes[2] == b':'
76 && is_hex_digit(bytes[3])
77 && is_hex_digit(bytes[4])
78 && bytes[5] == b'.'
79 && bytes[6] >= b'0'
80 && bytes[6] <= b'7'
81}
82
83#[inline]
84fn is_hex_digit(b: u8) -> bool {
85 b.is_ascii_hexdigit()
86}
87
88#[derive(Debug, Clone)]
100pub struct BenchTimer {
101 start: Instant,
102 label: String,
103}
104
105impl BenchTimer {
106 pub fn start(label: &str) -> Self {
108 Self {
109 start: Instant::now(),
110 label: label.to_string(),
111 }
112 }
113
114 pub fn start_default() -> Self {
116 Self::start("benchmark")
117 }
118
119 pub fn elapsed(&self) -> Duration {
121 self.start.elapsed()
122 }
123
124 pub fn elapsed_ms(&self) -> u128 {
126 self.elapsed().as_millis()
127 }
128
129 pub fn elapsed_secs(&self) -> f64 {
131 self.elapsed().as_secs_f64()
132 }
133
134 pub fn label(&self) -> &str {
136 &self.label
137 }
138
139 pub fn reset(&mut self) {
141 self.start = Instant::now();
142 }
143}
144
145pub fn format_elapsed(duration: Duration) -> String {
158 let ms = duration.as_millis();
159 if ms < 1000 {
160 format!("{ms}ms")
161 } else {
162 format!("{:.3}s", duration.as_secs_f64())
163 }
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
181pub struct BenchmarkResult {
182 pub name: String,
184 pub value: f64,
186 pub unit: String,
188 pub timestamp: String,
190 #[serde(default, skip_serializing_if = "String::is_empty")]
192 pub gpu_id: String,
193 #[serde(default = "default_true")]
195 pub success: bool,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub error: Option<String>,
199}
200
201fn default_true() -> bool {
202 true
203}
204
205impl BenchmarkResult {
206 pub fn new(name: &str, value: f64, unit: &str) -> Self {
208 Self {
209 name: name.to_string(),
210 value,
211 unit: unit.to_string(),
212 timestamp: now_timestamp(),
213 gpu_id: String::new(),
214 success: true,
215 error: None,
216 }
217 }
218
219 pub fn with_gpu(name: &str, value: f64, unit: &str, gpu_id: &str) -> Self {
221 Self {
222 gpu_id: gpu_id.to_string(),
223 ..Self::new(name, value, unit)
224 }
225 }
226
227 pub fn failed(name: &str, error: &str) -> Self {
229 Self {
230 name: name.to_string(),
231 value: 0.0,
232 unit: String::new(),
233 timestamp: now_timestamp(),
234 gpu_id: String::new(),
235 success: false,
236 error: Some(error.to_string()),
237 }
238 }
239
240 pub fn to_json(&self) -> Result<String, serde_json::Error> {
242 serde_json::to_string(self)
243 }
244
245 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
247 serde_json::to_string_pretty(self)
248 }
249
250 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
252 serde_json::from_str(json)
253 }
254}
255
256impl fmt::Display for BenchmarkResult {
257 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258 if self.success {
259 write!(f, "{}: {:.3} {}", self.name, self.value, self.unit)
260 } else {
261 write!(
262 f,
263 "{}: FAILED ({})",
264 self.name,
265 self.error.as_deref().unwrap_or("unknown error")
266 )
267 }
268 }
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
278pub struct BenchmarkResults {
279 #[serde(default, skip_serializing_if = "String::is_empty")]
281 pub name: String,
282 pub results: Vec<BenchmarkResult>,
284 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
286 pub metadata: serde_json::Value,
287}
288
289impl BenchmarkResults {
290 pub fn new(name: &str) -> Self {
292 Self {
293 name: name.to_string(),
294 results: Vec::new(),
295 metadata: serde_json::Value::Null,
296 }
297 }
298
299 pub fn add(&mut self, result: BenchmarkResult) {
301 self.results.push(result);
302 }
303
304 pub fn to_json(&self) -> Result<String, serde_json::Error> {
306 serde_json::to_string(self)
307 }
308
309 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
311 serde_json::to_string_pretty(self)
312 }
313
314 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
316 serde_json::from_str(json)
317 }
318
319 pub fn len(&self) -> usize {
321 self.results.len()
322 }
323
324 pub fn is_empty(&self) -> bool {
326 self.results.is_empty()
327 }
328}
329
330pub fn now_timestamp() -> String {
350 let now = std::time::SystemTime::now();
351 let duration = now
352 .duration_since(std::time::UNIX_EPOCH)
353 .unwrap_or_default();
354 let secs = duration.as_secs() as i64;
355
356 let (year, month, day, hour, minute, second) = unix_to_datetime(secs);
358 format!(
359 "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
360 year, month, day, hour, minute, second
361 )
362}
363
364fn unix_to_datetime(secs: i64) -> (i64, i64, i64, i64, i64, i64) {
368 let mut remaining = secs;
369 let second = remaining % 60;
370 remaining /= 60;
371 let minute = remaining % 60;
372 remaining /= 60;
373 let hour = remaining % 24;
374 let mut days = remaining / 24;
375
376 let mut year = 1970i64;
378 loop {
379 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
380 if days < days_in_year {
381 break;
382 }
383 days -= days_in_year;
384 year += 1;
385 }
386
387 let month_days: [i64; 12] = if is_leap_year(year) {
388 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
389 } else {
390 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
391 };
392
393 let mut month = 1i64;
394 for &md in &month_days {
395 if days < md {
396 break;
397 }
398 days -= md;
399 month += 1;
400 }
401 let day = days + 1;
402
403 (year, month, day, hour, minute, second)
404}
405
406fn is_leap_year(year: i64) -> bool {
407 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
408}
409
410pub fn format_timestamp_prefix() -> String {
414 format!("[{}] ", now_timestamp())
415}
416
417#[derive(Debug)]
429pub struct BenchLogger {
430 log_file: Option<PathBuf>,
432 color_enabled: bool,
434}
435
436struct BenchColors {
438 red: &'static str,
439 green: &'static str,
440 yellow: &'static str,
441 blue: &'static str,
442 reset: &'static str,
443}
444
445const COLOR: BenchColors = BenchColors {
446 red: "\x1b[0;31m",
447 green: "\x1b[0;32m",
448 yellow: "\x1b[0;33m",
449 blue: "\x1b[0;34m",
450 reset: "\x1b[0m",
451};
452
453const NO_COLOR: BenchColors = BenchColors {
454 red: "",
455 green: "",
456 yellow: "",
457 blue: "",
458 reset: "",
459};
460
461impl BenchLogger {
462 pub fn new() -> Self {
464 Self {
465 log_file: None,
466 color_enabled: color_output_enabled(),
467 }
468 }
469
470 pub fn with_file<P: AsRef<Path>>(path: P) -> Self {
474 Self {
475 log_file: Some(path.as_ref().to_path_buf()),
476 color_enabled: color_output_enabled(),
477 }
478 }
479
480 pub fn set_log_file<P: AsRef<Path>>(&mut self, path: P) {
482 self.log_file = Some(path.as_ref().to_path_buf());
483 }
484
485 pub fn clear_log_file(&mut self) {
487 self.log_file = None;
488 }
489
490 pub fn log_file_path(&self) -> Option<&Path> {
492 self.log_file.as_deref()
493 }
494
495 pub fn log(&self, message: &str) {
499 let line = format!("[{}] {}", now_timestamp(), message);
500 eprintln!("{}", line);
501
502 if let Some(ref path) = self.log_file {
503 if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
504 let stripped = strip_ansi_escapes(&line);
506 let _ = writeln!(file, "{}", stripped);
507 }
508 }
509 }
510
511 pub fn info(&self, message: &str) {
513 let c = self.colors();
514 self.log(&format!("{}INFO{} {}", c.blue, c.reset, message));
515 }
516
517 pub fn warn(&self, message: &str) {
519 let c = self.colors();
520 self.log(&format!("{}WARN{} {}", c.yellow, c.reset, message));
521 }
522
523 pub fn error(&self, message: &str) {
525 let c = self.colors();
526 self.log(&format!("{}ERROR{} {}", c.red, c.reset, message));
527 }
528
529 pub fn success(&self, message: &str) {
531 let c = self.colors();
532 self.log(&format!("{}OK{} {}", c.green, c.reset, message));
533 }
534
535 fn colors(&self) -> &'static BenchColors {
536 if self.color_enabled {
537 &COLOR
538 } else {
539 &NO_COLOR
540 }
541 }
542}
543
544impl Default for BenchLogger {
545 fn default() -> Self {
546 Self::new()
547 }
548}
549
550fn color_output_enabled() -> bool {
554 if std::env::var("NO_COLOR").is_ok() {
555 return false;
556 }
557 use std::io::IsTerminal;
558 std::io::stderr().is_terminal()
559}
560
561fn strip_ansi_escapes(s: &str) -> String {
563 let mut result = String::with_capacity(s.len());
564 let mut chars = s.chars().peekable();
565 while let Some(ch) = chars.next() {
566 if ch == '\x1b' {
567 if chars.peek() == Some(&'[') {
569 chars.next();
570 while let Some(&c) = chars.peek() {
572 chars.next();
573 if c.is_ascii_alphabetic() {
574 break;
575 }
576 }
577 }
578 } else {
579 result.push(ch);
580 }
581 }
582 result
583}
584
585pub fn resolve_log_dir() -> PathBuf {
598 if let Ok(env_dir) = std::env::var("MLSTACK_LOG_DIR") {
599 let trimmed = env_dir.trim();
600 if !trimmed.is_empty() {
601 let path = PathBuf::from(trimmed);
602 if ensure_dir(&path) {
603 return path;
604 }
605 }
606 }
607
608 if let Ok(home) = std::env::var("HOME") {
609 let trimmed = home.trim();
610 if !trimmed.is_empty() {
611 let path = PathBuf::from(trimmed).join(".rusty-stack").join("logs");
612 if ensure_dir(&path) {
613 return path;
614 }
615 }
616 }
617
618 let tmp = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string());
619 let path = PathBuf::from(tmp.trim()).join("rusty-stack").join("logs");
620 let _ = std::fs::create_dir_all(&path);
621 path
622}
623
624fn ensure_dir(path: &Path) -> bool {
626 if std::fs::create_dir_all(path).is_ok() {
627 let probe = path.join(".bench_write_probe");
629 if let Ok(f) = File::create(&probe) {
630 let _ = f;
631 let _ = std::fs::remove_file(&probe);
632 return true;
633 }
634 }
635 false
636}
637
638pub fn is_dry_run() -> bool {
644 std::env::var("DRY_RUN")
645 .map(|v| v.eq_ignore_ascii_case("true"))
646 .unwrap_or(false)
647}
648
649#[cfg(test)]
654mod tests {
655 use super::*;
656 use std::fs;
657 use tempfile::TempDir;
658
659 #[test]
662 fn test_pci_normalize_strips_domain_prefix() {
663 assert_eq!(
664 normalize_pci_bus_id("0000:01:00.0"),
665 Ok("01:00.0".to_string())
666 );
667 }
668
669 #[test]
670 fn test_pci_normalize_already_short() {
671 assert_eq!(normalize_pci_bus_id("01:00.0"), Ok("01:00.0".to_string()));
672 }
673
674 #[test]
675 fn test_pci_normalize_uppercase() {
676 assert_eq!(
677 normalize_pci_bus_id("0000:0A:1B.2"),
678 Ok("0a:1b.2".to_string())
679 );
680 }
681
682 #[test]
683 fn test_pci_normalize_mixed_case() {
684 assert_eq!(normalize_pci_bus_id("0A:1B.3"), Ok("0a:1b.3".to_string()));
685 }
686
687 #[test]
688 fn test_pci_normalize_with_whitespace() {
689 assert_eq!(
690 normalize_pci_bus_id(" 0000:03:00.0 "),
691 Ok("03:00.0".to_string())
692 );
693 }
694
695 #[test]
696 fn test_pci_normalize_with_trailing_text() {
697 assert_eq!(
699 normalize_pci_bus_id("0000:05:00.0 some extra text"),
700 Ok("05:00.0".to_string())
701 );
702 }
703
704 #[test]
705 fn test_pci_normalize_full_domain_format() {
706 assert_eq!(
707 normalize_pci_bus_id("0000:0f:00.0"),
708 Ok("0f:00.0".to_string())
709 );
710 }
711
712 #[test]
713 fn test_pci_normalize_various_valid() {
714 assert_eq!(normalize_pci_bus_id("00:00.0"), Ok("00:00.0".to_string()));
715 assert_eq!(normalize_pci_bus_id("ff:ff.7"), Ok("ff:ff.7".to_string()));
716 assert_eq!(normalize_pci_bus_id("ab:cd.0"), Ok("ab:cd.0".to_string()));
717 }
718
719 #[test]
720 fn test_pci_normalize_invalid_empty() {
721 assert!(normalize_pci_bus_id("").is_err());
722 }
723
724 #[test]
725 fn test_pci_normalize_invalid_garbage() {
726 assert!(normalize_pci_bus_id("not-a-bus-id").is_err());
727 }
728
729 #[test]
730 fn test_pci_normalize_invalid_function_too_high() {
731 assert!(normalize_pci_bus_id("01:00.8").is_err());
733 }
734
735 #[test]
736 fn test_pci_normalize_invalid_missing_parts() {
737 assert!(normalize_pci_bus_id("01:00").is_err());
738 assert!(normalize_pci_bus_id("01").is_err());
739 }
740
741 #[test]
744 fn test_timer_elapsed_increases() {
745 let timer = BenchTimer::start("test");
746 let e1 = timer.elapsed_ms();
747 std::thread::sleep(std::time::Duration::from_millis(10));
748 let e2 = timer.elapsed_ms();
749 assert!(e2 >= e1, "elapsed should be non-decreasing");
750 }
751
752 #[test]
753 fn test_timer_label() {
754 let timer = BenchTimer::start("my_bench");
755 assert_eq!(timer.label(), "my_bench");
756 }
757
758 #[test]
759 fn test_timer_default_label() {
760 let timer = BenchTimer::start_default();
761 assert_eq!(timer.label(), "benchmark");
762 }
763
764 #[test]
765 fn test_timer_reset() {
766 let mut timer = BenchTimer::start("test");
767 std::thread::sleep(std::time::Duration::from_millis(20));
768 let before = timer.elapsed_ms();
769 timer.reset();
770 let after = timer.elapsed_ms();
771 assert!(
772 after < before,
773 "after reset, elapsed should be smaller: after={after}, before={before}"
774 );
775 }
776
777 #[test]
778 fn test_format_elapsed_milliseconds() {
779 assert_eq!(format_elapsed(Duration::from_millis(0)), "0ms");
780 assert_eq!(format_elapsed(Duration::from_millis(500)), "500ms");
781 assert_eq!(format_elapsed(Duration::from_millis(999)), "999ms");
782 }
783
784 #[test]
785 fn test_format_elapsed_seconds() {
786 assert_eq!(format_elapsed(Duration::from_millis(1000)), "1.000s");
787 assert_eq!(format_elapsed(Duration::from_millis(1500)), "1.500s");
788 assert_eq!(format_elapsed(Duration::from_secs(60)), "60.000s");
789 }
790
791 #[test]
794 fn test_benchmark_result_new() {
795 let r = BenchmarkResult::new("memory_bandwidth", 512.3, "GB/s");
796 assert_eq!(r.name, "memory_bandwidth");
797 assert!((r.value - 512.3).abs() < f64::EPSILON);
798 assert_eq!(r.unit, "GB/s");
799 assert!(r.success);
800 assert!(r.error.is_none());
801 assert!(r.gpu_id.is_empty());
802 assert!(!r.timestamp.is_empty());
803 }
804
805 #[test]
806 fn test_benchmark_result_with_gpu() {
807 let r = BenchmarkResult::with_gpu("flops", 10.5, "TFLOPS", "01:00.0");
808 assert_eq!(r.gpu_id, "01:00.0");
809 }
810
811 #[test]
812 fn test_benchmark_result_failed() {
813 let r = BenchmarkResult::failed("rocm_bench", "ROCm not found");
814 assert_eq!(r.name, "rocm_bench");
815 assert!(!r.success);
816 assert_eq!(r.error.as_deref(), Some("ROCm not found"));
817 assert_eq!(r.value, 0.0);
818 }
819
820 #[test]
821 fn test_benchmark_result_json_roundtrip() {
822 let original = BenchmarkResult::with_gpu("bandwidth", 256.7, "GB/s", "03:00.0");
823 let json = original.to_json().expect("serialization should succeed");
824 let restored = BenchmarkResult::from_json(&json).expect("deserialization should succeed");
825
826 assert_eq!(original, restored);
827 }
828
829 #[test]
830 fn test_benchmark_result_json_roundtrip_failed() {
831 let original = BenchmarkResult::failed("flops", "GPU error");
832 let json = original.to_json().expect("serialization should succeed");
833 let restored = BenchmarkResult::from_json(&json).expect("deserialization should succeed");
834
835 assert_eq!(original, restored);
836 }
837
838 #[test]
839 fn test_benchmark_result_json_has_expected_fields() {
840 let r = BenchmarkResult::new("test_bench", 42.0, "ms");
841 let json = r.to_json().expect("serialization should succeed");
842 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
843
844 assert!(value.get("name").is_some());
845 assert!(value.get("value").is_some());
846 assert!(value.get("unit").is_some());
847 assert!(value.get("timestamp").is_some());
848 assert!(value.get("success").is_some());
849 assert!(value.get("gpu_id").is_none());
851 assert!(value.get("error").is_none());
853 }
854
855 #[test]
856 fn test_benchmark_result_json_includes_gpu_id() {
857 let r = BenchmarkResult::with_gpu("test", 1.0, "s", "01:00.0");
858 let json = r.to_json().expect("serialization should succeed");
859 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
860
861 assert_eq!(value["gpu_id"].as_str(), Some("01:00.0"));
862 }
863
864 #[test]
865 fn test_benchmark_result_display_success() {
866 let r = BenchmarkResult::new("bandwidth", 512.3, "GB/s");
867 let display = format!("{r}");
868 assert!(display.contains("bandwidth"));
869 assert!(display.contains("512"));
870 assert!(display.contains("GB/s"));
871 }
872
873 #[test]
874 fn test_benchmark_result_display_failed() {
875 let r = BenchmarkResult::failed("test", "error msg");
876 let display = format!("{r}");
877 assert!(display.contains("FAILED"));
878 assert!(display.contains("error msg"));
879 }
880
881 #[test]
884 fn test_benchmark_results_collection() {
885 let mut results = BenchmarkResults::new("gpu_suite");
886 assert!(results.is_empty());
887
888 results.add(BenchmarkResult::new("bench1", 1.0, "ms"));
889 results.add(BenchmarkResult::new("bench2", 2.0, "ms"));
890 assert_eq!(results.len(), 2);
891 }
892
893 #[test]
894 fn test_benchmark_results_json_roundtrip() {
895 let mut results = BenchmarkResults::new("gpu_suite");
896 results.add(BenchmarkResult::with_gpu("bw", 512.0, "GB/s", "01:00.0"));
897 results.add(BenchmarkResult::new("latency", 0.5, "ms"));
898 results.metadata = serde_json::json!({"rocm_version": "7.2.1"});
899
900 let json = results.to_json().expect("serialization should succeed");
901 let restored = BenchmarkResults::from_json(&json).expect("deserialization should succeed");
902
903 assert_eq!(results, restored);
904 }
905
906 #[test]
907 fn test_benchmark_results_json_compatible_with_benchmark_logs() {
908 let mut results = BenchmarkResults::new("test_suite");
910 results.add(BenchmarkResult::new("mem_bw", 256.0, "GB/s"));
911
912 let json = results.to_json().unwrap();
913 let parsed = crate::benchmark_logs::extract_benchmark_json_value(&json);
914 assert!(
915 parsed.is_some(),
916 "benchmark_logs should be able to parse our JSON"
917 );
918
919 let value = parsed.unwrap();
920 assert!(value.get("results").is_some());
921 }
922
923 #[test]
926 fn test_now_timestamp_format() {
927 let ts = now_timestamp();
928 assert_eq!(ts.len(), 19, "timestamp should be 19 chars: {ts}");
930 assert!(ts.starts_with("20"), "year should be 20xx: {ts}");
932 assert_eq!(&ts[4..5], "-", "separator at pos 4 should be -");
934 assert_eq!(&ts[7..8], "-", "separator at pos 7 should be -");
935 assert_eq!(&ts[10..11], " ", "separator at pos 10 should be space");
936 assert_eq!(&ts[13..14], ":", "separator at pos 13 should be :");
937 assert_eq!(&ts[16..17], ":", "separator at pos 16 should be :");
938 }
939
940 #[test]
941 fn test_format_timestamp_prefix() {
942 let prefix = format_timestamp_prefix();
943 assert!(prefix.starts_with('['), "prefix should start with [");
944 assert!(prefix.ends_with("] "), "prefix should end with ] ");
945 assert_eq!(prefix.len(), 22, "prefix should be 22 chars: {prefix}");
947 }
948
949 fn validate_timestamp_format(ts: &str) -> bool {
951 if ts.len() != 19 {
952 return false;
953 }
954 let bytes = ts.as_bytes();
955 bytes[0].is_ascii_digit()
957 && bytes[1].is_ascii_digit()
958 && bytes[2].is_ascii_digit()
959 && bytes[3].is_ascii_digit()
960 && bytes[4] == b'-'
961 && bytes[5].is_ascii_digit()
962 && bytes[6].is_ascii_digit()
963 && bytes[7] == b'-'
964 && bytes[8].is_ascii_digit()
965 && bytes[9].is_ascii_digit()
966 && bytes[10] == b' '
967 && bytes[11].is_ascii_digit()
968 && bytes[12].is_ascii_digit()
969 && bytes[13] == b':'
970 && bytes[14].is_ascii_digit()
971 && bytes[15].is_ascii_digit()
972 && bytes[16] == b':'
973 && bytes[17].is_ascii_digit()
974 && bytes[18].is_ascii_digit()
975 }
976
977 #[test]
978 fn test_timestamp_format_validation() {
979 let ts = now_timestamp();
980 assert!(
981 validate_timestamp_format(&ts),
982 "timestamp '{ts}' should validate"
983 );
984 }
985
986 #[test]
989 fn test_bench_logger_log_to_file() {
990 let tmp = TempDir::new().expect("temp dir");
991 let log_path = tmp.path().join("bench.log");
992
993 let logger = BenchLogger::with_file(&log_path);
994 logger.info("test message");
995
996 let contents = fs::read_to_string(&log_path).expect("read log file");
997 assert!(contents.contains("INFO"), "log should contain INFO level");
998 assert!(
999 contents.contains("test message"),
1000 "log should contain message"
1001 );
1002 assert!(
1004 !contents.contains('\x1b'),
1005 "file should not have ANSI escapes"
1006 );
1007 }
1008
1009 #[test]
1010 fn test_bench_logger_tee_behavior() {
1011 let tmp = TempDir::new().expect("temp dir");
1012 let log_path = tmp.path().join("tee.log");
1013
1014 let logger = BenchLogger::with_file(&log_path);
1015 logger.warn("warning message");
1016 logger.error("error message");
1017 logger.success("success message");
1018
1019 let contents = fs::read_to_string(&log_path).expect("read log file");
1020 assert!(contents.contains("WARN"), "should have WARN");
1021 assert!(contents.contains("ERROR"), "should have ERROR");
1022 assert!(contents.contains("OK"), "should have OK");
1023 assert!(contents.contains("warning message"));
1024 assert!(contents.contains("error message"));
1025 assert!(contents.contains("success message"));
1026 }
1027
1028 #[test]
1029 fn test_bench_logger_no_file() {
1030 let logger = BenchLogger::new();
1031 assert!(logger.log_file_path().is_none());
1032 logger.info("no file test");
1034 }
1035
1036 #[test]
1037 fn test_bench_logger_set_and_clear_file() {
1038 let mut logger = BenchLogger::new();
1039 assert!(logger.log_file_path().is_none());
1040
1041 let tmp = TempDir::new().expect("temp dir");
1042 let log_path = tmp.path().join("dynamic.log");
1043 logger.set_log_file(&log_path);
1044 assert!(logger.log_file_path().is_some());
1045
1046 logger.info("written to dynamic file");
1047 let contents = fs::read_to_string(&log_path).expect("read log");
1048 assert!(contents.contains("written to dynamic file"));
1049
1050 logger.clear_log_file();
1051 assert!(logger.log_file_path().is_none());
1052 }
1053
1054 #[test]
1055 fn test_bench_logger_timestamp_in_file() {
1056 let tmp = TempDir::new().expect("temp dir");
1057 let log_path = tmp.path().join("ts.log");
1058
1059 let logger = BenchLogger::with_file(&log_path);
1060 logger.log("plain message");
1061
1062 let contents = fs::read_to_string(&log_path).expect("read log");
1063 assert!(
1065 validate_timestamp_prefix(&contents),
1066 "log line should have timestamp prefix: {contents}"
1067 );
1068 }
1069
1070 fn validate_timestamp_prefix(contents: &str) -> bool {
1071 for (i, ch) in contents.char_indices() {
1073 if ch == '[' && contents.len() > i + 20 {
1074 let candidate = &contents[i + 1..i + 20];
1075 if validate_timestamp_format(candidate)
1076 && contents.as_bytes().get(i + 20) == Some(&b']')
1077 {
1078 return true;
1079 }
1080 }
1081 }
1082 false
1083 }
1084
1085 #[test]
1086 fn test_bench_log_lines_have_timestamps() {
1087 let tmp = TempDir::new().expect("temp dir");
1088 let log_path = tmp.path().join("lines.log");
1089
1090 let logger = BenchLogger::with_file(&log_path);
1091 logger.info("line1");
1092 logger.warn("line2");
1093 logger.error("line3");
1094
1095 let contents = fs::read_to_string(&log_path).expect("read log");
1096 for line in contents.lines() {
1097 assert!(
1098 line.contains("INFO") || line.contains("WARN") || line.contains("ERROR"),
1099 "each line should have a level marker: {line}"
1100 );
1101 }
1102 }
1103
1104 #[test]
1107 fn test_strip_ansi_escapes() {
1108 let colored = "\x1b[0;31mERROR\x1b[0m something";
1109 let stripped = strip_ansi_escapes(colored);
1110 assert_eq!(stripped, "ERROR something");
1111 }
1112
1113 #[test]
1114 fn test_strip_ansi_no_escapes() {
1115 let plain = "plain text";
1116 assert_eq!(strip_ansi_escapes(plain), "plain text");
1117 }
1118
1119 #[test]
1122 fn test_resolve_log_dir_creates_dir() {
1123 let tmp = TempDir::new().expect("temp dir");
1124 let log_dir = tmp.path().join("test_logs");
1125
1126 std::env::set_var("MLSTACK_LOG_DIR", log_dir.to_str().unwrap());
1127 let result = resolve_log_dir();
1128 std::env::remove_var("MLSTACK_LOG_DIR");
1129
1130 assert!(result.ends_with("test_logs"));
1131 assert!(log_dir.exists());
1132 }
1133
1134 #[test]
1137 fn test_dry_run_false_by_default() {
1138 let _guard = crate::test_support::lock_env();
1142 std::env::remove_var("DRY_RUN");
1143 assert!(!is_dry_run());
1144 }
1145
1146 #[test]
1147 fn test_dry_run_true_when_set() {
1148 let _guard = crate::test_support::lock_env();
1149 std::env::set_var("DRY_RUN", "true");
1150 assert!(is_dry_run());
1151 std::env::remove_var("DRY_RUN");
1152 }
1153
1154 #[test]
1155 fn test_dry_run_case_insensitive() {
1156 let _guard = crate::test_support::lock_env();
1157 std::env::remove_var("DRY_RUN");
1159
1160 std::env::set_var("DRY_RUN", "TRUE");
1161 assert!(is_dry_run());
1162 std::env::remove_var("DRY_RUN");
1163
1164 std::env::set_var("DRY_RUN", "True");
1165 assert!(is_dry_run());
1166 std::env::remove_var("DRY_RUN");
1167 }
1168
1169 #[test]
1170 fn test_dry_run_false_when_other_value() {
1171 let _guard = crate::test_support::lock_env();
1172 std::env::set_var("DRY_RUN", "false");
1173 assert!(!is_dry_run());
1174 std::env::remove_var("DRY_RUN");
1175
1176 std::env::set_var("DRY_RUN", "0");
1177 assert!(!is_dry_run());
1178 std::env::remove_var("DRY_RUN");
1179 }
1180
1181 #[test]
1184 fn test_single_result_benchmark_logs_compat() {
1185 let r = BenchmarkResult::new("test_bench", 42.0, "ms");
1186 let json = r.to_json().unwrap();
1187 let parsed = crate::benchmark_logs::extract_benchmark_json_value(&json);
1189 assert!(parsed.is_some());
1190 let val = parsed.unwrap();
1191 assert_eq!(val["name"].as_str(), Some("test_bench"));
1192 assert!((val["value"].as_f64().unwrap() - 42.0).abs() < f64::EPSILON);
1193 }
1194
1195 #[test]
1196 fn test_results_collection_benchmark_logs_compat() {
1197 let mut results = BenchmarkResults::new("full_suite");
1198 results.add(BenchmarkResult::new("bw", 100.0, "GB/s"));
1199 results.add(BenchmarkResult::failed("broken", "oops"));
1200
1201 let json = results.to_json().unwrap();
1202 let parsed = crate::benchmark_logs::extract_benchmark_json_value(&json);
1203 assert!(parsed.is_some());
1204 let val = parsed.unwrap();
1205 assert!(val.get("results").is_some());
1206 }
1207}