Skip to main content

git_perf/
audit.rs

1use crate::{
2    config,
3    data::{Commit, MeasurementData},
4    defaults,
5    measurement_retrieval::{self, summarize_measurements},
6    stats::{self, DispersionMethod, ReductionFunc, StatsWithUnit, VecAggregation},
7};
8use anyhow::{anyhow, bail, Result};
9use itertools::Itertools;
10use log::info;
11use sparklines::spark;
12use std::cmp::Ordering;
13use std::collections::HashSet;
14use std::iter;
15
16/// Formats a z-score for display in audit output.
17/// Only finite z-scores are displayed with numeric values.
18/// Infinite and NaN values return an empty string.
19fn format_z_score_display(z_score: f64) -> String {
20    if z_score.is_finite() {
21        format!(" {:.2}", z_score)
22    } else {
23        String::new()
24    }
25}
26
27/// Determines the direction arrow based on comparison of head and tail means.
28/// Returns ↑ for greater, ↓ for less, → for equal.
29/// Returns → for NaN values to avoid panicking.
30fn get_direction_arrow(head_mean: f64, tail_mean: f64) -> &'static str {
31    match head_mean.partial_cmp(&tail_mean) {
32        Some(Ordering::Greater) => "↑",
33        Some(Ordering::Less) => "↓",
34        Some(Ordering::Equal) | None => "→",
35    }
36}
37
38#[derive(Debug, PartialEq)]
39struct AuditResult {
40    message: String,
41    passed: bool,
42}
43
44/// Resolved audit parameters for a specific measurement.
45#[derive(Debug, PartialEq)]
46pub(crate) struct ResolvedAuditParams {
47    pub min_count: u16,
48    pub summarize_by: ReductionFunc,
49    pub sigma: f64,
50    pub dispersion_method: DispersionMethod,
51}
52
53/// Resolves audit parameters for a specific measurement with proper precedence:
54/// CLI option -> measurement-specific config -> global config -> built-in default
55///
56/// Note: When CLI provides min_count, the caller (audit_multiple) uses the same
57/// value for all measurements. When CLI is None, this function reads per-measurement config.
58pub(crate) fn resolve_audit_params(
59    measurement: &str,
60    cli_min_count: Option<u16>,
61    cli_summarize_by: Option<ReductionFunc>,
62    cli_sigma: Option<f64>,
63    cli_dispersion_method: Option<DispersionMethod>,
64) -> ResolvedAuditParams {
65    let min_count = cli_min_count
66        .or_else(|| config::audit_min_measurements(measurement))
67        .unwrap_or(defaults::DEFAULT_MIN_MEASUREMENTS);
68
69    let summarize_by = cli_summarize_by
70        .or_else(|| config::audit_aggregate_by(measurement).map(ReductionFunc::from))
71        .unwrap_or(ReductionFunc::Min);
72
73    let sigma = cli_sigma
74        .or_else(|| config::audit_sigma(measurement))
75        .unwrap_or(defaults::DEFAULT_SIGMA);
76
77    let dispersion_method = cli_dispersion_method
78        .or_else(|| {
79            Some(DispersionMethod::from(config::audit_dispersion_method(
80                measurement,
81            )))
82        })
83        .unwrap_or(DispersionMethod::StandardDeviation);
84
85    ResolvedAuditParams {
86        min_count,
87        summarize_by,
88        sigma,
89        dispersion_method,
90    }
91}
92
93/// Discovers all unique measurement names from commits that match the filters and selectors.
94/// This is used to efficiently find which measurements to audit when filters are provided.
95fn discover_matching_measurements(
96    commits: &[Result<Commit>],
97    filters: &[regex::Regex],
98    selectors: &[(String, String)],
99) -> Vec<String> {
100    let mut unique_measurements = HashSet::new();
101
102    for commit in commits.iter().flatten() {
103        for measurement in &commit.measurements {
104            // Check if measurement name matches any filter
105            if !crate::filter::matches_any_filter(&measurement.name, filters) {
106                continue;
107            }
108
109            // Check if measurement matches selectors
110            if !measurement.key_values_is_superset_of(selectors) {
111                continue;
112            }
113
114            // This measurement matches - add to set
115            unique_measurements.insert(measurement.name.clone());
116        }
117    }
118
119    // Convert to sorted vector for deterministic ordering
120    let mut result: Vec<String> = unique_measurements.into_iter().collect();
121    result.sort();
122    result
123}
124
125#[allow(clippy::too_many_arguments)]
126pub fn audit_multiple(
127    start_commit: &str,
128    max_count: usize,
129    min_count: Option<u16>,
130    selectors: &[(String, String)],
131    summarize_by: Option<ReductionFunc>,
132    sigma: Option<f64>,
133    dispersion_method: Option<DispersionMethod>,
134    combined_patterns: &[String],
135    _no_change_point_warning: bool, // TODO: Implement change point warning in Phase 2
136) -> Result<()> {
137    // Early return if patterns are empty - nothing to audit
138    if combined_patterns.is_empty() {
139        return Ok(());
140    }
141
142    // Compile combined regex patterns (measurements as exact matches + filter patterns)
143    // early to fail fast on invalid patterns
144    let filters = crate::filter::compile_filters(combined_patterns)?;
145
146    // Phase 1: Walk commits ONCE (optimization: scan commits only once)
147    // Collect into Vec so we can reuse the data for multiple measurements
148    let all_commits: Vec<Result<Commit>> =
149        measurement_retrieval::walk_commits_from(start_commit, max_count)?.collect();
150
151    // Phase 2: Discover all measurements that match the combined patterns from the commit data
152    // The combined_patterns already include both measurements (as exact regex) and filters (OR behavior)
153    let measurements_to_audit = discover_matching_measurements(&all_commits, &filters, selectors);
154
155    // If no measurements were discovered, provide appropriate error message
156    if measurements_to_audit.is_empty() {
157        // Check if we have any commits at all
158        if all_commits.is_empty() {
159            bail!("No commit at HEAD");
160        }
161        // Check if any commits have any measurements at all
162        let has_any_measurements = all_commits.iter().any(|commit_result| {
163            if let Ok(commit) = commit_result {
164                !commit.measurements.is_empty()
165            } else {
166                false
167            }
168        });
169
170        if !has_any_measurements {
171            // No measurements exist in any commits - specific error for this case
172            bail!("No measurement for HEAD");
173        }
174        // Measurements exist but don't match the patterns
175        bail!("No measurements found matching the provided patterns");
176    }
177
178    let mut failed = false;
179
180    // Phase 3: For each measurement, audit using the pre-loaded commit data
181    for measurement in measurements_to_audit {
182        let params = resolve_audit_params(
183            &measurement,
184            min_count,
185            summarize_by,
186            sigma,
187            dispersion_method,
188        );
189
190        // Warn if max_count limits historical data below min_measurements requirement
191        if (max_count as u16) < params.min_count {
192            eprintln!(
193                "⚠️  Warning: --max_count ({}) is less than min_measurements ({}) for measurement '{}'.",
194                max_count, params.min_count, measurement
195            );
196            eprintln!(
197                "   This limits available historical data and may prevent achieving statistical significance."
198            );
199        }
200
201        let result = audit_with_commits(
202            &measurement,
203            &all_commits,
204            params.min_count,
205            selectors,
206            params.summarize_by,
207            params.sigma,
208            params.dispersion_method,
209        )?;
210
211        // TODO(Phase 2): Add change point detection warning here
212        // If !_no_change_point_warning, detect change points in current epoch
213        // and warn if any exist, as they make z-score comparisons unreliable:
214        //   ⚠️  WARNING: Change point detected in current epoch at commit a1b2c3d (+23.5%)
215        //       Historical z-score comparison may be unreliable due to regime shift.
216        //       Consider bumping epoch or investigating the change.
217        // See docs/plans/change-point-detection.md for implementation details.
218
219        println!("{}", result.message);
220
221        if !result.passed {
222            failed = true;
223        }
224    }
225
226    if failed {
227        bail!("One or more measurements failed audit.");
228    }
229
230    Ok(())
231}
232
233/// Audits a measurement using pre-loaded commit data.
234/// This is more efficient than the old `audit` function when auditing multiple measurements,
235/// as it reuses the same commit data instead of walking commits multiple times.
236fn audit_with_commits(
237    measurement: &str,
238    commits: &[Result<Commit>],
239    min_count: u16,
240    selectors: &[(String, String)],
241    summarize_by: ReductionFunc,
242    sigma: f64,
243    dispersion_method: DispersionMethod,
244) -> Result<AuditResult> {
245    // Convert Vec<Result<Commit>> into an iterator of Result<Commit> by cloning references
246    // This is necessary because summarize_measurements expects an iterator of Result<Commit>
247    let commits_iter = commits.iter().map(|r| match r {
248        Ok(commit) => Ok(Commit {
249            commit: commit.commit.clone(),
250            title: commit.title.clone(),
251            author: commit.author.clone(),
252            measurements: commit.measurements.clone(),
253        }),
254        Err(e) => Err(anyhow::anyhow!("{}", e)),
255    });
256
257    // Filter to only this specific measurement with matching selectors
258    let filter_by =
259        |m: &MeasurementData| m.name == measurement && m.key_values_is_superset_of(selectors);
260
261    let mut aggregates = measurement_retrieval::take_while_same_epoch(summarize_measurements(
262        commits_iter,
263        &summarize_by,
264        &filter_by,
265    ));
266
267    let head = aggregates
268        .next()
269        .ok_or(anyhow!("No commit at HEAD"))
270        .and_then(|s| {
271            s.and_then(|cs| {
272                cs.measurement
273                    .map(|m| m.val)
274                    .ok_or(anyhow!("No measurement for HEAD."))
275            })
276        })?;
277
278    let tail: Vec<_> = aggregates
279        .filter_map_ok(|cs| cs.measurement.map(|m| m.val))
280        .try_collect()?;
281
282    audit_with_data(
283        measurement,
284        head,
285        tail,
286        min_count,
287        sigma,
288        dispersion_method,
289        summarize_by,
290    )
291}
292
293/// Core audit logic that can be tested with mock data
294/// This function contains all the mutation-tested logic paths
295fn audit_with_data(
296    measurement: &str,
297    head: f64,
298    tail: Vec<f64>,
299    min_count: u16,
300    sigma: f64,
301    dispersion_method: DispersionMethod,
302    summarize_by: ReductionFunc,
303) -> Result<AuditResult> {
304    // Note: CLI enforces min_count >= 2 via clap::value_parser!(u16).range(2..)
305    // Tests may use lower values for edge case testing, but production code
306    // should never call this with min_count < 2
307    assert!(min_count >= 2, "min_count must be at least 2");
308
309    // Get unit for this measurement from config
310    let unit = config::measurement_unit(measurement);
311    let unit_str = unit.as_deref();
312
313    let head_summary = stats::aggregate_measurements(iter::once(&head));
314    let tail_summary = stats::aggregate_measurements(tail.iter());
315
316    // Generate sparkline and calculate range for all measurements - used in both skip and normal paths
317    let all_measurements = tail.into_iter().chain(iter::once(head)).collect::<Vec<_>>();
318
319    let mut tail_measurements = all_measurements.clone();
320    tail_measurements.pop(); // Remove head to get just tail for median calculation
321    let tail_median = tail_measurements.median().unwrap_or_default();
322
323    // Calculate min and max once for use in both branches
324    let min_val = all_measurements
325        .iter()
326        .min_by(|a, b| a.partial_cmp(b).unwrap())
327        .unwrap();
328    let max_val = all_measurements
329        .iter()
330        .max_by(|a, b| a.partial_cmp(b).unwrap())
331        .unwrap();
332
333    // Tiered approach for sparkline display:
334    // 1. If tail median is non-zero: use median as baseline, show percentages (default behavior)
335    // 2. If tail median is zero: show absolute differences instead
336    let tail_median_is_zero = tail_median.abs() < f64::EPSILON;
337
338    let sparkline = if tail_median_is_zero {
339        // Median is zero - show absolute range
340        format!(
341            " [{} – {}] {}",
342            min_val,
343            max_val,
344            spark(all_measurements.as_slice())
345        )
346    } else {
347        // MUTATION POINT: / vs % (Line 140)
348        // Median is non-zero - use it as baseline for percentage ranges
349        let relative_min = min_val / tail_median - 1.0;
350        let relative_max = max_val / tail_median - 1.0;
351
352        format!(
353            " [{:+.2}% – {:+.2}%] {}",
354            (relative_min * 100.0),
355            (relative_max * 100.0),
356            spark(all_measurements.as_slice())
357        )
358    };
359
360    // Helper function to build the measurement summary text
361    // This is used for both skipped and normal audit results to avoid duplication
362    let build_summary = || -> String {
363        let mut summary = String::new();
364
365        // Use the length of all_measurements vector for total count
366        let total_measurements = all_measurements.len();
367
368        // If only 1 total measurement (head only, no tail), show only head summary
369        if total_measurements == 1 {
370            let head_display = StatsWithUnit {
371                stats: &head_summary,
372                unit: unit_str,
373            };
374            summary.push_str(&format!("Head: {}\n", head_display));
375        } else if total_measurements >= 2 {
376            // 2+ measurements: show aggregation method, z-score, head, tail, and sparkline
377            let direction = get_direction_arrow(head_summary.mean, tail_summary.mean);
378            let z_score = head_summary.z_score_with_method(&tail_summary, dispersion_method);
379            let z_score_display = format_z_score_display(z_score);
380            let method_name = match dispersion_method {
381                DispersionMethod::StandardDeviation => "stddev",
382                DispersionMethod::MedianAbsoluteDeviation => "mad",
383            };
384
385            let head_display = StatsWithUnit {
386                stats: &head_summary,
387                unit: unit_str,
388            };
389            let tail_display = StatsWithUnit {
390                stats: &tail_summary,
391                unit: unit_str,
392            };
393
394            summary.push_str(&format!("Aggregation: {summarize_by}\n"));
395            summary.push_str(&format!(
396                "z-score ({method_name}): {direction}{}\n",
397                z_score_display
398            ));
399            summary.push_str(&format!("Head: {}\n", head_display));
400            summary.push_str(&format!("Tail: {}\n", tail_display));
401            summary.push_str(&sparkline);
402        }
403        // If 0 total measurements, return empty summary
404
405        summary
406    };
407
408    // MUTATION POINT: < vs == (Line 120)
409    if tail_summary.len < min_count.into() {
410        let number_measurements = tail_summary.len;
411        // MUTATION POINT: > vs < (Line 122)
412        let plural_s = if number_measurements == 1 { "" } else { "s" };
413        info!("Only {number_measurements} historical measurement{plural_s} found. Less than requested min_measurements of {min_count}. Skipping test.");
414
415        let mut skip_message = format!(
416            "⏭️ '{measurement}'\nOnly {number_measurements} historical measurement{plural_s} found. Less than requested min_measurements of {min_count}. Skipping test."
417        );
418
419        // Add summary using the same logic as passing/failing cases
420        let summary = build_summary();
421        if !summary.is_empty() {
422            skip_message.push('\n');
423            skip_message.push_str(&summary);
424        }
425
426        return Ok(AuditResult {
427            message: skip_message,
428            passed: true,
429        });
430    }
431
432    // MUTATION POINT: / vs % (Line 150)
433    // Calculate relative deviation - naturally handles infinity when tail_median is zero
434    let head_relative_deviation = (head / tail_median - 1.0).abs() * 100.0;
435
436    // Calculate absolute deviation
437    let head_absolute_deviation = (head - tail_median).abs();
438
439    // Check if we have a minimum relative deviation threshold configured
440    let min_relative_deviation = config::audit_min_relative_deviation(measurement);
441    let min_absolute_deviation = config::audit_min_absolute_deviation(measurement);
442
443    // MUTATION POINT: < vs == (Line 156)
444    let passed_due_to_relative_threshold = min_relative_deviation
445        .map(|threshold| head_relative_deviation < threshold)
446        .unwrap_or(false);
447
448    let passed_due_to_absolute_threshold = min_absolute_deviation
449        .map(|threshold| head_absolute_deviation < threshold)
450        .unwrap_or(false);
451
452    let passed_due_to_threshold =
453        passed_due_to_relative_threshold || passed_due_to_absolute_threshold;
454
455    let text_summary = build_summary();
456
457    // MUTATION POINT: > vs >= (Line 178)
458    let z_score_exceeds_sigma =
459        head_summary.is_significant(&tail_summary, sigma, dispersion_method);
460
461    // MUTATION POINT: ! removal (Line 181)
462    let passed = !z_score_exceeds_sigma || passed_due_to_threshold;
463
464    // Add threshold information to output if applicable
465    // Only show note when the audit would have failed without the threshold
466    let threshold_note = if z_score_exceeds_sigma {
467        let mut notes = Vec::new();
468        if passed_due_to_relative_threshold {
469            notes.push(format!(
470                "Note: Passed due to relative deviation ({:.1}%) being below threshold ({:.1}%)",
471                head_relative_deviation,
472                min_relative_deviation.unwrap()
473            ));
474        }
475        if passed_due_to_absolute_threshold {
476            notes.push(format!(
477                "Note: Passed due to absolute deviation ({:.1}) being below threshold ({:.1})",
478                head_absolute_deviation,
479                min_absolute_deviation.unwrap()
480            ));
481        }
482        if notes.is_empty() {
483            String::new()
484        } else {
485            format!("\n{}", notes.join("\n"))
486        }
487    } else {
488        String::new()
489    };
490
491    // MUTATION POINT: ! removal (Line 194)
492    if !passed {
493        return Ok(AuditResult {
494            message: format!(
495                "❌ '{measurement}'\nHEAD differs significantly from tail measurements.\n{text_summary}{threshold_note}"
496            ),
497            passed: false,
498        });
499    }
500
501    Ok(AuditResult {
502        message: format!("✅ '{measurement}'\n{text_summary}{threshold_note}"),
503        passed: true,
504    })
505}
506
507#[cfg(test)]
508mod test {
509    use crate::test_helpers::with_isolated_test_setup;
510
511    use super::*;
512
513    #[test]
514    fn test_format_z_score_display() {
515        // Test cases for z-score display formatting
516        let test_cases = vec![
517            (2.5_f64, " 2.50"),
518            (0.0_f64, " 0.00"),
519            (-1.5_f64, " -1.50"),
520            (999.999_f64, " 1000.00"),
521            (0.001_f64, " 0.00"),
522            (f64::INFINITY, ""),
523            (f64::NEG_INFINITY, ""),
524            (f64::NAN, ""),
525        ];
526
527        for (z_score, expected) in test_cases {
528            let result = format_z_score_display(z_score);
529            assert_eq!(result, expected, "Failed for z_score: {}", z_score);
530        }
531    }
532
533    #[test]
534    fn test_direction_arrows() {
535        // Test cases for direction arrow logic
536        let test_cases = vec![
537            (5.0_f64, 3.0_f64, "↑"), // head > tail
538            (1.0_f64, 3.0_f64, "↓"), // head < tail
539            (3.0_f64, 3.0_f64, "→"), // head == tail
540        ];
541
542        for (head_mean, tail_mean, expected) in test_cases {
543            let result = get_direction_arrow(head_mean, tail_mean);
544            assert_eq!(
545                result, expected,
546                "Failed for head_mean: {}, tail_mean: {}",
547                head_mean, tail_mean
548            );
549        }
550    }
551
552    #[test]
553    fn test_audit_with_different_dispersion_methods() {
554        // Test that audit produces different results with different dispersion methods
555
556        // Create mock data that would produce different z-scores with stddev vs MAD
557        let head_value = 35.0;
558        let tail_values = [30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 100.0];
559
560        let head_summary = stats::aggregate_measurements(std::iter::once(&head_value));
561        let tail_summary = stats::aggregate_measurements(tail_values.iter());
562
563        // Calculate z-scores with both methods
564        let z_score_stddev =
565            head_summary.z_score_with_method(&tail_summary, DispersionMethod::StandardDeviation);
566        let z_score_mad = head_summary
567            .z_score_with_method(&tail_summary, DispersionMethod::MedianAbsoluteDeviation);
568
569        // With the outlier (100.0), stddev should be much larger than MAD
570        // So z-score with stddev should be smaller than z-score with MAD
571        assert!(
572            z_score_stddev < z_score_mad,
573            "stddev z-score ({}) should be smaller than MAD z-score ({}) with outlier data",
574            z_score_stddev,
575            z_score_mad
576        );
577
578        // Both should be positive since head > tail mean
579        assert!(z_score_stddev > 0.0);
580        assert!(z_score_mad > 0.0);
581    }
582
583    #[test]
584    fn test_dispersion_method_conversion() {
585        // Test that the conversion from CLI types to stats types works correctly
586
587        // Test stddev conversion
588        let cli_stddev = git_perf_cli_types::DispersionMethod::StandardDeviation;
589        let stats_stddev: DispersionMethod = cli_stddev.into();
590        assert_eq!(stats_stddev, DispersionMethod::StandardDeviation);
591
592        // Test MAD conversion
593        let cli_mad = git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation;
594        let stats_mad: DispersionMethod = cli_mad.into();
595        assert_eq!(stats_mad, DispersionMethod::MedianAbsoluteDeviation);
596    }
597
598    #[test]
599    fn test_audit_multiple_with_no_measurements() {
600        // This test exercises the actual production audit_multiple function
601        // Tests the case where no patterns are provided (empty list)
602        // With no patterns, it should succeed (nothing to audit)
603        with_isolated_test_setup(|_git_dir, _home_path| {
604            let result = audit_multiple(
605                "HEAD",
606                100,
607                Some(1),
608                &[],
609                Some(ReductionFunc::Mean),
610                Some(2.0),
611                Some(DispersionMethod::StandardDeviation),
612                &[], // Empty combined_patterns
613                false,
614            );
615
616            // Should succeed when no measurements need to be audited
617            assert!(
618                result.is_ok(),
619                "audit_multiple should succeed with empty pattern list"
620            );
621        });
622    }
623
624    // MUTATION TESTING COVERAGE TESTS - Exercise actual production code paths
625
626    #[test]
627    fn test_min_count_boundary_condition() {
628        // COVERS MUTATION: tail_summary.len < min_count.into() vs ==
629        // Test with exactly min_count measurements (should NOT skip)
630        let result = audit_with_data(
631            "test_measurement",
632            15.0,
633            vec![10.0, 11.0, 12.0], // Exactly 3 measurements
634            3,                      // min_count = 3
635            2.0,
636            DispersionMethod::StandardDeviation,
637            ReductionFunc::Min,
638        );
639
640        assert!(result.is_ok());
641        let audit_result = result.unwrap();
642        // Should NOT be skipped (would be skipped if < was changed to ==)
643        assert!(!audit_result.message.contains("Skipping test"));
644
645        // Test with fewer than min_count (should skip)
646        let result = audit_with_data(
647            "test_measurement",
648            15.0,
649            vec![10.0, 11.0], // Only 2 measurements
650            3,                // min_count = 3
651            2.0,
652            DispersionMethod::StandardDeviation,
653            ReductionFunc::Min,
654        );
655
656        assert!(result.is_ok());
657        let audit_result = result.unwrap();
658        assert!(audit_result.message.contains("Skipping test"));
659        assert!(audit_result.passed); // Skipped tests are marked as passed
660    }
661
662    #[test]
663    fn test_pluralization_logic() {
664        // COVERS MUTATION: number_measurements > 1 vs ==
665        // Test with 0 measurements (should have 's' - grammatically correct)
666        let result = audit_with_data(
667            "test_measurement",
668            15.0,
669            vec![], // 0 measurements
670            5,      // min_count > 0 to trigger skip
671            2.0,
672            DispersionMethod::StandardDeviation,
673            ReductionFunc::Min,
674        );
675
676        assert!(result.is_ok());
677        let message = result.unwrap().message;
678        assert!(message.contains("0 historical measurements found")); // Has 's'
679        assert!(!message.contains("0 historical measurement found")); // Should not be singular
680
681        // Test with 1 measurement (no 's')
682        let result = audit_with_data(
683            "test_measurement",
684            15.0,
685            vec![10.0], // 1 measurement
686            5,          // min_count > 1 to trigger skip
687            2.0,
688            DispersionMethod::StandardDeviation,
689            ReductionFunc::Min,
690        );
691
692        assert!(result.is_ok());
693        let message = result.unwrap().message;
694        assert!(message.contains("1 historical measurement found")); // No 's'
695
696        // Test with 2+ measurements (should have 's')
697        let result = audit_with_data(
698            "test_measurement",
699            15.0,
700            vec![10.0, 11.0], // 2 measurements
701            5,                // min_count > 2 to trigger skip
702            2.0,
703            DispersionMethod::StandardDeviation,
704            ReductionFunc::Min,
705        );
706
707        assert!(result.is_ok());
708        let message = result.unwrap().message;
709        assert!(message.contains("2 historical measurements found")); // Has 's'
710    }
711
712    #[test]
713    fn test_skip_with_summaries() {
714        // Test that when audit is skipped, summaries are shown based on TOTAL measurement count
715        // Total measurements = 1 head + N tail
716        // and the format matches passing/failing cases
717
718        // Test with 0 tail measurements (1 total): should show Head only
719        let result = audit_with_data(
720            "test_measurement",
721            15.0,
722            vec![], // 0 tail measurements = 1 total measurement
723            5,      // min_count > 0 to trigger skip
724            2.0,
725            DispersionMethod::StandardDeviation,
726            ReductionFunc::Min,
727        );
728
729        assert!(result.is_ok());
730        let message = result.unwrap().message;
731        assert!(message.contains("Skipping test"));
732        assert!(message.contains("Head:")); // Head summary shown
733        assert!(!message.contains("z-score")); // No z-score (only 1 total measurement)
734        assert!(!message.contains("Tail:")); // No tail
735        assert!(!message.contains("[")); // No sparkline
736
737        // Test with 1 tail measurement (2 total): should show everything
738        let result = audit_with_data(
739            "test_measurement",
740            15.0,
741            vec![10.0], // 1 tail measurement = 2 total measurements
742            5,          // min_count > 1 to trigger skip
743            2.0,
744            DispersionMethod::StandardDeviation,
745            ReductionFunc::Min,
746        );
747
748        assert!(result.is_ok());
749        let message = result.unwrap().message;
750        assert!(message.contains("Skipping test"));
751        assert!(message.contains("z-score (stddev):")); // Z-score with method shown
752        assert!(message.contains("Head:")); // Head summary shown
753        assert!(message.contains("Tail:")); // Tail summary shown
754        assert!(message.contains("[")); // Sparkline shown
755                                        // Verify order: z-score, Head, Tail, sparkline
756        let z_pos = message.find("z-score").unwrap();
757        let head_pos = message.find("Head:").unwrap();
758        let tail_pos = message.find("Tail:").unwrap();
759        let spark_pos = message.find("[").unwrap();
760        assert!(z_pos < head_pos, "z-score should come before Head");
761        assert!(head_pos < tail_pos, "Head should come before Tail");
762        assert!(tail_pos < spark_pos, "Tail should come before sparkline");
763
764        // Test with 2 tail measurements (3 total): should show everything
765        let result = audit_with_data(
766            "test_measurement",
767            15.0,
768            vec![10.0, 11.0], // 2 tail measurements = 3 total measurements
769            5,                // min_count > 2 to trigger skip
770            2.0,
771            DispersionMethod::StandardDeviation,
772            ReductionFunc::Min,
773        );
774
775        assert!(result.is_ok());
776        let message = result.unwrap().message;
777        assert!(message.contains("Skipping test"));
778        assert!(message.contains("z-score (stddev):")); // Z-score with method shown
779        assert!(message.contains("Head:")); // Head summary shown
780        assert!(message.contains("Tail:")); // Tail summary shown
781        assert!(message.contains("[")); // Sparkline shown
782                                        // Verify order: z-score, Head, Tail, sparkline
783        let z_pos = message.find("z-score").unwrap();
784        let head_pos = message.find("Head:").unwrap();
785        let tail_pos = message.find("Tail:").unwrap();
786        let spark_pos = message.find("[").unwrap();
787        assert!(z_pos < head_pos, "z-score should come before Head");
788        assert!(head_pos < tail_pos, "Head should come before Tail");
789        assert!(tail_pos < spark_pos, "Tail should come before sparkline");
790
791        // Test with MAD dispersion method to ensure method name is correct
792        let result = audit_with_data(
793            "test_measurement",
794            15.0,
795            vec![10.0, 11.0], // 2 tail measurements = 3 total measurements
796            5,                // min_count > 2 to trigger skip
797            2.0,
798            DispersionMethod::MedianAbsoluteDeviation,
799            ReductionFunc::Min,
800        );
801
802        assert!(result.is_ok());
803        let message = result.unwrap().message;
804        assert!(message.contains("z-score (mad):")); // MAD method shown
805    }
806
807    #[test]
808    fn test_relative_calculations_division_vs_modulo() {
809        // COVERS MUTATIONS: / vs % in relative_min, relative_max, head_relative_deviation
810        // Use values where division and modulo produce very different results
811        let result = audit_with_data(
812            "test_measurement",
813            25.0,                   // head
814            vec![10.0, 10.0, 10.0], // tail, median = 10.0
815            2,
816            10.0, // High sigma to avoid z-score failures
817            DispersionMethod::StandardDeviation,
818            ReductionFunc::Min,
819        );
820
821        assert!(result.is_ok());
822        let audit_result = result.unwrap();
823
824        // With division:
825        // - relative_min = (10.0 / 10.0 - 1.0) * 100 = 0.0%
826        // - relative_max = (25.0 / 10.0 - 1.0) * 100 = 150.0%
827        // With modulo:
828        // - relative_min = (10.0 % 10.0 - 1.0) * 100 = -100.0% (since 10.0 % 10.0 = 0.0)
829        // - relative_max = (25.0 % 10.0 - 1.0) * 100 = -50.0% (since 25.0 % 10.0 = 5.0)
830
831        // Check that the calculation uses division, not modulo
832        // The range should show [+0.00% – +150.00%], not [-100.00% – -50.00%]
833        assert!(audit_result.message.contains("[+0.00% – +150.00%]"));
834
835        // Ensure the modulo results are NOT present
836        assert!(!audit_result.message.contains("[-100.00% – -50.00%]"));
837        assert!(!audit_result.message.contains("-100.00%"));
838        assert!(!audit_result.message.contains("-50.00%"));
839    }
840
841    #[test]
842    fn test_core_pass_fail_logic() {
843        // COVERS MUTATION: !z_score_exceeds_sigma || passed_due_to_threshold
844        // vs z_score_exceeds_sigma || passed_due_to_threshold
845
846        // Case 1: z_score exceeds sigma, no threshold bypass (should fail)
847        let result = audit_with_data(
848            "test_measurement",                 // No config threshold for this name
849            100.0,                              // Very high head value
850            vec![10.0, 10.0, 10.0, 10.0, 10.0], // Low tail values
851            2,
852            0.5, // Low sigma threshold
853            DispersionMethod::StandardDeviation,
854            ReductionFunc::Min,
855        );
856
857        assert!(result.is_ok());
858        let audit_result = result.unwrap();
859        assert!(!audit_result.passed); // Should fail
860        assert!(audit_result.message.contains("❌"));
861
862        // Case 2: z_score within sigma (should pass)
863        let result = audit_with_data(
864            "test_measurement",
865            10.2,                               // Close to tail values
866            vec![10.0, 10.1, 10.0, 10.1, 10.0], // Some variance to avoid zero stddev
867            2,
868            100.0, // Very high sigma threshold
869            DispersionMethod::StandardDeviation,
870            ReductionFunc::Min,
871        );
872
873        assert!(result.is_ok());
874        let audit_result = result.unwrap();
875        assert!(audit_result.passed); // Should pass
876        assert!(audit_result.message.contains("✅"));
877    }
878
879    #[test]
880    fn test_final_result_logic() {
881        // COVERS MUTATION: if !passed vs if passed
882        // This tests the final branch that determines success vs failure message
883
884        // Test failing case (should get failure message)
885        let result = audit_with_data(
886            "test_measurement",
887            1000.0, // Extreme outlier
888            vec![10.0, 10.0, 10.0, 10.0, 10.0],
889            2,
890            0.1, // Very strict sigma
891            DispersionMethod::StandardDeviation,
892            ReductionFunc::Min,
893        );
894
895        assert!(result.is_ok());
896        let audit_result = result.unwrap();
897        assert!(!audit_result.passed);
898        assert!(audit_result.message.contains("❌"));
899        assert!(audit_result.message.contains("differs significantly"));
900
901        // Test passing case (should get success message)
902        let result = audit_with_data(
903            "test_measurement",
904            10.01,                              // Very close to tail
905            vec![10.0, 10.1, 10.0, 10.1, 10.0], // Varied values to avoid zero variance
906            2,
907            100.0, // Very lenient sigma
908            DispersionMethod::StandardDeviation,
909            ReductionFunc::Min,
910        );
911
912        assert!(result.is_ok());
913        let audit_result = result.unwrap();
914        assert!(audit_result.passed);
915        assert!(audit_result.message.contains("✅"));
916        assert!(!audit_result.message.contains("differs significantly"));
917    }
918
919    #[test]
920    fn test_dispersion_methods_produce_different_results() {
921        // Test that different dispersion methods work in the production code
922        let head = 35.0;
923        let tail = vec![30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 100.0];
924
925        let result_stddev = audit_with_data(
926            "test_measurement",
927            head,
928            tail.clone(),
929            2,
930            2.0,
931            DispersionMethod::StandardDeviation,
932            ReductionFunc::Min,
933        );
934
935        let result_mad = audit_with_data(
936            "test_measurement",
937            head,
938            tail,
939            2,
940            2.0,
941            DispersionMethod::MedianAbsoluteDeviation,
942            ReductionFunc::Min,
943        );
944
945        assert!(result_stddev.is_ok());
946        assert!(result_mad.is_ok());
947
948        let stddev_result = result_stddev.unwrap();
949        let mad_result = result_mad.unwrap();
950
951        // Both should contain method indicators
952        assert!(stddev_result.message.contains("stddev"));
953        assert!(mad_result.message.contains("mad"));
954    }
955
956    #[test]
957    fn test_head_and_tail_have_units_and_auto_scaling() {
958        // Test that both head and tail measurements display units with auto-scaling
959
960        // First, set up a test environment with a configured unit
961        use crate::test_helpers::setup_test_env_with_config;
962
963        let config_content = r#"
964[measurement."build_time"]
965unit = "ms"
966"#;
967        let (_temp_dir, _dir_guard) = setup_test_env_with_config(config_content);
968
969        // Test with large millisecond values that should auto-scale to seconds
970        let head = 12_345.67; // Will auto-scale to ~12.35s
971        let tail = vec![10_000.0, 10_500.0, 11_000.0, 11_500.0, 12_000.0]; // Will auto-scale to 10s, 10.5s, 11s, etc.
972
973        let result = audit_with_data(
974            "build_time",
975            head,
976            tail,
977            2,
978            10.0, // High sigma to ensure it passes
979            DispersionMethod::StandardDeviation,
980            ReductionFunc::Min,
981        );
982
983        assert!(result.is_ok());
984        let audit_result = result.unwrap();
985        let message = &audit_result.message;
986
987        // Verify Head section exists
988        assert!(
989            message.contains("Head:"),
990            "Message should contain Head section"
991        );
992
993        // With auto-scaling, 12345.67ms should become ~12.35s or 12.3s
994        // Check that the value is auto-scaled (contains 's' for seconds)
995        assert!(
996            message.contains("12.3s") || message.contains("12.35s"),
997            "Head mean should be auto-scaled to seconds, got: {}",
998            message
999        );
1000
1001        let head_section: Vec<&str> = message
1002            .lines()
1003            .filter(|line| line.contains("Head:"))
1004            .collect();
1005
1006        assert!(
1007            !head_section.is_empty(),
1008            "Should find Head section in message"
1009        );
1010
1011        let head_line = head_section[0];
1012
1013        // With auto-scaling, all values (mean, stddev, MAD) get their units auto-scaled
1014        // They should all have units now (not just mean)
1015        assert!(
1016            head_line.contains("μ:") && head_line.contains("σ:") && head_line.contains("MAD:"),
1017            "Head line should contain μ, σ, and MAD labels, got: {}",
1018            head_line
1019        );
1020
1021        // Verify Tail section has units
1022        assert!(
1023            message.contains("Tail:"),
1024            "Message should contain Tail section"
1025        );
1026
1027        let tail_section: Vec<&str> = message
1028            .lines()
1029            .filter(|line| line.contains("Tail:"))
1030            .collect();
1031
1032        assert!(
1033            !tail_section.is_empty(),
1034            "Should find Tail section in message"
1035        );
1036
1037        let tail_line = tail_section[0];
1038
1039        // Tail mean should be auto-scaled to seconds (10000-12000ms → 10-12s)
1040        assert!(
1041            tail_line.contains("11s")
1042                || tail_line.contains("11.")
1043                || tail_line.contains("10.")
1044                || tail_line.contains("12."),
1045            "Tail should contain auto-scaled second values, got: {}",
1046            tail_line
1047        );
1048
1049        // Verify the basic format structure is present
1050        assert!(
1051            tail_line.contains("μ:")
1052                && tail_line.contains("σ:")
1053                && tail_line.contains("MAD:")
1054                && tail_line.contains("n:"),
1055            "Tail line should contain all stat labels, got: {}",
1056            tail_line
1057        );
1058    }
1059
1060    #[test]
1061    fn test_threshold_note_only_shown_when_audit_would_fail() {
1062        // Test that the threshold note is only shown when the audit would have
1063        // failed without the threshold (i.e., when z_score_exceeds_sigma is true)
1064        use crate::test_helpers::setup_test_env_with_config;
1065
1066        let config_content = r#"
1067[measurement."build_time"]
1068min_relative_deviation = 10.0
1069"#;
1070        let (_temp_dir, _dir_guard) = setup_test_env_with_config(config_content);
1071
1072        // Case 1: Low z-score AND low relative deviation (threshold is configured but not needed)
1073        // Should pass without showing the note
1074        let result = audit_with_data(
1075            "build_time",
1076            10.1,                               // Very close to tail values
1077            vec![10.0, 10.1, 10.0, 10.1, 10.0], // Low variance
1078            2,
1079            100.0, // Very high sigma threshold - won't be exceeded
1080            DispersionMethod::StandardDeviation,
1081            ReductionFunc::Min,
1082        );
1083
1084        assert!(result.is_ok());
1085        let audit_result = result.unwrap();
1086        assert!(audit_result.passed);
1087        assert!(audit_result.message.contains("✅"));
1088        // The note should NOT be shown because the audit would have passed anyway
1089        assert!(
1090            !audit_result
1091                .message
1092                .contains("Note: Passed due to relative deviation"),
1093            "Note should not appear when audit passes without needing threshold bypass"
1094        );
1095
1096        // Case 2: High z-score but low relative deviation (threshold saves the audit)
1097        // Should pass and show the note
1098        let result = audit_with_data(
1099            "build_time",
1100            1002.0, // High z-score outlier but low relative deviation
1101            vec![1000.0, 1000.1, 1000.0, 1000.1, 1000.0], // Very low variance
1102            2,
1103            0.5, // Low sigma threshold - will be exceeded
1104            DispersionMethod::StandardDeviation,
1105            ReductionFunc::Min,
1106        );
1107
1108        assert!(result.is_ok());
1109        let audit_result = result.unwrap();
1110        assert!(audit_result.passed);
1111        assert!(audit_result.message.contains("✅"));
1112        // The note SHOULD be shown because the audit would have failed without the threshold
1113        assert!(
1114            audit_result
1115                .message
1116                .contains("Note: Passed due to relative deviation"),
1117            "Note should appear when audit passes due to threshold bypass. Got: {}",
1118            audit_result.message
1119        );
1120
1121        // Case 3: High z-score AND high relative deviation (threshold doesn't help)
1122        // Should fail
1123        let result = audit_with_data(
1124            "build_time",
1125            1200.0, // High z-score AND high relative deviation
1126            vec![1000.0, 1000.1, 1000.0, 1000.1, 1000.0], // Very low variance
1127            2,
1128            0.5, // Low sigma threshold - will be exceeded
1129            DispersionMethod::StandardDeviation,
1130            ReductionFunc::Min,
1131        );
1132
1133        assert!(result.is_ok());
1134        let audit_result = result.unwrap();
1135        assert!(!audit_result.passed);
1136        assert!(audit_result.message.contains("❌"));
1137        // No note shown because the audit still failed
1138        assert!(
1139            !audit_result
1140                .message
1141                .contains("Note: Passed due to relative deviation"),
1142            "Note should not appear when audit fails"
1143        );
1144    }
1145
1146    #[test]
1147    fn test_absolute_threshold_note_and_deviation_value() {
1148        // Tests that:
1149        // 1. The note shows the correct absolute deviation value (catches - vs / mutation)
1150        // 2. The boundary: deviation exactly AT threshold fails (catches < vs <= mutation)
1151        use crate::test_helpers::setup_test_env_with_config;
1152
1153        let config_content = r#"
1154[measurement."build_time"]
1155min_absolute_deviation = 50.0
1156"#;
1157        let (_temp_dir, _dir_guard) = setup_test_env_with_config(config_content);
1158
1159        // Case 1: High z-score but low absolute deviation (threshold saves the audit)
1160        // head=1010, tail values very tightly clustered around 1000
1161        // absolute deviation = |1010 - 1000| = 10 < 50 => should pass
1162        // if - were replaced with /, deviation would be |1010/1000| = 1.01, still < 50 (passes anyway)
1163        // So we need values where subtraction and division give meaningfully different results
1164        // head=1005, tail=1000: subtract=5, divide=1.005; but threshold=50, both < 50
1165        // Let's use head=100, tail_median=10: subtract=90, divide=10; threshold=50
1166        // With threshold=50: subtract(90) >= 50 fails, divide(10) < 50 passes
1167        // This catches the - vs / mutation
1168        let result = audit_with_data(
1169            "build_time",
1170            100.0,                              // head value
1171            vec![10.0, 10.0, 10.0, 10.0, 10.0], // tail values, median=10
1172            2,
1173            0.5, // Low sigma - will be exceeded
1174            DispersionMethod::StandardDeviation,
1175            ReductionFunc::Min,
1176        );
1177
1178        assert!(result.is_ok());
1179        let audit_result = result.unwrap();
1180        // absolute deviation = |100 - 10| = 90, which is > 50 threshold => should FAIL
1181        assert!(
1182            !audit_result.passed,
1183            "Should fail: absolute deviation 90 > threshold 50. Got: {}",
1184            audit_result.message
1185        );
1186
1187        // Case 2: absolute deviation exactly equals threshold => should FAIL (< not <=)
1188        // head=1050, tail_median=1000, absolute_deviation=50, threshold=50
1189        // With < : 50 < 50 is false => fails (correct)
1190        // With <= : 50 <= 50 is true => passes (wrong)
1191        let result = audit_with_data(
1192            "build_time",
1193            1050.0,                                       // head value
1194            vec![1000.0, 1000.0, 1000.0, 1000.0, 1000.0], // tail values, median=1000
1195            2,
1196            0.5, // Low sigma - will be exceeded
1197            DispersionMethod::StandardDeviation,
1198            ReductionFunc::Min,
1199        );
1200
1201        assert!(result.is_ok());
1202        let audit_result = result.unwrap();
1203        // absolute deviation = |1050 - 1000| = 50, which equals threshold 50 => should FAIL
1204        assert!(
1205            !audit_result.passed,
1206            "Should fail: absolute deviation 50 == threshold 50 (not strictly less than). Got: {}",
1207            audit_result.message
1208        );
1209
1210        // Case 3: absolute deviation strictly below threshold => should PASS with note
1211        // head=1049, tail_median=1000, absolute_deviation=49, threshold=50
1212        let result = audit_with_data(
1213            "build_time",
1214            1049.0,                                       // head value
1215            vec![1000.0, 1000.0, 1000.0, 1000.0, 1000.0], // tail values, median=1000
1216            2,
1217            0.5, // Low sigma - will be exceeded
1218            DispersionMethod::StandardDeviation,
1219            ReductionFunc::Min,
1220        );
1221
1222        assert!(result.is_ok());
1223        let audit_result = result.unwrap();
1224        assert!(
1225            audit_result.passed,
1226            "Should pass: absolute deviation 49 < threshold 50. Got: {}",
1227            audit_result.message
1228        );
1229        assert!(
1230            audit_result
1231                .message
1232                .contains("Note: Passed due to absolute deviation"),
1233            "Note should appear when audit passes due to absolute threshold. Got: {}",
1234            audit_result.message
1235        );
1236        // Verify the note contains the correct deviation value (catches - vs / mutation)
1237        // If / were used: |1049/1000| = 1.049, note would say "1.0" not "49.0"
1238        assert!(
1239            audit_result.message.contains("49.0"),
1240            "Note should show absolute deviation 49.0, not 1.0 (which would indicate / instead of -). Got: {}",
1241            audit_result.message
1242        );
1243    }
1244
1245    // Integration tests that verify per-measurement config determination
1246    #[cfg(test)]
1247    mod integration {
1248        use super::*;
1249        use crate::config::{
1250            audit_aggregate_by, audit_dispersion_method, audit_min_measurements, audit_sigma,
1251        };
1252        use crate::test_helpers::setup_test_env_with_config;
1253
1254        #[test]
1255        fn test_different_dispersion_methods_per_measurement() {
1256            let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1257                r#"
1258[measurement]
1259dispersion_method = "stddev"
1260
1261[measurement."build_time"]
1262dispersion_method = "mad"
1263
1264[measurement."memory_usage"]
1265dispersion_method = "stddev"
1266"#,
1267            );
1268
1269            // Verify each measurement gets its own config
1270            let build_time_method = audit_dispersion_method("build_time");
1271            let memory_usage_method = audit_dispersion_method("memory_usage");
1272            let other_method = audit_dispersion_method("other_metric");
1273
1274            assert_eq!(
1275                DispersionMethod::from(build_time_method),
1276                DispersionMethod::MedianAbsoluteDeviation,
1277                "build_time should use MAD"
1278            );
1279            assert_eq!(
1280                DispersionMethod::from(memory_usage_method),
1281                DispersionMethod::StandardDeviation,
1282                "memory_usage should use stddev"
1283            );
1284            assert_eq!(
1285                DispersionMethod::from(other_method),
1286                DispersionMethod::StandardDeviation,
1287                "other_metric should use default stddev"
1288            );
1289        }
1290
1291        #[test]
1292        fn test_different_min_measurements_per_measurement() {
1293            let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1294                r#"
1295[measurement]
1296min_measurements = 5
1297
1298[measurement."build_time"]
1299min_measurements = 10
1300
1301[measurement."memory_usage"]
1302min_measurements = 3
1303"#,
1304            );
1305
1306            assert_eq!(
1307                audit_min_measurements("build_time"),
1308                Some(10),
1309                "build_time should require 10 measurements"
1310            );
1311            assert_eq!(
1312                audit_min_measurements("memory_usage"),
1313                Some(3),
1314                "memory_usage should require 3 measurements"
1315            );
1316            assert_eq!(
1317                audit_min_measurements("other_metric"),
1318                Some(5),
1319                "other_metric should use default 5 measurements"
1320            );
1321        }
1322
1323        #[test]
1324        fn test_different_aggregate_by_per_measurement() {
1325            let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1326                r#"
1327[measurement]
1328aggregate_by = "median"
1329
1330[measurement."build_time"]
1331aggregate_by = "max"
1332
1333[measurement."memory_usage"]
1334aggregate_by = "mean"
1335"#,
1336            );
1337
1338            assert_eq!(
1339                audit_aggregate_by("build_time"),
1340                Some(git_perf_cli_types::ReductionFunc::Max),
1341                "build_time should use max"
1342            );
1343            assert_eq!(
1344                audit_aggregate_by("memory_usage"),
1345                Some(git_perf_cli_types::ReductionFunc::Mean),
1346                "memory_usage should use mean"
1347            );
1348            assert_eq!(
1349                audit_aggregate_by("other_metric"),
1350                Some(git_perf_cli_types::ReductionFunc::Median),
1351                "other_metric should use default median"
1352            );
1353        }
1354
1355        #[test]
1356        fn test_different_sigma_per_measurement() {
1357            let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1358                r#"
1359[measurement]
1360sigma = 3.0
1361
1362[measurement."build_time"]
1363sigma = 5.5
1364
1365[measurement."memory_usage"]
1366sigma = 2.0
1367"#,
1368            );
1369
1370            assert_eq!(
1371                audit_sigma("build_time"),
1372                Some(5.5),
1373                "build_time should use sigma 5.5"
1374            );
1375            assert_eq!(
1376                audit_sigma("memory_usage"),
1377                Some(2.0),
1378                "memory_usage should use sigma 2.0"
1379            );
1380            assert_eq!(
1381                audit_sigma("other_metric"),
1382                Some(3.0),
1383                "other_metric should use default sigma 3.0"
1384            );
1385        }
1386
1387        #[test]
1388        fn test_cli_overrides_config() {
1389            let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1390                r#"
1391[measurement."build_time"]
1392min_measurements = 10
1393aggregate_by = "max"
1394sigma = 5.5
1395dispersion_method = "mad"
1396"#,
1397            );
1398
1399            // Test that CLI values override config
1400            let params = super::resolve_audit_params(
1401                "build_time",
1402                Some(2),                                   // CLI min
1403                Some(ReductionFunc::Min),                  // CLI aggregate
1404                Some(3.0),                                 // CLI sigma
1405                Some(DispersionMethod::StandardDeviation), // CLI dispersion
1406            );
1407
1408            assert_eq!(
1409                params.min_count, 2,
1410                "CLI min_measurements should override config"
1411            );
1412            assert_eq!(
1413                params.summarize_by,
1414                ReductionFunc::Min,
1415                "CLI aggregate_by should override config"
1416            );
1417            assert_eq!(params.sigma, 3.0, "CLI sigma should override config");
1418            assert_eq!(
1419                params.dispersion_method,
1420                DispersionMethod::StandardDeviation,
1421                "CLI dispersion should override config"
1422            );
1423        }
1424
1425        #[test]
1426        fn test_config_overrides_defaults() {
1427            let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1428                r#"
1429[measurement."build_time"]
1430min_measurements = 10
1431aggregate_by = "max"
1432sigma = 5.5
1433dispersion_method = "mad"
1434"#,
1435            );
1436
1437            // Test that config values are used when no CLI values provided
1438            let params = super::resolve_audit_params(
1439                "build_time",
1440                None, // No CLI values
1441                None,
1442                None,
1443                None,
1444            );
1445
1446            assert_eq!(
1447                params.min_count, 10,
1448                "Config min_measurements should override default"
1449            );
1450            assert_eq!(
1451                params.summarize_by,
1452                ReductionFunc::Max,
1453                "Config aggregate_by should override default"
1454            );
1455            assert_eq!(params.sigma, 5.5, "Config sigma should override default");
1456            assert_eq!(
1457                params.dispersion_method,
1458                DispersionMethod::MedianAbsoluteDeviation,
1459                "Config dispersion should override default"
1460            );
1461        }
1462
1463        #[test]
1464        fn test_uses_defaults_when_no_config_or_cli() {
1465            let (_temp_dir, _dir_guard) = setup_test_env_with_config("");
1466
1467            // Test that defaults are used when no CLI or config
1468            let params = super::resolve_audit_params(
1469                "non_existent_measurement",
1470                None, // No CLI values
1471                None,
1472                None,
1473                None,
1474            );
1475
1476            assert_eq!(
1477                params.min_count, 2,
1478                "Should use default min_measurements of 2"
1479            );
1480            assert_eq!(
1481                params.summarize_by,
1482                ReductionFunc::Min,
1483                "Should use default aggregate_by of Min"
1484            );
1485            assert_eq!(params.sigma, 4.0, "Should use default sigma of 4.0");
1486            assert_eq!(
1487                params.dispersion_method,
1488                DispersionMethod::StandardDeviation,
1489                "Should use default dispersion of stddev"
1490            );
1491        }
1492    }
1493
1494    #[test]
1495    fn test_discover_matching_measurements() {
1496        use crate::data::{Commit, MeasurementData};
1497        use std::collections::HashMap;
1498
1499        // Create mock commits with various measurements
1500        let commits = vec![
1501            Ok(Commit {
1502                commit: "abc123".to_string(),
1503                title: "test: commit 1".to_string(),
1504                author: "Test Author".to_string(),
1505                measurements: vec![
1506                    MeasurementData {
1507                        epoch: 0,
1508                        name: "bench_cpu".to_string(),
1509                        timestamp: 1000.0,
1510                        val: 100.0,
1511                        key_values: {
1512                            let mut map = HashMap::new();
1513                            map.insert("os".to_string(), "linux".to_string());
1514                            map
1515                        },
1516                    },
1517                    MeasurementData {
1518                        epoch: 0,
1519                        name: "bench_memory".to_string(),
1520                        timestamp: 1000.0,
1521                        val: 200.0,
1522                        key_values: {
1523                            let mut map = HashMap::new();
1524                            map.insert("os".to_string(), "linux".to_string());
1525                            map
1526                        },
1527                    },
1528                    MeasurementData {
1529                        epoch: 0,
1530                        name: "test_unit".to_string(),
1531                        timestamp: 1000.0,
1532                        val: 50.0,
1533                        key_values: {
1534                            let mut map = HashMap::new();
1535                            map.insert("os".to_string(), "linux".to_string());
1536                            map
1537                        },
1538                    },
1539                ],
1540            }),
1541            Ok(Commit {
1542                commit: "def456".to_string(),
1543                title: "test: commit 2".to_string(),
1544                author: "Test Author".to_string(),
1545                measurements: vec![
1546                    MeasurementData {
1547                        epoch: 0,
1548                        name: "bench_cpu".to_string(),
1549                        timestamp: 1000.0,
1550                        val: 105.0,
1551                        key_values: {
1552                            let mut map = HashMap::new();
1553                            map.insert("os".to_string(), "mac".to_string());
1554                            map
1555                        },
1556                    },
1557                    MeasurementData {
1558                        epoch: 0,
1559                        name: "other_metric".to_string(),
1560                        timestamp: 1000.0,
1561                        val: 75.0,
1562                        key_values: {
1563                            let mut map = HashMap::new();
1564                            map.insert("os".to_string(), "linux".to_string());
1565                            map
1566                        },
1567                    },
1568                ],
1569            }),
1570        ];
1571
1572        // Test 1: Single filter pattern matching "bench_*"
1573        let patterns = vec!["bench_.*".to_string()];
1574        let filters = crate::filter::compile_filters(&patterns).unwrap();
1575        let selectors = vec![];
1576        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1577
1578        assert_eq!(discovered.len(), 2);
1579        assert!(discovered.contains(&"bench_cpu".to_string()));
1580        assert!(discovered.contains(&"bench_memory".to_string()));
1581        assert!(!discovered.contains(&"test_unit".to_string()));
1582        assert!(!discovered.contains(&"other_metric".to_string()));
1583
1584        // Test 2: Multiple filter patterns (OR behavior)
1585        let patterns = vec!["bench_cpu".to_string(), "test_.*".to_string()];
1586        let filters = crate::filter::compile_filters(&patterns).unwrap();
1587        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1588
1589        assert_eq!(discovered.len(), 2);
1590        assert!(discovered.contains(&"bench_cpu".to_string()));
1591        assert!(discovered.contains(&"test_unit".to_string()));
1592        assert!(!discovered.contains(&"bench_memory".to_string()));
1593
1594        // Test 3: Filter with selectors
1595        let patterns = vec!["bench_.*".to_string()];
1596        let filters = crate::filter::compile_filters(&patterns).unwrap();
1597        let selectors = vec![("os".to_string(), "linux".to_string())];
1598        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1599
1600        // bench_cpu and bench_memory both have os=linux (in first commit)
1601        // bench_cpu also has os=mac (in second commit) but selector filters it to only linux
1602        assert_eq!(discovered.len(), 2);
1603        assert!(discovered.contains(&"bench_cpu".to_string()));
1604        assert!(discovered.contains(&"bench_memory".to_string()));
1605
1606        // Test 4: No matches
1607        let patterns = vec!["nonexistent.*".to_string()];
1608        let filters = crate::filter::compile_filters(&patterns).unwrap();
1609        let selectors = vec![];
1610        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1611
1612        assert_eq!(discovered.len(), 0);
1613
1614        // Test 5: Empty filters (should match all)
1615        let filters = vec![];
1616        let selectors = vec![];
1617        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1618
1619        // Empty filters should match nothing based on the logic
1620        // Actually, looking at matches_any_filter, empty filters return true
1621        // So this should discover all measurements
1622        assert_eq!(discovered.len(), 4);
1623        assert!(discovered.contains(&"bench_cpu".to_string()));
1624        assert!(discovered.contains(&"bench_memory".to_string()));
1625        assert!(discovered.contains(&"test_unit".to_string()));
1626        assert!(discovered.contains(&"other_metric".to_string()));
1627
1628        // Test 6: Selector filters out everything
1629        let patterns = vec!["bench_.*".to_string()];
1630        let filters = crate::filter::compile_filters(&patterns).unwrap();
1631        let selectors = vec![("os".to_string(), "windows".to_string())];
1632        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1633
1634        assert_eq!(discovered.len(), 0);
1635
1636        // Test 7: Exact match with anchored regex (simulating -m argument)
1637        let patterns = vec!["^bench_cpu$".to_string()];
1638        let filters = crate::filter::compile_filters(&patterns).unwrap();
1639        let selectors = vec![];
1640        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1641
1642        assert_eq!(discovered.len(), 1);
1643        assert!(discovered.contains(&"bench_cpu".to_string()));
1644
1645        // Test 8: Sorted output (verify deterministic ordering)
1646        let patterns = vec![".*".to_string()]; // Match all
1647        let filters = crate::filter::compile_filters(&patterns).unwrap();
1648        let selectors = vec![];
1649        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1650
1651        // Should be sorted alphabetically
1652        assert_eq!(discovered[0], "bench_cpu");
1653        assert_eq!(discovered[1], "bench_memory");
1654        assert_eq!(discovered[2], "other_metric");
1655        assert_eq!(discovered[3], "test_unit");
1656    }
1657
1658    #[test]
1659    fn test_audit_multiple_with_combined_patterns() {
1660        // This test verifies that combining explicit measurements (-m) and filter patterns (--filter)
1661        // works correctly with OR behavior. Both should be audited.
1662        // Note: This is an integration test that uses actual audit_multiple function,
1663        // but we can't easily test it without a real git repo, so we test the pattern combination
1664        // and discovery logic instead.
1665
1666        use crate::data::{Commit, MeasurementData};
1667        use std::collections::HashMap;
1668
1669        // Create mock commits
1670        let commits = vec![Ok(Commit {
1671            commit: "abc123".to_string(),
1672            title: "test: commit".to_string(),
1673            author: "Test Author".to_string(),
1674            measurements: vec![
1675                MeasurementData {
1676                    epoch: 0,
1677                    name: "timer".to_string(),
1678                    timestamp: 1000.0,
1679                    val: 10.0,
1680                    key_values: HashMap::new(),
1681                },
1682                MeasurementData {
1683                    epoch: 0,
1684                    name: "bench_cpu".to_string(),
1685                    timestamp: 1000.0,
1686                    val: 100.0,
1687                    key_values: HashMap::new(),
1688                },
1689                MeasurementData {
1690                    epoch: 0,
1691                    name: "memory".to_string(),
1692                    timestamp: 1000.0,
1693                    val: 500.0,
1694                    key_values: HashMap::new(),
1695                },
1696            ],
1697        })];
1698
1699        // Simulate combining -m timer with --filter "bench_.*"
1700        // This is what combine_measurements_and_filters does in cli.rs
1701        let measurements = vec!["timer".to_string()];
1702        let filter_patterns = vec!["bench_.*".to_string()];
1703        let combined =
1704            crate::filter::combine_measurements_and_filters(&measurements, &filter_patterns);
1705
1706        // combined should have: ["^timer$", "bench_.*"]
1707        assert_eq!(combined.len(), 2);
1708        assert_eq!(combined[0], "^timer$");
1709        assert_eq!(combined[1], "bench_.*");
1710
1711        // Now compile and discover
1712        let filters = crate::filter::compile_filters(&combined).unwrap();
1713        let selectors = vec![];
1714        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1715
1716        // Should discover both timer (exact match) and bench_cpu (pattern match)
1717        assert_eq!(discovered.len(), 2);
1718        assert!(discovered.contains(&"timer".to_string()));
1719        assert!(discovered.contains(&"bench_cpu".to_string()));
1720        assert!(!discovered.contains(&"memory".to_string())); // Not in -m or filter
1721
1722        // Test with multiple explicit measurements and multiple filters
1723        let measurements = vec!["timer".to_string(), "memory".to_string()];
1724        let filter_patterns = vec!["bench_.*".to_string(), "test_.*".to_string()];
1725        let combined =
1726            crate::filter::combine_measurements_and_filters(&measurements, &filter_patterns);
1727
1728        assert_eq!(combined.len(), 4);
1729
1730        let filters = crate::filter::compile_filters(&combined).unwrap();
1731        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1732
1733        // Should discover timer, memory, and bench_cpu (no test_* in commits)
1734        assert_eq!(discovered.len(), 3);
1735        assert!(discovered.contains(&"timer".to_string()));
1736        assert!(discovered.contains(&"memory".to_string()));
1737        assert!(discovered.contains(&"bench_cpu".to_string()));
1738    }
1739
1740    #[test]
1741    fn test_audit_with_empty_tail() {
1742        // Test for division by zero bug when tail is empty
1743        // This test reproduces the bug where tail_median is 0.0 when tail is empty,
1744        // causing division by zero in sparkline calculation
1745        let result = audit_with_data(
1746            "test_measurement",
1747            10.0,   // head
1748            vec![], // empty tail - triggers the bug
1749            2,      // min_count
1750            2.0,    // sigma
1751            DispersionMethod::StandardDeviation,
1752            ReductionFunc::Min,
1753        );
1754
1755        // Should succeed and skip (not crash with division by zero)
1756        assert!(result.is_ok(), "Should not crash on empty tail");
1757        let audit_result = result.unwrap();
1758
1759        // Should be skipped due to insufficient measurements
1760        assert!(audit_result.passed);
1761        assert!(audit_result.message.contains("Skipping test"));
1762
1763        // The message should not contain inf or NaN
1764        assert!(!audit_result.message.to_lowercase().contains("inf"));
1765        assert!(!audit_result.message.to_lowercase().contains("nan"));
1766    }
1767
1768    #[test]
1769    fn test_audit_with_all_zero_tail() {
1770        // Test for division by zero when all tail measurements are 0.0
1771        // This tests the edge case where median is 0.0 even with measurements
1772        let result = audit_with_data(
1773            "test_measurement",
1774            5.0,                 // non-zero head
1775            vec![0.0, 0.0, 0.0], // all zeros in tail
1776            2,                   // min_count
1777            2.0,                 // sigma
1778            DispersionMethod::StandardDeviation,
1779            ReductionFunc::Min,
1780        );
1781
1782        // Should succeed (not crash with division by zero)
1783        assert!(result.is_ok(), "Should not crash when tail median is 0.0");
1784        let audit_result = result.unwrap();
1785
1786        // The message should not contain inf or NaN
1787        assert!(!audit_result.message.to_lowercase().contains("inf"));
1788        assert!(!audit_result.message.to_lowercase().contains("nan"));
1789    }
1790
1791    #[test]
1792    fn test_tiered_baseline_approach() {
1793        // Test the tiered approach:
1794        // 1. Non-zero median → use median, show percentages
1795        // 2. Zero median → show absolute values
1796
1797        // Case 1: Median is non-zero - use percentages (default behavior)
1798        let result = audit_with_data(
1799            "test_measurement",
1800            15.0,                   // head
1801            vec![10.0, 11.0, 12.0], // median=11.0 (non-zero)
1802            2,
1803            2.0,
1804            DispersionMethod::StandardDeviation,
1805            ReductionFunc::Min,
1806        );
1807
1808        assert!(result.is_ok());
1809        let audit_result = result.unwrap();
1810        // Should use median as baseline and show percentage
1811        assert!(audit_result.message.contains('%'));
1812        assert!(!audit_result.message.to_lowercase().contains("inf"));
1813
1814        // Case 2: Median is zero with non-zero head - use absolute values
1815        let result = audit_with_data(
1816            "test_measurement",
1817            5.0,                 // head (non-zero)
1818            vec![0.0, 0.0, 0.0], // median=0
1819            2,
1820            2.0,
1821            DispersionMethod::StandardDeviation,
1822            ReductionFunc::Min,
1823        );
1824
1825        assert!(result.is_ok());
1826        let audit_result = result.unwrap();
1827        // Should show absolute values instead of percentages
1828        // The message should contain the sparkline but not percentage symbols
1829        assert!(!audit_result.message.to_lowercase().contains("inf"));
1830        assert!(!audit_result.message.to_lowercase().contains("nan"));
1831        // Check that sparkline exists (contains the dash character)
1832        assert!(audit_result.message.contains('–') || audit_result.message.contains('-'));
1833
1834        // Case 3: Everything is zero - show absolute values [0 - 0]
1835        let result = audit_with_data(
1836            "test_measurement",
1837            0.0,                 // head
1838            vec![0.0, 0.0, 0.0], // median=0
1839            2,
1840            2.0,
1841            DispersionMethod::StandardDeviation,
1842            ReductionFunc::Min,
1843        );
1844
1845        assert!(result.is_ok());
1846        let audit_result = result.unwrap();
1847        // Should show absolute range [0 - 0]
1848        assert!(!audit_result.message.to_lowercase().contains("inf"));
1849        assert!(!audit_result.message.to_lowercase().contains("nan"));
1850    }
1851
1852    #[test]
1853    fn test_min_measurements_two_with_no_tail() {
1854        // Test the minimum allowed min_measurements value (2) with no tail measurements.
1855        // This should skip the audit since we have 0 < 2 tail measurements.
1856        let result = audit_with_data(
1857            "test_measurement",
1858            15.0,   // head
1859            vec![], // no tail measurements
1860            2,      // min_count = 2 (minimum allowed by CLI)
1861            2.0,
1862            DispersionMethod::StandardDeviation,
1863            ReductionFunc::Min,
1864        );
1865
1866        assert!(result.is_ok());
1867        let audit_result = result.unwrap();
1868
1869        // Should pass (skipped) since we have 0 < 2 tail measurements
1870        assert!(audit_result.passed);
1871        assert!(audit_result.message.contains("Skipping test"));
1872        assert!(audit_result
1873            .message
1874            .contains("0 historical measurements found"));
1875        assert!(audit_result
1876            .message
1877            .contains("Less than requested min_measurements of 2"));
1878
1879        // Should show Head summary only (total_measurements = 1)
1880        assert!(audit_result.message.contains("Head:"));
1881        assert!(!audit_result.message.contains("z-score"));
1882        assert!(!audit_result.message.contains("Tail:"));
1883    }
1884
1885    #[test]
1886    fn test_min_measurements_two_with_single_tail() {
1887        // Test the minimum allowed min_measurements value (2) with a single tail measurement.
1888        // This should skip since we have 1 < 2 tail measurements.
1889        let result = audit_with_data(
1890            "test_measurement",
1891            15.0,       // head
1892            vec![10.0], // single tail measurement
1893            2,          // min_count = 2 (minimum allowed by CLI)
1894            2.0,
1895            DispersionMethod::StandardDeviation,
1896            ReductionFunc::Min,
1897        );
1898
1899        assert!(result.is_ok());
1900        let audit_result = result.unwrap();
1901
1902        // Should pass (skipped) since we have 1 < 2 tail measurements
1903        assert!(audit_result.passed);
1904        assert!(audit_result.message.contains("Skipping test"));
1905        assert!(audit_result
1906            .message
1907            .contains("1 historical measurement found"));
1908        assert!(audit_result
1909            .message
1910            .contains("Less than requested min_measurements of 2"));
1911
1912        // Should show both Head and Tail summaries with z-score (total_measurements = 2)
1913        assert!(audit_result.message.contains("Head:"));
1914        assert!(audit_result.message.contains("Tail:"));
1915        assert!(audit_result.message.contains("z-score"));
1916        assert!(audit_result.message.contains("["));
1917    }
1918
1919    #[test]
1920    fn test_aggregation_method_display_min() {
1921        // Test that the aggregation method is displayed correctly with ReductionFunc::Min
1922        let result = audit_with_data(
1923            "test_measurement",
1924            15.0,
1925            vec![10.0, 11.0, 12.0],
1926            2,
1927            2.0,
1928            DispersionMethod::StandardDeviation,
1929            ReductionFunc::Min,
1930        );
1931
1932        assert!(result.is_ok());
1933        let audit_result = result.unwrap();
1934        assert!(audit_result.message.contains("Aggregation: min"));
1935    }
1936
1937    #[test]
1938    fn test_aggregation_method_display_max() {
1939        // Test that the aggregation method is displayed correctly with ReductionFunc::Max
1940        let result = audit_with_data(
1941            "test_measurement",
1942            15.0,
1943            vec![10.0, 11.0, 12.0],
1944            2,
1945            2.0,
1946            DispersionMethod::StandardDeviation,
1947            ReductionFunc::Max,
1948        );
1949
1950        assert!(result.is_ok());
1951        let audit_result = result.unwrap();
1952        assert!(audit_result.message.contains("Aggregation: max"));
1953    }
1954
1955    #[test]
1956    fn test_aggregation_method_display_median() {
1957        // Test that the aggregation method is displayed correctly with ReductionFunc::Median
1958        let result = audit_with_data(
1959            "test_measurement",
1960            15.0,
1961            vec![10.0, 11.0, 12.0],
1962            2,
1963            2.0,
1964            DispersionMethod::StandardDeviation,
1965            ReductionFunc::Median,
1966        );
1967
1968        assert!(result.is_ok());
1969        let audit_result = result.unwrap();
1970        assert!(audit_result.message.contains("Aggregation: median"));
1971    }
1972
1973    #[test]
1974    fn test_aggregation_method_display_mean() {
1975        // Test that the aggregation method is displayed correctly with ReductionFunc::Mean
1976        let result = audit_with_data(
1977            "test_measurement",
1978            15.0,
1979            vec![10.0, 11.0, 12.0],
1980            2,
1981            2.0,
1982            DispersionMethod::StandardDeviation,
1983            ReductionFunc::Mean,
1984        );
1985
1986        assert!(result.is_ok());
1987        let audit_result = result.unwrap();
1988        assert!(audit_result.message.contains("Aggregation: mean"));
1989    }
1990
1991    #[test]
1992    fn test_aggregation_method_not_shown_with_single_measurement() {
1993        // Test that aggregation method is NOT shown when there's only 1 measurement
1994        let result = audit_with_data(
1995            "test_measurement",
1996            15.0,
1997            vec![], // No tail measurements, total = 1
1998            2,
1999            2.0,
2000            DispersionMethod::StandardDeviation,
2001            ReductionFunc::Median,
2002        );
2003
2004        assert!(result.is_ok());
2005        let audit_result = result.unwrap();
2006        // Should NOT show aggregation method (only 1 measurement total)
2007        assert!(!audit_result.message.contains("Aggregation:"));
2008        // But should show Head summary
2009        assert!(audit_result.message.contains("Head:"));
2010    }
2011}