simplebench_runtime/
baseline.rs

1use crate::config::ComparisonConfig;
2use crate::{BenchResult, CpuSnapshot, Percentiles};
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9/// Get the MAC address of the primary network interface and hash it for privacy
10///
11/// Returns a SHA256 hash (first 16 hex characters) of the MAC address to serve as
12/// a stable machine identifier without exposing the actual MAC address.
13fn get_primary_mac_address() -> Result<String, std::io::Error> {
14    let interface = default_net::get_default_interface().map_err(|e| {
15        std::io::Error::new(
16            std::io::ErrorKind::NotFound,
17            format!("Failed to get default network interface: {}", e),
18        )
19    })?;
20
21    let mac_addr = interface.mac_addr.ok_or_else(|| {
22        std::io::Error::new(
23            std::io::ErrorKind::NotFound,
24            "Default interface has no MAC address",
25        )
26    })?;
27
28    // Format as lowercase with dashes: aa-bb-cc-dd-ee-ff
29    // The default format uses colons, so replace them with dashes
30    let mac_string = format!("{}", mac_addr).replace(':', "-").to_lowercase();
31
32    // Hash the MAC address for privacy protection
33    hash_mac_address(&mac_string)
34}
35
36/// Hash a MAC address using SHA256 for privacy protection
37///
38/// Returns the first 16 characters of the hex digest as a stable machine identifier
39fn hash_mac_address(mac: &str) -> Result<String, std::io::Error> {
40    let mut hasher = Sha256::new();
41    hasher.update(mac.as_bytes());
42    let result = hasher.finalize();
43
44    // Use first 16 characters of hex digest (64 bits of entropy)
45    Ok(format!("{:x}", result)[..16].to_string())
46}
47
48/// Storage format for baseline benchmark results
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct BaselineData {
51    pub benchmark_name: String,
52    pub module: String,
53    pub timestamp: String,
54    /// All raw timing samples in nanoseconds
55    pub samples: Vec<u128>,
56    /// Comprehensive statistics calculated from samples
57    pub statistics: crate::Statistics,
58    pub iterations: usize,
59    #[serde(alias = "hostname")]
60    pub machine_id: String,
61
62    // CPU monitoring data
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub cpu_samples: Vec<CpuSnapshot>,
65
66    // Legacy fields for backward compatibility (optional)
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub percentiles: Option<Percentiles>,
69
70    // Flag indicating this run was a detected regression
71    #[serde(default, skip_serializing_if = "is_false")]
72    pub was_regression: bool,
73}
74
75fn is_false(b: &bool) -> bool {
76    !*b
77}
78
79impl BaselineData {
80    pub fn from_bench_result(
81        result: &BenchResult,
82        machine_id: String,
83        was_regression: bool,
84    ) -> Self {
85        // Convert Duration timings to u128 nanoseconds
86        let samples: Vec<u128> = result.all_timings.iter().map(|d| d.as_nanos()).collect();
87
88        // Calculate comprehensive statistics
89        let statistics = crate::calculate_statistics(&samples);
90
91        Self {
92            benchmark_name: result.name.clone(),
93            module: result.module.clone(),
94            timestamp: chrono::Utc::now().to_rfc3339(),
95            samples,
96            statistics,
97            iterations: result.iterations,
98            machine_id,
99            cpu_samples: result.cpu_samples.clone(),
100            percentiles: Some(result.percentiles.clone()),
101            was_regression,
102        }
103    }
104
105    pub fn to_bench_result(&self) -> BenchResult {
106        // If we have percentiles (new format), use them
107        let percentiles = if let Some(ref p) = self.percentiles {
108            p.clone()
109        } else {
110            // Reconstruct from statistics (for forward compatibility)
111            Percentiles {
112                mean: Duration::from_nanos(self.statistics.mean as u64),
113                p50: Duration::from_nanos(self.statistics.median as u64),
114                p90: Duration::from_nanos(self.statistics.p90 as u64),
115                p99: Duration::from_nanos(self.statistics.p99 as u64),
116            }
117        };
118
119        // Convert samples back to Duration
120        let all_timings: Vec<Duration> = self
121            .samples
122            .iter()
123            .map(|&ns| Duration::from_nanos(ns as u64))
124            .collect();
125
126        BenchResult {
127            name: self.benchmark_name.clone(),
128            module: self.module.clone(),
129            percentiles,
130            iterations: self.iterations,
131            samples: self.samples.len(),
132            all_timings,
133            cpu_samples: self.cpu_samples.clone(),
134            warmup_ms: None,
135            warmup_iterations: None,
136        }
137    }
138}
139
140/// Manages baseline storage in .benches/ directory
141#[derive(Debug)]
142pub struct BaselineManager {
143    root_dir: PathBuf,
144    machine_id: String,
145}
146
147impl BaselineManager {
148    /// Create a new baseline manager
149    ///
150    /// By default, uses .benches/ in the current directory
151    pub fn new() -> Result<Self, std::io::Error> {
152        let machine_id = get_primary_mac_address()?;
153
154        Ok(Self {
155            root_dir: PathBuf::from(".benches"),
156            machine_id,
157        })
158    }
159
160    /// Create a baseline manager with a custom root directory
161    pub fn with_root_dir<P: AsRef<Path>>(root_dir: P) -> Result<Self, std::io::Error> {
162        let machine_id = get_primary_mac_address()?;
163
164        Ok(Self {
165            root_dir: root_dir.as_ref().to_path_buf(),
166            machine_id,
167        })
168    }
169
170    /// Get the directory path for this machine's baselines
171    fn machine_dir(&self) -> PathBuf {
172        self.root_dir.join(&self.machine_id)
173    }
174
175    /// Get the directory path for a specific benchmark's runs
176    fn benchmark_dir(&self, crate_name: &str, benchmark_name: &str) -> PathBuf {
177        let dir_name = format!("{}_{}", crate_name, benchmark_name);
178        self.machine_dir().join(dir_name)
179    }
180
181    /// Get the file path for a specific benchmark baseline (legacy - single file)
182    fn legacy_baseline_path(&self, crate_name: &str, benchmark_name: &str) -> PathBuf {
183        let filename = format!("{}_{}.json", crate_name, benchmark_name);
184        self.machine_dir().join(filename)
185    }
186
187    /// Get a timestamped run path for a new baseline
188    fn get_run_path(&self, crate_name: &str, benchmark_name: &str) -> PathBuf {
189        let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S");
190        let filename = format!("{}.json", timestamp);
191        self.benchmark_dir(crate_name, benchmark_name)
192            .join(filename)
193    }
194
195    /// Ensure the baseline directory exists
196    fn ensure_dir_exists(
197        &self,
198        crate_name: &str,
199        benchmark_name: &str,
200    ) -> Result<(), std::io::Error> {
201        fs::create_dir_all(self.benchmark_dir(crate_name, benchmark_name))
202    }
203
204    /// Save a benchmark result as a baseline (creates new timestamped file)
205    pub fn save_baseline(
206        &self,
207        crate_name: &str,
208        result: &BenchResult,
209        was_regression: bool,
210    ) -> Result<(), std::io::Error> {
211        self.ensure_dir_exists(crate_name, &result.name)?;
212
213        let baseline =
214            BaselineData::from_bench_result(result, self.machine_id.clone(), was_regression);
215        let json = serde_json::to_string_pretty(&baseline)?;
216
217        let path = self.get_run_path(crate_name, &result.name);
218        fs::write(path, json)?;
219
220        Ok(())
221    }
222
223    /// Load the most recent baseline for a specific benchmark
224    pub fn load_baseline(
225        &self,
226        crate_name: &str,
227        benchmark_name: &str,
228    ) -> Result<Option<BaselineData>, std::io::Error> {
229        let bench_dir = self.benchmark_dir(crate_name, benchmark_name);
230
231        // Check if new directory structure exists
232        if bench_dir.exists() && bench_dir.is_dir() {
233            // Find most recent JSON file
234            let mut runs: Vec<_> = fs::read_dir(&bench_dir)?
235                .filter_map(|e| e.ok())
236                .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
237                .collect();
238
239            if runs.is_empty() {
240                return Ok(None);
241            }
242
243            // Sort by filename (timestamps are sortable)
244            runs.sort_by_key(|e| e.file_name());
245            let latest = runs.last().unwrap();
246
247            let contents = fs::read_to_string(latest.path())?;
248            let baseline: BaselineData = serde_json::from_str(&contents)?;
249            return Ok(Some(baseline));
250        }
251
252        // Fall back to legacy single-file format
253        let legacy_path = self.legacy_baseline_path(crate_name, benchmark_name);
254        if legacy_path.exists() {
255            let contents = fs::read_to_string(legacy_path)?;
256            let baseline: BaselineData = serde_json::from_str(&contents)?;
257            return Ok(Some(baseline));
258        }
259
260        Ok(None)
261    }
262
263    /// Check if a baseline exists for a benchmark
264    pub fn has_baseline(&self, crate_name: &str, benchmark_name: &str) -> bool {
265        let bench_dir = self.benchmark_dir(crate_name, benchmark_name);
266        if bench_dir.exists() && bench_dir.is_dir() {
267            return true;
268        }
269        self.legacy_baseline_path(crate_name, benchmark_name)
270            .exists()
271    }
272
273    /// List all run timestamps for a specific benchmark
274    pub fn list_runs(
275        &self,
276        crate_name: &str,
277        benchmark_name: &str,
278    ) -> Result<Vec<String>, std::io::Error> {
279        let bench_dir = self.benchmark_dir(crate_name, benchmark_name);
280
281        if !bench_dir.exists() || !bench_dir.is_dir() {
282            return Ok(vec![]);
283        }
284
285        let mut runs: Vec<String> = fs::read_dir(&bench_dir)?
286            .filter_map(|e| e.ok())
287            .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
288            .filter_map(|e| {
289                e.file_name()
290                    .to_string_lossy()
291                    .strip_suffix(".json")
292                    .map(|s| s.to_string())
293            })
294            .collect();
295
296        runs.sort();
297        Ok(runs)
298    }
299
300    /// Load a specific run by timestamp
301    pub fn load_run(
302        &self,
303        crate_name: &str,
304        benchmark_name: &str,
305        timestamp: &str,
306    ) -> Result<Option<BaselineData>, std::io::Error> {
307        let bench_dir = self.benchmark_dir(crate_name, benchmark_name);
308        let filename = format!("{}.json", timestamp);
309        let path = bench_dir.join(filename);
310
311        if !path.exists() {
312            return Ok(None);
313        }
314
315        let contents = fs::read_to_string(path)?;
316        let baseline: BaselineData = serde_json::from_str(&contents)?;
317        Ok(Some(baseline))
318    }
319
320    /// List all baselines for a crate
321    pub fn list_baselines(&self, crate_name: &str) -> Result<Vec<String>, std::io::Error> {
322        let machine_dir = self.machine_dir();
323
324        if !machine_dir.exists() {
325            return Ok(vec![]);
326        }
327
328        let prefix = format!("{}_", crate_name);
329        let mut baselines = Vec::new();
330
331        for entry in fs::read_dir(machine_dir)? {
332            let entry = entry?;
333            let name = entry.file_name().to_string_lossy().to_string();
334
335            // Check for new directory structure
336            if name.starts_with(&prefix) && entry.path().is_dir() {
337                // Extract benchmark name from directory name
338                let benchmark_name = name.strip_prefix(&prefix).unwrap_or(&name).to_string();
339                baselines.push(benchmark_name);
340            }
341            // Check for legacy single-file format
342            else if name.starts_with(&prefix) && name.ends_with(".json") {
343                let benchmark_name = name
344                    .strip_prefix(&prefix)
345                    .and_then(|s| s.strip_suffix(".json"))
346                    .unwrap_or(&name)
347                    .to_string();
348                baselines.push(benchmark_name);
349            }
350        }
351
352        Ok(baselines)
353    }
354
355    /// Load last N baseline runs for a benchmark
356    ///
357    /// Returns the most recent baseline runs in chronological order (oldest first).
358    /// **Excludes runs that were flagged as regressions** to keep the baseline clean.
359    /// This is used for statistical window comparison.
360    pub fn load_recent_baselines(
361        &self,
362        crate_name: &str,
363        benchmark_name: &str,
364        count: usize,
365    ) -> Result<Vec<BaselineData>, std::io::Error> {
366        let bench_dir = self.benchmark_dir(crate_name, benchmark_name);
367
368        if !bench_dir.exists() || !bench_dir.is_dir() {
369            return Ok(vec![]);
370        }
371
372        // List all run timestamps
373        let mut runs: Vec<_> = fs::read_dir(&bench_dir)?
374            .filter_map(|e| e.ok())
375            .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
376            .collect();
377
378        if runs.is_empty() {
379            return Ok(vec![]);
380        }
381
382        // Sort chronologically by filename (timestamps are sortable)
383        runs.sort_by_key(|e| e.file_name());
384
385        // Load baseline data, filtering out regressions
386        let mut baselines = Vec::new();
387        for entry in runs.iter().rev() {
388            // Stop once we have enough non-regression baselines
389            if baselines.len() >= count {
390                break;
391            }
392
393            let contents = fs::read_to_string(entry.path())?;
394            if let Ok(baseline) = serde_json::from_str::<BaselineData>(&contents) {
395                // Skip runs that were detected as regressions
396                if !baseline.was_regression {
397                    baselines.push(baseline);
398                }
399            }
400        }
401
402        // Reverse to get chronological order (oldest first)
403        baselines.reverse();
404
405        Ok(baselines)
406    }
407}
408
409impl Default for BaselineManager {
410    fn default() -> Self {
411        Self::new().expect("Failed to get primary MAC address")
412    }
413}
414
415/// Result of baseline comparison for a single benchmark
416#[derive(Debug, Clone)]
417pub struct ComparisonResult {
418    pub benchmark_name: String,
419    pub comparison: Option<crate::Comparison>,
420    pub is_regression: bool,
421}
422
423/// Detect regression using statistical window + Bayesian Change Point Detection
424///
425/// This function combines three criteria for robust regression detection:
426/// 1. Statistical significance (outside confidence interval)
427/// 2. Practical significance (exceeds threshold percentage)
428/// 3. Change point probability (likely distribution shift)
429///
430/// All three conditions must be met for a regression to be flagged.
431pub fn detect_regression_with_cpd(
432    current: &crate::BenchResult,
433    historical: &[BaselineData],
434    threshold: f64,
435    confidence_level: f64,
436    cp_threshold: f64,
437    hazard_rate: f64,
438) -> ComparisonResult {
439    if historical.is_empty() {
440        return ComparisonResult {
441            benchmark_name: current.name.clone(),
442            comparison: None,
443            is_regression: false,
444        };
445    }
446
447    // Extract means from historical runs (in nanoseconds)
448    let historical_means: Vec<f64> = historical
449        .iter()
450        .map(|b| b.statistics.mean as f64)
451        .collect();
452
453    let current_mean = current.percentiles.mean.as_nanos() as f64;
454
455    // --- Statistical Window Analysis ---
456    let hist_mean = crate::statistics::mean(&historical_means);
457    let hist_stddev = crate::statistics::standard_deviation(&historical_means);
458
459    // Z-score: how many standard deviations away?
460    let z_score_value = crate::statistics::z_score(current_mean, hist_mean, hist_stddev);
461
462    // Confidence interval (one-tailed for regression detection)
463    let z_critical = if (confidence_level - 0.90).abs() < 0.01 {
464        1.282 // 90% one-tailed
465    } else if (confidence_level - 0.95).abs() < 0.01 {
466        1.645 // 95% one-tailed
467    } else if (confidence_level - 0.99).abs() < 0.01 {
468        2.326 // 99% one-tailed
469    } else {
470        1.96 // Default two-tailed 95%
471    };
472
473    let upper_bound = hist_mean + (z_critical * hist_stddev);
474    let lower_bound = hist_mean - (z_critical * hist_stddev);
475
476    // For regression, we only care if it's slower (above upper bound)
477    let statistically_significant = current_mean > upper_bound;
478
479    // --- Bayesian Change Point Detection ---
480    let change_probability = crate::changepoint::bayesian_change_point_probability(
481        current_mean,
482        &historical_means,
483        hazard_rate,
484    );
485
486    // --- Practical Significance ---
487    let percentage_change = ((current_mean - hist_mean) / hist_mean) * 100.0;
488    let practically_significant = percentage_change > threshold;
489
490    // --- Combined Decision ---
491    // Use tiered logic based on strength of statistical evidence:
492    //
493    // 1. EXTREME evidence (z-score > 5): Statistical + practical significance = regression
494    //    This catches acute performance disasters that are clearly not noise
495    //
496    // 2. STRONG evidence (z-score > 2): Require all three conditions
497    //    Statistical + practical + change point = regression
498    //    This is the normal case for real regressions
499    //
500    // 3. WEAK evidence (z-score <= 2): Not a regression
501    //    Likely just noise or natural variance, even if percentage is high
502
503    let is_regression = if z_score_value.abs() > 5.0 {
504        // Extreme statistical evidence: trust the statistics
505        statistically_significant && practically_significant
506    } else if z_score_value.abs() > 2.0 {
507        // Strong statistical evidence: require change point confirmation
508        statistically_significant && practically_significant && change_probability > cp_threshold
509    } else {
510        // Weak evidence: not a regression
511        false
512    };
513
514    ComparisonResult {
515        benchmark_name: current.name.clone(),
516        comparison: Some(crate::Comparison {
517            current_mean: current.percentiles.mean,
518            baseline_mean: Duration::from_nanos(hist_mean as u64),
519            percentage_change,
520            baseline_count: historical.len(),
521            z_score: Some(z_score_value),
522            confidence_interval: Some((lower_bound, upper_bound)),
523            change_probability: Some(change_probability),
524        }),
525        is_regression,
526    }
527}
528
529/// Process benchmarks with baseline comparison using CPD
530///
531/// This function:
532/// 1. Loads recent baseline runs (window-based)
533/// 2. Compares current results with historical data using statistical + Bayesian CPD
534/// 3. Saves new baselines
535/// 4. Returns comparison results
536pub fn process_with_baselines(
537    results: &[crate::BenchResult],
538    config: &ComparisonConfig,
539) -> Result<Vec<ComparisonResult>, std::io::Error> {
540    let baseline_manager = BaselineManager::new()?;
541    let mut comparisons = Vec::new();
542
543    for result in results {
544        // Extract crate name from module path (first component)
545        let crate_name = result.module.split("::").next().unwrap_or("unknown");
546
547        // Load recent baselines (window-based comparison)
548        let historical =
549            baseline_manager.load_recent_baselines(crate_name, &result.name, config.window_size)?;
550
551        let comparison_result = if !historical.is_empty() {
552            // Use CPD-based comparison
553            detect_regression_with_cpd(
554                result,
555                &historical,
556                config.threshold,
557                config.confidence_level,
558                config.cp_threshold,
559                config.hazard_rate,
560            )
561        } else {
562            // No baseline exists - first run
563            ComparisonResult {
564                benchmark_name: result.name.clone(),
565                comparison: None,
566                is_regression: false,
567            }
568        };
569
570        let is_regression = comparison_result.is_regression;
571        comparisons.push(comparison_result);
572
573        // Save current result as baseline with regression flag
574        baseline_manager.save_baseline(crate_name, result, is_regression)?;
575    }
576
577    Ok(comparisons)
578}
579
580/// Check if any regressions were detected and exit in CI mode
581pub fn check_regressions_and_exit(comparisons: &[ComparisonResult], config: &ComparisonConfig) {
582    if !config.ci_mode {
583        return;
584    }
585
586    let has_regression = comparisons.iter().any(|c| c.is_regression);
587
588    if has_regression {
589        use colored::Colorize;
590        eprintln!();
591        eprintln!(
592            "{}",
593            format!(
594                "FAILED: Performance regression detected (threshold: {}%)",
595                config.threshold
596            )
597            .red()
598            .bold()
599        );
600        std::process::exit(1);
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use std::time::Duration;
608    use tempfile::TempDir;
609
610    fn create_test_result(name: &str) -> BenchResult {
611        BenchResult {
612            name: name.to_string(),
613            module: "test_module".to_string(),
614            iterations: 100,
615            samples: 10,
616            percentiles: Percentiles {
617                p50: Duration::from_millis(5),
618                p90: Duration::from_millis(10),
619                p99: Duration::from_millis(15),
620                mean: Duration::from_millis(8),
621            },
622            all_timings: vec![Duration::from_millis(5); 10],
623            cpu_samples: vec![],
624            ..Default::default()
625        }
626    }
627
628    #[test]
629    fn test_baseline_data_conversion() {
630        let result = create_test_result("test_bench");
631        let machine_id = "0123456789abcdef".to_string(); // 16-char hex hash
632
633        let baseline = BaselineData::from_bench_result(&result, machine_id.clone(), false);
634
635        assert_eq!(baseline.benchmark_name, "test_bench");
636        assert_eq!(baseline.module, "test_module");
637        assert_eq!(baseline.machine_id, machine_id);
638        assert_eq!(baseline.iterations, 100);
639        assert_eq!(baseline.statistics.sample_count, 10);
640        assert_eq!(baseline.samples.len(), 10);
641
642        let converted = baseline.to_bench_result();
643        assert_eq!(converted.name, result.name);
644        assert_eq!(converted.module, result.module);
645        assert_eq!(converted.percentiles.p90, result.percentiles.p90);
646    }
647
648    #[test]
649    fn test_save_and_load_baseline() {
650        let temp_dir = TempDir::new().unwrap();
651        let manager = BaselineManager::with_root_dir(temp_dir.path()).unwrap();
652
653        let result = create_test_result("test_bench");
654
655        // Save baseline
656        manager.save_baseline("my_crate", &result, false).unwrap();
657
658        // Load baseline
659        let loaded = manager.load_baseline("my_crate", "test_bench").unwrap();
660        assert!(loaded.is_some());
661
662        let baseline = loaded.unwrap();
663        assert_eq!(baseline.benchmark_name, "test_bench");
664        assert_eq!(baseline.module, "test_module");
665        assert!(baseline.percentiles.is_some());
666        assert_eq!(baseline.percentiles.unwrap().p90, Duration::from_millis(10));
667    }
668
669    #[test]
670    fn test_load_nonexistent_baseline() {
671        let temp_dir = TempDir::new().unwrap();
672        let manager = BaselineManager::with_root_dir(temp_dir.path()).unwrap();
673
674        let loaded = manager.load_baseline("my_crate", "nonexistent").unwrap();
675        assert!(loaded.is_none());
676    }
677
678    #[test]
679    fn test_has_baseline() {
680        let temp_dir = TempDir::new().unwrap();
681        let manager = BaselineManager::with_root_dir(temp_dir.path()).unwrap();
682
683        let result = create_test_result("test_bench");
684
685        assert!(!manager.has_baseline("my_crate", "test_bench"));
686
687        manager.save_baseline("my_crate", &result, false).unwrap();
688
689        assert!(manager.has_baseline("my_crate", "test_bench"));
690    }
691
692    #[test]
693    fn test_list_baselines() {
694        let temp_dir = TempDir::new().unwrap();
695        let manager = BaselineManager::with_root_dir(temp_dir.path()).unwrap();
696
697        let result1 = create_test_result("bench1");
698        let result2 = create_test_result("bench2");
699
700        manager.save_baseline("my_crate", &result1, false).unwrap();
701        manager.save_baseline("my_crate", &result2, false).unwrap();
702
703        let mut baselines = manager.list_baselines("my_crate").unwrap();
704        baselines.sort();
705
706        assert_eq!(baselines, vec!["bench1", "bench2"]);
707    }
708
709    #[test]
710    fn test_get_primary_mac_address() {
711        // Test that we can get a hashed machine ID
712        let result = get_primary_mac_address();
713
714        // Should succeed on systems with network interfaces
715        assert!(result.is_ok(), "Failed to get machine ID: {:?}", result);
716
717        let machine_id = result.unwrap();
718
719        // Should be 16 characters (first 16 chars of SHA256 hex digest)
720        assert_eq!(
721            machine_id.len(),
722            16,
723            "Machine ID should be 16 characters: {}",
724            machine_id
725        );
726
727        // Should be lowercase hex
728        assert_eq!(
729            machine_id,
730            machine_id.to_lowercase(),
731            "Machine ID should be lowercase"
732        );
733        assert!(
734            machine_id.chars().all(|c| c.is_ascii_hexdigit()),
735            "Machine ID should contain only hex digits"
736        );
737    }
738
739    #[test]
740    fn test_mac_address_format() {
741        // Test that BaselineManager can be created successfully
742        let manager_result = BaselineManager::new();
743        assert!(
744            manager_result.is_ok(),
745            "Failed to create BaselineManager: {:?}",
746            manager_result
747        );
748
749        let manager = manager_result.unwrap();
750
751        // Verify machine_id is properly formatted (16 character hex hash)
752        assert_eq!(
753            manager.machine_id.len(),
754            16,
755            "Machine ID should be 16 characters"
756        );
757        assert_eq!(manager.machine_id, manager.machine_id.to_lowercase());
758        assert!(manager.machine_id.chars().all(|c| c.is_ascii_hexdigit()));
759    }
760}