Skip to main content

perfgate_host_detect/
lib.rs

1//! Host mismatch detection for benchmarking noise reduction.
2//!
3//! This crate provides detection of host environment differences between
4//! baseline and current benchmark runs. Host mismatches can introduce
5//! significant noise into performance measurements, leading to false
6//! positives or negatives in regression detection.
7//!
8//! # Example
9//!
10//! ```
11//! use perfgate_host_detect::detect_host_mismatch;
12//! use perfgate_types::HostInfo;
13//!
14//! let baseline = HostInfo {
15//!     os: "linux".to_string(),
16//!     arch: "x86_64".to_string(),
17//!     cpu_count: Some(8),
18//!     memory_bytes: Some(16 * 1024 * 1024 * 1024),
19//!     hostname_hash: Some("abc123".to_string()),
20//! };
21//!
22//! let current = HostInfo {
23//!     os: "linux".to_string(),
24//!     arch: "x86_64".to_string(),
25//!     cpu_count: Some(8),
26//!     memory_bytes: Some(16 * 1024 * 1024 * 1024),
27//!     hostname_hash: Some("abc123".to_string()),
28//! };
29//!
30//! assert!(detect_host_mismatch(&baseline, &current).is_none());
31//! ```
32//!
33//! # Detection Criteria
34//!
35//! The function detects mismatches based on:
36//!
37//! - **OS mismatch**: Different operating systems (e.g., `linux` vs `windows`)
38//! - **Architecture mismatch**: Different CPU architectures (e.g., `x86_64` vs `aarch64`)
39//! - **CPU count**: Significant difference (> 2x) in logical CPU count
40//! - **Memory**: Significant difference (> 2x) in total system memory
41//! - **Hostname hash**: Different hashed hostnames (different machines)
42//!
43//! The 2x threshold for CPU and memory is chosen to avoid false positives
44//! from minor variations (e.g., 8 vs 10 CPUs) while catching significant
45//! differences (e.g., 4 vs 16 CPUs) that could affect benchmark results.
46
47use perfgate_types::{HostInfo, HostMismatchInfo};
48
49/// Detect host mismatches between baseline and current runs.
50///
51/// Returns `Some(HostMismatchInfo)` if any mismatch is detected, `None` otherwise.
52///
53/// # Detection Criteria
54///
55/// - Different `os` or `arch`
56/// - Significant difference in `cpu_count` (> 2x)
57/// - Significant difference in `memory_bytes` (> 2x)
58/// - Different `hostname_hash` (if both present)
59///
60/// # Examples
61///
62/// Detect an OS mismatch (e.g., running benchmarks on a different platform):
63///
64/// ```
65/// use perfgate_host_detect::detect_host_mismatch;
66/// use perfgate_types::HostInfo;
67///
68/// let baseline = HostInfo {
69///     os: "linux".to_string(),
70///     arch: "x86_64".to_string(),
71///     cpu_count: None,
72///     memory_bytes: None,
73///     hostname_hash: None,
74/// };
75///
76/// let current = HostInfo {
77///     os: "windows".to_string(),
78///     arch: "x86_64".to_string(),
79///     cpu_count: None,
80///     memory_bytes: None,
81///     hostname_hash: None,
82/// };
83///
84/// let mismatch = detect_host_mismatch(&baseline, &current);
85/// assert!(mismatch.is_some());
86/// assert!(mismatch.unwrap().reasons[0].contains("OS mismatch"));
87/// ```
88///
89/// Detect an architecture mismatch (e.g., `x86_64` vs `aarch64`):
90///
91/// ```
92/// # use perfgate_host_detect::detect_host_mismatch;
93/// # use perfgate_types::HostInfo;
94/// let baseline = HostInfo {
95///     os: "linux".to_string(),
96///     arch: "x86_64".to_string(),
97///     cpu_count: None,
98///     memory_bytes: None,
99///     hostname_hash: None,
100/// };
101/// let current = HostInfo {
102///     os: "linux".to_string(),
103///     arch: "aarch64".to_string(),
104///     cpu_count: None,
105///     memory_bytes: None,
106///     hostname_hash: None,
107/// };
108///
109/// let mismatch = detect_host_mismatch(&baseline, &current).unwrap();
110/// assert!(mismatch.reasons[0].contains("architecture mismatch"));
111/// ```
112///
113/// Detect significant CPU count differences (> 2x ratio indicates a
114/// different cloud instance type or machine class):
115///
116/// ```
117/// # use perfgate_host_detect::detect_host_mismatch;
118/// # use perfgate_types::HostInfo;
119/// let baseline = HostInfo {
120///     os: "linux".to_string(),
121///     arch: "x86_64".to_string(),
122///     cpu_count: Some(4),
123///     memory_bytes: None,
124///     hostname_hash: None,
125/// };
126/// let current = HostInfo {
127///     os: "linux".to_string(),
128///     arch: "x86_64".to_string(),
129///     cpu_count: Some(32),
130///     memory_bytes: None,
131///     hostname_hash: None,
132/// };
133///
134/// let mismatch = detect_host_mismatch(&baseline, &current).unwrap();
135/// assert!(mismatch.reasons[0].contains("CPU count differs"));
136/// ```
137///
138/// Minor CPU differences (≤ 2x) are ignored to reduce false positives:
139///
140/// ```
141/// # use perfgate_host_detect::detect_host_mismatch;
142/// # use perfgate_types::HostInfo;
143/// let baseline = HostInfo {
144///     os: "linux".to_string(),
145///     arch: "x86_64".to_string(),
146///     cpu_count: Some(8),
147///     memory_bytes: None,
148///     hostname_hash: None,
149/// };
150/// let current = HostInfo {
151///     os: "linux".to_string(),
152///     arch: "x86_64".to_string(),
153///     cpu_count: Some(16),
154///     memory_bytes: None,
155///     hostname_hash: None,
156/// };
157///
158/// // Exactly 2x is still within tolerance
159/// assert!(detect_host_mismatch(&baseline, &current).is_none());
160/// ```
161///
162/// Detect significant memory differences (different cloud instance sizes):
163///
164/// ```
165/// # use perfgate_host_detect::detect_host_mismatch;
166/// # use perfgate_types::HostInfo;
167/// let baseline = HostInfo {
168///     os: "linux".to_string(),
169///     arch: "x86_64".to_string(),
170///     cpu_count: None,
171///     memory_bytes: Some(8 * 1024 * 1024 * 1024),   // 8 GB
172///     hostname_hash: None,
173/// };
174/// let current = HostInfo {
175///     os: "linux".to_string(),
176///     arch: "x86_64".to_string(),
177///     cpu_count: None,
178///     memory_bytes: Some(64 * 1024 * 1024 * 1024),  // 64 GB
179///     hostname_hash: None,
180/// };
181///
182/// let mismatch = detect_host_mismatch(&baseline, &current).unwrap();
183/// assert!(mismatch.reasons[0].contains("memory differs"));
184/// ```
185///
186/// Detect hostname hash mismatch (benchmarks ran on different machines):
187///
188/// ```
189/// # use perfgate_host_detect::detect_host_mismatch;
190/// # use perfgate_types::HostInfo;
191/// let baseline = HostInfo {
192///     os: "linux".to_string(),
193///     arch: "x86_64".to_string(),
194///     cpu_count: None,
195///     memory_bytes: None,
196///     hostname_hash: Some("abc123".to_string()),
197/// };
198/// let current = HostInfo {
199///     os: "linux".to_string(),
200///     arch: "x86_64".to_string(),
201///     cpu_count: None,
202///     memory_bytes: None,
203///     hostname_hash: Some("def456".to_string()),
204/// };
205///
206/// let mismatch = detect_host_mismatch(&baseline, &current).unwrap();
207/// assert!(mismatch.reasons[0].contains("hostname mismatch"));
208/// ```
209///
210/// Optional fields that are `None` on either side are silently skipped:
211///
212/// ```
213/// # use perfgate_host_detect::detect_host_mismatch;
214/// # use perfgate_types::HostInfo;
215/// let baseline = HostInfo {
216///     os: "linux".to_string(),
217///     arch: "x86_64".to_string(),
218///     cpu_count: Some(4),
219///     memory_bytes: Some(16 * 1024 * 1024 * 1024),
220///     hostname_hash: Some("abc".to_string()),
221/// };
222/// let current = HostInfo {
223///     os: "linux".to_string(),
224///     arch: "x86_64".to_string(),
225///     cpu_count: None,   // unknown — skipped
226///     memory_bytes: None, // unknown — skipped
227///     hostname_hash: None, // unknown — skipped
228/// };
229///
230/// assert!(detect_host_mismatch(&baseline, &current).is_none());
231/// ```
232pub fn detect_host_mismatch(baseline: &HostInfo, current: &HostInfo) -> Option<HostMismatchInfo> {
233    let mut reasons = Vec::new();
234
235    if baseline.os != current.os {
236        reasons.push(format!(
237            "OS mismatch: baseline={}, current={}",
238            baseline.os, current.os
239        ));
240    }
241
242    if baseline.arch != current.arch {
243        reasons.push(format!(
244            "architecture mismatch: baseline={}, current={}",
245            baseline.arch, current.arch
246        ));
247    }
248
249    if let (Some(base_cpu), Some(curr_cpu)) = (baseline.cpu_count, current.cpu_count) {
250        let ratio = if base_cpu > 0 && curr_cpu > 0 {
251            (base_cpu as f64 / curr_cpu as f64).max(curr_cpu as f64 / base_cpu as f64)
252        } else {
253            1.0
254        };
255        if ratio > 2.0 {
256            reasons.push(format!(
257                "CPU count differs significantly: baseline={}, current={} ({:.1}x)",
258                base_cpu, curr_cpu, ratio
259            ));
260        }
261    }
262
263    if let (Some(base_mem), Some(curr_mem)) = (baseline.memory_bytes, current.memory_bytes) {
264        let ratio = if base_mem > 0 && curr_mem > 0 {
265            (base_mem as f64 / curr_mem as f64).max(curr_mem as f64 / base_mem as f64)
266        } else {
267            1.0
268        };
269        if ratio > 2.0 {
270            let base_gb = base_mem as f64 / (1024.0 * 1024.0 * 1024.0);
271            let curr_gb = curr_mem as f64 / (1024.0 * 1024.0 * 1024.0);
272            reasons.push(format!(
273                "memory differs significantly: baseline={:.1}GB, current={:.1}GB ({:.1}x)",
274                base_gb, curr_gb, ratio
275            ));
276        }
277    }
278
279    if let (Some(base_hash), Some(curr_hash)) = (&baseline.hostname_hash, &current.hostname_hash)
280        && base_hash != curr_hash
281    {
282        reasons.push("hostname mismatch (different machines)".to_string());
283    }
284
285    if reasons.is_empty() {
286        None
287    } else {
288        Some(HostMismatchInfo { reasons })
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    fn make_host_info(os: &str, arch: &str) -> HostInfo {
297        HostInfo {
298            os: os.to_string(),
299            arch: arch.to_string(),
300            cpu_count: None,
301            memory_bytes: None,
302            hostname_hash: None,
303        }
304    }
305
306    #[test]
307    fn no_mismatch_when_identical() {
308        let baseline = make_host_info("linux", "x86_64");
309        let current = make_host_info("linux", "x86_64");
310        assert!(detect_host_mismatch(&baseline, &current).is_none());
311    }
312
313    #[test]
314    fn detects_os_mismatch() {
315        let baseline = make_host_info("linux", "x86_64");
316        let current = make_host_info("windows", "x86_64");
317        let mismatch = detect_host_mismatch(&baseline, &current);
318        assert!(mismatch.is_some());
319        let reasons = mismatch.unwrap().reasons;
320        assert!(reasons.iter().any(|r| r.contains("OS mismatch")));
321        assert!(reasons.iter().any(|r| r.contains("baseline=linux")));
322        assert!(reasons.iter().any(|r| r.contains("current=windows")));
323    }
324
325    #[test]
326    fn detects_arch_mismatch() {
327        let baseline = make_host_info("linux", "x86_64");
328        let current = make_host_info("linux", "aarch64");
329        let mismatch = detect_host_mismatch(&baseline, &current);
330        assert!(mismatch.is_some());
331        let reasons = mismatch.unwrap().reasons;
332        assert!(reasons.iter().any(|r| r.contains("architecture mismatch")));
333        assert!(reasons.iter().any(|r| r.contains("baseline=x86_64")));
334        assert!(reasons.iter().any(|r| r.contains("current=aarch64")));
335    }
336
337    #[test]
338    fn detects_cpu_count_significant_difference() {
339        let mut baseline = make_host_info("linux", "x86_64");
340        let mut current = make_host_info("linux", "x86_64");
341        baseline.cpu_count = Some(4);
342        current.cpu_count = Some(16);
343        let mismatch = detect_host_mismatch(&baseline, &current);
344        assert!(mismatch.is_some());
345        let reasons = mismatch.unwrap().reasons;
346        assert!(reasons.iter().any(|r| r.contains("CPU count differs")));
347        assert!(reasons.iter().any(|r| r.contains("4.0x")));
348    }
349
350    #[test]
351    fn ignores_cpu_count_minor_difference() {
352        let mut baseline = make_host_info("linux", "x86_64");
353        let mut current = make_host_info("linux", "x86_64");
354        baseline.cpu_count = Some(8);
355        current.cpu_count = Some(12);
356        let mismatch = detect_host_mismatch(&baseline, &current);
357        assert!(mismatch.is_none());
358    }
359
360    #[test]
361    fn cpu_count_at_exact_2x_threshold_is_not_mismatch() {
362        let mut baseline = make_host_info("linux", "x86_64");
363        let mut current = make_host_info("linux", "x86_64");
364        baseline.cpu_count = Some(4);
365        current.cpu_count = Some(8);
366        let mismatch = detect_host_mismatch(&baseline, &current);
367        assert!(mismatch.is_none());
368    }
369
370    #[test]
371    fn cpu_count_just_over_2x_is_mismatch() {
372        let mut baseline = make_host_info("linux", "x86_64");
373        let mut current = make_host_info("linux", "x86_64");
374        baseline.cpu_count = Some(4);
375        current.cpu_count = Some(9);
376        let mismatch = detect_host_mismatch(&baseline, &current);
377        assert!(mismatch.is_some());
378        let reasons = mismatch.unwrap().reasons;
379        assert!(reasons.iter().any(|r| r.contains("CPU count differs")));
380    }
381
382    #[test]
383    fn detects_memory_significant_difference() {
384        let mut baseline = make_host_info("linux", "x86_64");
385        let mut current = make_host_info("linux", "x86_64");
386        baseline.memory_bytes = Some(8 * 1024 * 1024 * 1024);
387        current.memory_bytes = Some(32 * 1024 * 1024 * 1024);
388        let mismatch = detect_host_mismatch(&baseline, &current);
389        assert!(mismatch.is_some());
390        let reasons = mismatch.unwrap().reasons;
391        assert!(reasons.iter().any(|r| r.contains("memory differs")));
392        assert!(reasons.iter().any(|r| r.contains("8.0GB")));
393        assert!(reasons.iter().any(|r| r.contains("32.0GB")));
394    }
395
396    #[test]
397    fn ignores_memory_minor_difference() {
398        let mut baseline = make_host_info("linux", "x86_64");
399        let mut current = make_host_info("linux", "x86_64");
400        baseline.memory_bytes = Some(16 * 1024 * 1024 * 1024);
401        current.memory_bytes = Some(24 * 1024 * 1024 * 1024);
402        let mismatch = detect_host_mismatch(&baseline, &current);
403        assert!(mismatch.is_none());
404    }
405
406    #[test]
407    fn memory_at_exact_2x_threshold_is_not_mismatch() {
408        let mut baseline = make_host_info("linux", "x86_64");
409        let mut current = make_host_info("linux", "x86_64");
410        baseline.memory_bytes = Some(8 * 1024 * 1024 * 1024);
411        current.memory_bytes = Some(16 * 1024 * 1024 * 1024);
412        let mismatch = detect_host_mismatch(&baseline, &current);
413        assert!(mismatch.is_none());
414    }
415
416    #[test]
417    fn detects_hostname_difference() {
418        let mut baseline = make_host_info("linux", "x86_64");
419        let mut current = make_host_info("linux", "x86_64");
420        baseline.hostname_hash = Some("abc123".to_string());
421        current.hostname_hash = Some("def456".to_string());
422        let mismatch = detect_host_mismatch(&baseline, &current);
423        assert!(mismatch.is_some());
424        let reasons = mismatch.unwrap().reasons;
425        assert!(reasons.iter().any(|r| r.contains("hostname mismatch")));
426    }
427
428    #[test]
429    fn ignores_hostname_when_only_baseline_has_it() {
430        let mut baseline = make_host_info("linux", "x86_64");
431        let current = make_host_info("linux", "x86_64");
432        baseline.hostname_hash = Some("abc123".to_string());
433        let mismatch = detect_host_mismatch(&baseline, &current);
434        assert!(mismatch.is_none());
435    }
436
437    #[test]
438    fn ignores_hostname_when_only_current_has_it() {
439        let baseline = make_host_info("linux", "x86_64");
440        let mut current = make_host_info("linux", "x86_64");
441        current.hostname_hash = Some("def456".to_string());
442        let mismatch = detect_host_mismatch(&baseline, &current);
443        assert!(mismatch.is_none());
444    }
445
446    #[test]
447    fn ignores_hostname_when_both_are_none() {
448        let baseline = make_host_info("linux", "x86_64");
449        let current = make_host_info("linux", "x86_64");
450        let mismatch = detect_host_mismatch(&baseline, &current);
451        assert!(mismatch.is_none());
452    }
453
454    #[test]
455    fn same_hostname_hash_is_not_mismatch() {
456        let mut baseline = make_host_info("linux", "x86_64");
457        let mut current = make_host_info("linux", "x86_64");
458        baseline.hostname_hash = Some("abc123".to_string());
459        current.hostname_hash = Some("abc123".to_string());
460        let mismatch = detect_host_mismatch(&baseline, &current);
461        assert!(mismatch.is_none());
462    }
463
464    #[test]
465    fn detects_multiple_simultaneous_mismatches() {
466        let baseline = HostInfo {
467            os: "linux".to_string(),
468            arch: "x86_64".to_string(),
469            cpu_count: Some(4),
470            memory_bytes: Some(8 * 1024 * 1024 * 1024),
471            hostname_hash: Some("abc".to_string()),
472        };
473        let current = HostInfo {
474            os: "windows".to_string(),
475            arch: "aarch64".to_string(),
476            cpu_count: Some(32),
477            memory_bytes: Some(64 * 1024 * 1024 * 1024),
478            hostname_hash: Some("def".to_string()),
479        };
480        let mismatch = detect_host_mismatch(&baseline, &current);
481        assert!(mismatch.is_some());
482        let reasons = mismatch.unwrap().reasons;
483        assert_eq!(reasons.len(), 5);
484    }
485
486    #[test]
487    fn partial_fields_none_handling_cpu() {
488        let mut baseline = make_host_info("linux", "x86_64");
489        let mut current = make_host_info("linux", "x86_64");
490        baseline.cpu_count = Some(4);
491        current.cpu_count = None;
492        let mismatch = detect_host_mismatch(&baseline, &current);
493        assert!(mismatch.is_none());
494    }
495
496    #[test]
497    fn partial_fields_none_handling_memory() {
498        let mut baseline = make_host_info("linux", "x86_64");
499        let mut current = make_host_info("linux", "x86_64");
500        baseline.memory_bytes = None;
501        current.memory_bytes = Some(32 * 1024 * 1024 * 1024);
502        let mismatch = detect_host_mismatch(&baseline, &current);
503        assert!(mismatch.is_none());
504    }
505
506    #[test]
507    fn zero_cpu_count_is_handled_gracefully() {
508        let mut baseline = make_host_info("linux", "x86_64");
509        let mut current = make_host_info("linux", "x86_64");
510        baseline.cpu_count = Some(0);
511        current.cpu_count = Some(8);
512        let mismatch = detect_host_mismatch(&baseline, &current);
513        assert!(mismatch.is_none());
514    }
515
516    #[test]
517    fn zero_memory_is_handled_gracefully() {
518        let mut baseline = make_host_info("linux", "x86_64");
519        let mut current = make_host_info("linux", "x86_64");
520        baseline.memory_bytes = Some(0);
521        current.memory_bytes = Some(32 * 1024 * 1024 * 1024);
522        let mismatch = detect_host_mismatch(&baseline, &current);
523        assert!(mismatch.is_none());
524    }
525
526    #[test]
527    fn cpu_count_ratio_works_both_directions() {
528        let mut baseline = make_host_info("linux", "x86_64");
529        let mut current = make_host_info("linux", "x86_64");
530
531        baseline.cpu_count = Some(16);
532        current.cpu_count = Some(4);
533        let mismatch = detect_host_mismatch(&baseline, &current);
534        assert!(mismatch.is_some());
535    }
536
537    #[test]
538    fn memory_ratio_works_both_directions() {
539        let mut baseline = make_host_info("linux", "x86_64");
540        let mut current = make_host_info("linux", "x86_64");
541
542        baseline.memory_bytes = Some(64 * 1024 * 1024 * 1024);
543        current.memory_bytes = Some(8 * 1024 * 1024 * 1024);
544        let mismatch = detect_host_mismatch(&baseline, &current);
545        assert!(mismatch.is_some());
546    }
547
548    #[test]
549    fn identical_fully_populated_no_mismatch() {
550        let host = HostInfo {
551            os: "linux".to_string(),
552            arch: "x86_64".to_string(),
553            cpu_count: Some(8),
554            memory_bytes: Some(16 * 1024 * 1024 * 1024),
555            hostname_hash: Some("abc123def456".to_string()),
556        };
557        assert!(detect_host_mismatch(&host, &host.clone()).is_none());
558    }
559
560    #[test]
561    fn equal_cpu_count_no_mismatch() {
562        let mut baseline = make_host_info("linux", "x86_64");
563        let mut current = make_host_info("linux", "x86_64");
564        baseline.cpu_count = Some(16);
565        current.cpu_count = Some(16);
566        assert!(detect_host_mismatch(&baseline, &current).is_none());
567    }
568
569    #[test]
570    fn equal_memory_no_mismatch() {
571        let mut baseline = make_host_info("linux", "x86_64");
572        let mut current = make_host_info("linux", "x86_64");
573        baseline.memory_bytes = Some(32 * 1024 * 1024 * 1024);
574        current.memory_bytes = Some(32 * 1024 * 1024 * 1024);
575        assert!(detect_host_mismatch(&baseline, &current).is_none());
576    }
577
578    #[test]
579    fn os_mismatch_reason_contains_both_values() {
580        let baseline = make_host_info("macos", "x86_64");
581        let current = make_host_info("linux", "x86_64");
582        let reasons = detect_host_mismatch(&baseline, &current).unwrap().reasons;
583        assert_eq!(reasons.len(), 1);
584        assert!(reasons[0].contains("macos"));
585        assert!(reasons[0].contains("linux"));
586    }
587
588    #[test]
589    fn arch_mismatch_reason_contains_both_values() {
590        let baseline = make_host_info("linux", "arm64");
591        let current = make_host_info("linux", "x86_64");
592        let reasons = detect_host_mismatch(&baseline, &current).unwrap().reasons;
593        assert_eq!(reasons.len(), 1);
594        assert!(reasons[0].contains("arm64"));
595        assert!(reasons[0].contains("x86_64"));
596    }
597
598    #[test]
599    fn cpu_mismatch_reason_contains_counts_and_ratio() {
600        let mut baseline = make_host_info("linux", "x86_64");
601        let mut current = make_host_info("linux", "x86_64");
602        baseline.cpu_count = Some(2);
603        current.cpu_count = Some(8);
604        let reasons = detect_host_mismatch(&baseline, &current).unwrap().reasons;
605        assert!(reasons[0].contains("baseline=2"));
606        assert!(reasons[0].contains("current=8"));
607        assert!(reasons[0].contains("4.0x"));
608    }
609
610    #[test]
611    fn hostname_mismatch_only_one_reason() {
612        let mut baseline = make_host_info("linux", "x86_64");
613        let mut current = make_host_info("linux", "x86_64");
614        baseline.hostname_hash = Some("aaa".to_string());
615        current.hostname_hash = Some("bbb".to_string());
616        let reasons = detect_host_mismatch(&baseline, &current).unwrap().reasons;
617        assert_eq!(reasons.len(), 1);
618        assert!(reasons[0].contains("hostname mismatch"));
619    }
620
621    #[test]
622    fn multiple_mismatches_os_and_arch() {
623        let baseline = make_host_info("linux", "x86_64");
624        let current = make_host_info("windows", "aarch64");
625        let reasons = detect_host_mismatch(&baseline, &current).unwrap().reasons;
626        assert_eq!(reasons.len(), 2);
627        assert!(reasons.iter().any(|r| r.contains("OS mismatch")));
628        assert!(reasons.iter().any(|r| r.contains("architecture mismatch")));
629    }
630
631    #[test]
632    fn all_none_optional_fields_no_mismatch() {
633        let baseline = HostInfo {
634            os: "linux".to_string(),
635            arch: "x86_64".to_string(),
636            cpu_count: None,
637            memory_bytes: None,
638            hostname_hash: None,
639        };
640        let current = baseline.clone();
641        assert!(detect_host_mismatch(&baseline, &current).is_none());
642    }
643
644    #[test]
645    fn both_zero_cpu_and_zero_memory_no_mismatch() {
646        let mut baseline = make_host_info("linux", "x86_64");
647        let mut current = make_host_info("linux", "x86_64");
648        baseline.cpu_count = Some(0);
649        current.cpu_count = Some(0);
650        baseline.memory_bytes = Some(0);
651        current.memory_bytes = Some(0);
652        assert!(detect_host_mismatch(&baseline, &current).is_none());
653    }
654}
655
656#[cfg(test)]
657mod property_tests {
658    use super::*;
659    use proptest::prelude::*;
660
661    fn host_info_strategy() -> impl Strategy<Value = HostInfo> {
662        (
663            "[a-z]{3,10}",
664            "[a-z0-9_]{3,10}",
665            proptest::option::of(1u32..256u32),
666            proptest::option::of(1u64..68719476736u64),
667            proptest::option::of("[a-f0-9]{16}"),
668        )
669            .prop_map(
670                |(os, arch, cpu_count, memory_bytes, hostname_hash)| HostInfo {
671                    os,
672                    arch,
673                    cpu_count,
674                    memory_bytes,
675                    hostname_hash,
676                },
677            )
678    }
679
680    proptest! {
681        #[test]
682        fn idempotence_same_host_returns_none(host in host_info_strategy()) {
683            prop_assert!(detect_host_mismatch(&host, &host).is_none());
684        }
685
686        #[test]
687        fn symmetry_detect_a_b_implies_detect_b_a(
688            baseline in host_info_strategy(),
689            current in host_info_strategy()
690        ) {
691            let forward = detect_host_mismatch(&baseline, &current);
692            let reverse = detect_host_mismatch(&current, &baseline);
693
694            match (&forward, &reverse) {
695                (None, None) => prop_assert!(true),
696                (Some(f), Some(r)) => {
697                    prop_assert_eq!(f.reasons.len(), r.reasons.len());
698                }
699                _ => prop_assert!(false, "symmetry violated: forward={:?}, reverse={:?}", forward, reverse),
700            }
701        }
702
703        #[test]
704        fn os_difference_always_detected(
705            os1 in "[a-z]{3,10}",
706            os2 in "[a-z]{3,10}",
707            arch in "[a-z0-9_]{3,10}"
708        ) {
709            prop_assume!(os1 != os2);
710            let baseline = HostInfo {
711                os: os1.clone(),
712                arch: arch.clone(),
713                cpu_count: None,
714                memory_bytes: None,
715                hostname_hash: None,
716            };
717            let current = HostInfo {
718                os: os2.clone(),
719                arch,
720                cpu_count: None,
721                memory_bytes: None,
722                hostname_hash: None,
723            };
724            let mismatch = detect_host_mismatch(&baseline, &current);
725            prop_assert!(mismatch.is_some());
726            prop_assert!(mismatch.unwrap().reasons.iter().any(|r| r.contains("OS mismatch")));
727        }
728
729        #[test]
730        fn arch_difference_always_detected(
731            arch1 in "[a-z0-9_]{3,10}",
732            arch2 in "[a-z0-9_]{3,10}",
733            os in "[a-z]{3,10}"
734        ) {
735            prop_assume!(arch1 != arch2);
736            let baseline = HostInfo {
737                os: os.clone(),
738                arch: arch1.clone(),
739                cpu_count: None,
740                memory_bytes: None,
741                hostname_hash: None,
742            };
743            let current = HostInfo {
744                os,
745                arch: arch2.clone(),
746                cpu_count: None,
747                memory_bytes: None,
748                hostname_hash: None,
749            };
750            let mismatch = detect_host_mismatch(&baseline, &current);
751            prop_assert!(mismatch.is_some());
752            prop_assert!(mismatch.unwrap().reasons.iter().any(|r| r.contains("architecture mismatch")));
753        }
754
755        #[test]
756        fn cpu_count_2x_plus_1_always_detected(
757            small_cpu in 1u32..100u32,
758        ) {
759            let large_cpu = small_cpu * 2 + 1;
760            let mut baseline = HostInfo {
761                os: "linux".to_string(),
762                arch: "x86_64".to_string(),
763                cpu_count: Some(small_cpu),
764                memory_bytes: None,
765                hostname_hash: None,
766            };
767            let mut current = HostInfo {
768                os: "linux".to_string(),
769                arch: "x86_64".to_string(),
770                cpu_count: Some(large_cpu),
771                memory_bytes: None,
772                hostname_hash: None,
773            };
774
775            let mismatch_forward = detect_host_mismatch(&baseline, &current);
776            prop_assert!(mismatch_forward.is_some());
777
778            std::mem::swap(&mut baseline.cpu_count, &mut current.cpu_count);
779            let mismatch_reverse = detect_host_mismatch(&baseline, &current);
780            prop_assert!(mismatch_reverse.is_some());
781        }
782
783        #[test]
784        fn cpu_count_exact_2x_not_detected(
785            cpu in 1u32..100u32,
786        ) {
787            let baseline = HostInfo {
788                os: "linux".to_string(),
789                arch: "x86_64".to_string(),
790                cpu_count: Some(cpu),
791                memory_bytes: None,
792                hostname_hash: None,
793            };
794            let current = HostInfo {
795                os: "linux".to_string(),
796                arch: "x86_64".to_string(),
797                cpu_count: Some(cpu * 2),
798                memory_bytes: None,
799                hostname_hash: None,
800            };
801            let mismatch = detect_host_mismatch(&baseline, &current);
802            prop_assert!(mismatch.is_none());
803        }
804
805        #[test]
806        fn memory_2x_plus_1gb_always_detected(
807            small_mem_gb in 1u64..32u64,
808        ) {
809            let small_mem = small_mem_gb * 1024 * 1024 * 1024;
810            let large_mem = small_mem * 2 + (1024 * 1024 * 1024);
811            let mut baseline = HostInfo {
812                os: "linux".to_string(),
813                arch: "x86_64".to_string(),
814                cpu_count: None,
815                memory_bytes: Some(small_mem),
816                hostname_hash: None,
817            };
818            let mut current = HostInfo {
819                os: "linux".to_string(),
820                arch: "x86_64".to_string(),
821                cpu_count: None,
822                memory_bytes: Some(large_mem),
823                hostname_hash: None,
824            };
825
826            let mismatch_forward = detect_host_mismatch(&baseline, &current);
827            prop_assert!(mismatch_forward.is_some());
828
829            std::mem::swap(&mut baseline.memory_bytes, &mut current.memory_bytes);
830            let mismatch_reverse = detect_host_mismatch(&baseline, &current);
831            prop_assert!(mismatch_reverse.is_some());
832        }
833
834        #[test]
835        fn hostname_hash_difference_detected_when_both_present(
836            hash1 in "[a-f0-9]{16}",
837            hash2 in "[a-f0-9]{16}"
838        ) {
839            prop_assume!(hash1 != hash2);
840            let baseline = HostInfo {
841                os: "linux".to_string(),
842                arch: "x86_64".to_string(),
843                cpu_count: None,
844                memory_bytes: None,
845                hostname_hash: Some(hash1),
846            };
847            let current = HostInfo {
848                os: "linux".to_string(),
849                arch: "x86_64".to_string(),
850                cpu_count: None,
851                memory_bytes: None,
852                hostname_hash: Some(hash2),
853            };
854            let mismatch = detect_host_mismatch(&baseline, &current);
855            prop_assert!(mismatch.is_some());
856            prop_assert!(mismatch.unwrap().reasons.iter().any(|r| r.contains("hostname mismatch")));
857        }
858
859        #[test]
860        fn none_fields_do_not_cause_mismatch(
861            host in host_info_strategy()
862        ) {
863            let minimal = HostInfo {
864                os: host.os.clone(),
865                arch: host.arch.clone(),
866                cpu_count: None,
867                memory_bytes: None,
868                hostname_hash: None,
869            };
870            let mismatch = detect_host_mismatch(&host, &minimal);
871            prop_assert!(mismatch.is_none());
872        }
873    }
874}