Skip to main content

rusty_stack/installers/common/
benchmark_common.rs

1//! Benchmark common utilities — PCI bus ID normalization, timing, result
2//! structs with JSON serialization, and timestamped logging.
3//!
4//! Ports the core infrastructure from `scripts/lib/benchmark_common.sh`:
5//! - PCI bus ID normalization (`0000:01:00.0` → `01:00.0`)
6//! - Benchmark timing utilities (elapsed calculation, formatting)
7//! - `BenchmarkResult` struct with JSON serialization compatible with
8//!   `benchmark_logs.rs` parsing
9//! - Timestamped logging with `[YYYY-MM-DD HH:MM:SS]` prefix
10//! - Tee-style logging to stderr + optional file output
11//!
12//! # Validation Assertions
13//!
14//! - **VAL-INFRA-013**: PCI bus normalization, timing utilities, result struct
15//!   with JSON serialization
16//! - **VAL-INFRA-014**: Timestamped logging `[YYYY-MM-DD HH:MM:SS]` prefix,
17//!   supports stderr + file output
18
19use 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
26// ===========================================================================
27// PCI Bus ID Normalization
28// ===========================================================================
29
30/// Normalize a PCI bus ID string.
31///
32/// Strips leading `0000:` domain prefix, lowercases the input, and validates
33/// the result matches the pattern `XX:XX.X` (where X is a hex digit and the
34/// function number is 0–7).
35///
36/// # Examples
37///
38/// ```
39/// use rusty_stack::installers::common::benchmark_common::normalize_pci_bus_id;
40///
41/// assert_eq!(normalize_pci_bus_id("0000:01:00.0"), Ok("01:00.0".to_string()));
42/// assert_eq!(normalize_pci_bus_id("01:00.0"), Ok("01:00.0".to_string()));
43/// assert_eq!(normalize_pci_bus_id("03:00.0"), Ok("03:00.0".to_string()));
44/// ```
45///
46/// # Validation
47///
48/// - **VAL-INFRA-013**: PCI bus normalization (0000:01:00.0 → 01:00.0)
49pub fn normalize_pci_bus_id(raw: &str) -> Result<String, String> {
50    let mut s = raw.trim().to_lowercase();
51    // Strip leading whitespace and take only the first token
52    s = s.split_whitespace().next().unwrap_or("").to_string();
53    // Strip the 0000: domain prefix if present
54    if s.starts_with("0000:") {
55        s = s[5..].to_string();
56    }
57    // Validate: XX:XX.X where X is hex and function is 0-7
58    if is_valid_pci_bus_id(&s) {
59        Ok(s)
60    } else {
61        Err(format!("Invalid PCI bus ID: '{s}'"))
62    }
63}
64
65/// Check whether a string matches the PCI bus ID format `XX:XX.X`.
66fn is_valid_pci_bus_id(s: &str) -> bool {
67    let bytes = s.as_bytes();
68    if bytes.len() != 7 {
69        return false;
70    }
71    // Format: hh:hh.f
72    //         0123456
73    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// ===========================================================================
89// Timing Utilities
90// ===========================================================================
91
92/// A simple stopwatch for benchmark timing.
93///
94/// Records the start time on creation and provides elapsed time queries.
95///
96/// # Validation
97///
98/// - **VAL-INFRA-013**: Benchmark timing utilities (elapsed calculation, formatting)
99#[derive(Debug, Clone)]
100pub struct BenchTimer {
101    start: Instant,
102    label: String,
103}
104
105impl BenchTimer {
106    /// Create a new timer with the given label, starting immediately.
107    pub fn start(label: &str) -> Self {
108        Self {
109            start: Instant::now(),
110            label: label.to_string(),
111        }
112    }
113
114    /// Create a new timer with label "benchmark".
115    pub fn start_default() -> Self {
116        Self::start("benchmark")
117    }
118
119    /// Return the elapsed time since start.
120    pub fn elapsed(&self) -> Duration {
121        self.start.elapsed()
122    }
123
124    /// Return elapsed time in milliseconds.
125    pub fn elapsed_ms(&self) -> u128 {
126        self.elapsed().as_millis()
127    }
128
129    /// Return elapsed time in seconds (floating point).
130    pub fn elapsed_secs(&self) -> f64 {
131        self.elapsed().as_secs_f64()
132    }
133
134    /// Return the timer's label.
135    pub fn label(&self) -> &str {
136        &self.label
137    }
138
139    /// Reset the timer, starting from now.
140    pub fn reset(&mut self) {
141        self.start = Instant::now();
142    }
143}
144
145/// Format a `Duration` as a human-readable string.
146///
147/// Returns milliseconds for durations under 1 second, otherwise seconds with
148/// 3 decimal places.
149///
150/// ```
151/// use rusty_stack::installers::common::benchmark_common::format_elapsed;
152/// use std::time::Duration;
153///
154/// assert_eq!(format_elapsed(Duration::from_millis(500)), "500ms");
155/// assert_eq!(format_elapsed(Duration::from_millis(1500)), "1.500s");
156/// ```
157pub 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// ===========================================================================
167// BenchmarkResult Struct
168// ===========================================================================
169
170/// A single benchmark measurement result.
171///
172/// Contains the benchmark name, numeric value, unit, timestamp, and optional
173/// GPU identifier. Serializes to/from JSON compatible with `benchmark_logs.rs`
174/// parsing logic.
175///
176/// # Validation
177///
178/// - **VAL-INFRA-013**: BenchmarkResult struct with name, value, unit,
179///   timestamp, gpu_id; JSON serialization round-trip with benchmark_logs.rs
180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
181pub struct BenchmarkResult {
182    /// Human-readable benchmark name (e.g., "memory_bandwidth").
183    pub name: String,
184    /// Numeric benchmark value.
185    pub value: f64,
186    /// Unit of measurement (e.g., "GB/s", "ms", "TFLOPS").
187    pub unit: String,
188    /// ISO 8601 timestamp when the benchmark was recorded.
189    pub timestamp: String,
190    /// PCI bus ID of the GPU used (e.g., "01:00.0"), or empty if CPU-only.
191    #[serde(default, skip_serializing_if = "String::is_empty")]
192    pub gpu_id: String,
193    /// Whether the benchmark succeeded.
194    #[serde(default = "default_true")]
195    pub success: bool,
196    /// Optional error message (set when `success` is false).
197    #[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    /// Create a new successful benchmark result with the current timestamp.
207    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    /// Create a new benchmark result with a specific GPU ID.
220    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    /// Create a failed benchmark result.
228    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    /// Serialize to a JSON string.
241    pub fn to_json(&self) -> Result<String, serde_json::Error> {
242        serde_json::to_string(self)
243    }
244
245    /// Serialize to a pretty-printed JSON string.
246    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
247        serde_json::to_string_pretty(self)
248    }
249
250    /// Deserialize from a JSON string.
251    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/// A collection of benchmark results, compatible with the JSON schema used
272/// by `benchmark_logs.rs`.
273///
274/// # Validation
275///
276/// - **VAL-INFRA-013**: JSON serialization round-trip with benchmark_logs.rs types
277#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
278pub struct BenchmarkResults {
279    /// The benchmark suite name.
280    #[serde(default, skip_serializing_if = "String::is_empty")]
281    pub name: String,
282    /// Individual benchmark results.
283    pub results: Vec<BenchmarkResult>,
284    /// Optional metadata (GPU info, ROCm version, etc.).
285    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
286    pub metadata: serde_json::Value,
287}
288
289impl BenchmarkResults {
290    /// Create an empty result collection.
291    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    /// Add a result to the collection.
300    pub fn add(&mut self, result: BenchmarkResult) {
301        self.results.push(result);
302    }
303
304    /// Serialize to a JSON string.
305    pub fn to_json(&self) -> Result<String, serde_json::Error> {
306        serde_json::to_string(self)
307    }
308
309    /// Serialize to a pretty-printed JSON string.
310    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
311        serde_json::to_string_pretty(self)
312    }
313
314    /// Deserialize from a JSON string.
315    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
316        serde_json::from_str(json)
317    }
318
319    /// Number of results in the collection.
320    pub fn len(&self) -> usize {
321        self.results.len()
322    }
323
324    /// Whether the collection is empty.
325    pub fn is_empty(&self) -> bool {
326        self.results.is_empty()
327    }
328}
329
330// ===========================================================================
331// Timestamp Utilities
332// ===========================================================================
333
334/// Get the current timestamp in `YYYY-MM-DD HH:MM:SS` format.
335///
336/// ```
337/// use rusty_stack::installers::common::benchmark_common::now_timestamp;
338///
339/// let ts = now_timestamp();
340/// assert!(ts.len() == 19);
341/// assert!(ts.starts_with("20"));
342/// assert_eq!(&ts[4..5], "-");
343/// assert_eq!(&ts[13..14], ":");
344/// ```
345///
346/// # Validation
347///
348/// - **VAL-INFRA-014**: `[YYYY-MM-DD HH:MM:SS]` prefix format
349pub 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    // Simple date/time calculation from Unix epoch
357    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
364/// Convert Unix timestamp to (year, month, day, hour, minute, second).
365///
366/// Uses a simplified algorithm that avoids external dependencies.
367fn 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    // Unix epoch starts at 1970-01-01
377    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
410/// Format a timestamp string for use in log lines.
411///
412/// Returns `[YYYY-MM-DD HH:MM:SS]` wrapped in brackets.
413pub fn format_timestamp_prefix() -> String {
414    format!("[{}] ", now_timestamp())
415}
416
417// ===========================================================================
418// Timestamped Logging
419// ===========================================================================
420
421/// Benchmark logger that writes timestamped lines to stderr and optionally
422/// to a log file (tee behavior).
423///
424/// # Validation
425///
426/// - **VAL-INFRA-014**: Timestamped logging `[YYYY-MM-DD HH:MM:SS]` prefix,
427///   supports stderr + file output (tee behavior)
428#[derive(Debug)]
429pub struct BenchLogger {
430    /// Optional log file path for tee output.
431    log_file: Option<PathBuf>,
432    /// Whether color output is enabled.
433    color_enabled: bool,
434}
435
436/// Color constants for benchmark logging.
437struct 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    /// Create a new logger with no file output.
463    pub fn new() -> Self {
464        Self {
465            log_file: None,
466            color_enabled: color_output_enabled(),
467        }
468    }
469
470    /// Create a new logger that also writes to the given file path.
471    ///
472    /// The file is opened in append mode; if it doesn't exist, it is created.
473    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    /// Set the log file path (replaces any existing one).
481    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    /// Clear the log file (stderr-only mode).
486    pub fn clear_log_file(&mut self) {
487        self.log_file = None;
488    }
489
490    /// Get the log file path, if set.
491    pub fn log_file_path(&self) -> Option<&Path> {
492        self.log_file.as_deref()
493    }
494
495    /// Log a raw message with timestamp prefix.
496    ///
497    /// Writes to stderr and, if configured, to the log file (tee behavior).
498    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                // Strip ANSI color codes for file output
505                let stripped = strip_ansi_escapes(&line);
506                let _ = writeln!(file, "{}", stripped);
507            }
508        }
509    }
510
511    /// Log an info-level message.
512    pub fn info(&self, message: &str) {
513        let c = self.colors();
514        self.log(&format!("{}INFO{} {}", c.blue, c.reset, message));
515    }
516
517    /// Log a warning-level message.
518    pub fn warn(&self, message: &str) {
519        let c = self.colors();
520        self.log(&format!("{}WARN{} {}", c.yellow, c.reset, message));
521    }
522
523    /// Log an error-level message.
524    pub fn error(&self, message: &str) {
525        let c = self.colors();
526        self.log(&format!("{}ERROR{} {}", c.red, c.reset, message));
527    }
528
529    /// Log a success/OK message.
530    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
550/// Determine whether color output should be enabled.
551///
552/// Returns `false` if `NO_COLOR` is set or stderr is not a TTY.
553fn 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
561/// Strip ANSI escape sequences from a string (for file output).
562fn 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            // Skip the escape sequence
568            if chars.peek() == Some(&'[') {
569                chars.next();
570                // Consume sequence: digits and semicolons followed by a letter
571                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
585// ===========================================================================
586// Log Directory Resolution
587// ===========================================================================
588
589/// Resolve the benchmark log directory.
590///
591/// Mirrors `benchmark_resolve_log_dir()` from the shell script:
592/// - Prefers `MLSTACK_LOG_DIR` environment variable
593/// - Falls back to `$HOME/.rusty-stack/logs`
594/// - Final fallback to `${TMPDIR:-/tmp}/rusty-stack/logs`
595///
596/// Creates the directory if it doesn't exist.
597pub 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
624/// Ensure a directory exists and is writable.
625fn ensure_dir(path: &Path) -> bool {
626    if std::fs::create_dir_all(path).is_ok() {
627        // Check writability by trying to create a temp file
628        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
638// ===========================================================================
639// Dry-Run Support
640// ===========================================================================
641
642/// Check if dry-run mode is enabled via environment variable.
643pub 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// ===========================================================================
650// Tests
651// ===========================================================================
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use std::fs;
657    use tempfile::TempDir;
658
659    // ----- PCI Bus ID Normalization Tests -----
660
661    #[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        // Takes only the first token
698        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        // Function number must be 0-7
732        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    // ----- Timing Tests -----
742
743    #[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    // ----- BenchmarkResult Tests -----
792
793    #[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        // gpu_id should be skipped when empty
850        assert!(value.get("gpu_id").is_none());
851        // error should be skipped when None
852        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    // ----- BenchmarkResults Collection Tests -----
882
883    #[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        // Verify that the JSON output can be parsed by benchmark_logs::extract_benchmark_json_value
909        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    // ----- Timestamp Tests -----
924
925    #[test]
926    fn test_now_timestamp_format() {
927        let ts = now_timestamp();
928        // Format: YYYY-MM-DD HH:MM:SS (19 chars)
929        assert_eq!(ts.len(), 19, "timestamp should be 19 chars: {ts}");
930        // Year should start with 20xx
931        assert!(ts.starts_with("20"), "year should be 20xx: {ts}");
932        // Separators
933        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        // Length: [YYYY-MM-DD HH:MM:SS] + space = 22
946        assert_eq!(prefix.len(), 22, "prefix should be 22 chars: {prefix}");
947    }
948
949    // We use a simple manual validation instead of regex
950    fn validate_timestamp_format(ts: &str) -> bool {
951        if ts.len() != 19 {
952            return false;
953        }
954        let bytes = ts.as_bytes();
955        // YYYY-MM-DD HH:MM:SS
956        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    // ----- Logging Tests -----
987
988    #[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        // File output should not contain ANSI escape codes
1003        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        // Should not panic when logging without a file
1033        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        // Should contain [YYYY-MM-DD HH:MM:SS] prefix
1064        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        // Look for [YYYY-MM-DD HH:MM:SS] pattern
1072        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    // ----- ANSI Stripping Tests -----
1105
1106    #[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    // ----- Log Directory Tests -----
1120
1121    #[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    // ----- Dry-Run Tests -----
1135
1136    #[test]
1137    fn test_dry_run_false_by_default() {
1138        // Serialize: this mutates (remove_var) AND reads the shared DRY_RUN env,
1139        // racing with the other test_dry_run_* setters unless held under the
1140        // global env lock.
1141        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        // Ensure clean state
1158        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    // ----- Integration: BenchmarkResult + benchmark_logs compatibility -----
1182
1183    #[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        // benchmark_logs should be able to parse this
1188        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}