Skip to main content

git_perf/
config_cmd.rs

1use crate::change_point::ChangePointConfig;
2use crate::config::{
3    audit_aggregate_by, audit_dispersion_method, audit_min_absolute_deviation,
4    audit_min_measurements, audit_min_relative_deviation, audit_sigma, backoff_max_elapsed_seconds,
5    change_point_config, determine_epoch_from_config, measurement_unit, read_hierarchical_config,
6};
7use crate::git::git_interop::get_repository_root;
8use anyhow::{Context, Result};
9use config::Config;
10use git_perf_cli_types::ConfigFormat;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::PathBuf;
14
15/// Complete configuration information
16#[derive(Debug, Serialize, Deserialize)]
17pub struct ConfigInfo {
18    /// Git context information
19    pub git_context: GitContext,
20
21    /// Configuration sources being used
22    pub config_sources: ConfigSources,
23
24    /// Global settings (not measurement-specific)
25    pub global_settings: GlobalSettings,
26
27    /// Measurement-specific configurations
28    pub measurements: HashMap<String, MeasurementConfig>,
29
30    /// Validation issues (if validation was requested)
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub validation_issues: Option<Vec<String>>,
33}
34
35/// Git repository context
36#[derive(Debug, Serialize, Deserialize)]
37pub struct GitContext {
38    /// Current branch name
39    pub branch: String,
40
41    /// Repository root path
42    pub repository_root: PathBuf,
43}
44
45/// Configuration file sources
46#[derive(Debug, Serialize, Deserialize)]
47pub struct ConfigSources {
48    /// System-wide config path (if exists)
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub system_config: Option<PathBuf>,
51
52    /// Local repository config path (if exists)
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub local_config: Option<PathBuf>,
55}
56
57/// Global configuration settings
58#[derive(Debug, Serialize, Deserialize)]
59pub struct GlobalSettings {
60    /// Backoff max elapsed seconds
61    pub backoff_max_elapsed_seconds: u64,
62}
63
64/// Configuration for a specific measurement
65#[derive(Debug, Serialize, Deserialize)]
66pub struct MeasurementConfig {
67    /// Measurement name
68    pub name: String,
69
70    /// Epoch (8-char hex string)
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub epoch: Option<String>,
73
74    /// Minimum relative deviation threshold (%)
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub min_relative_deviation: Option<f64>,
77
78    /// Minimum absolute deviation threshold (raw units)
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub min_absolute_deviation: Option<f64>,
81
82    /// Dispersion method (stddev or mad)
83    pub dispersion_method: String,
84
85    /// Minimum measurements required
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub min_measurements: Option<u16>,
88
89    /// Aggregation function
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub aggregate_by: Option<String>,
92
93    /// Sigma threshold
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub sigma: Option<f64>,
96
97    /// Measurement unit
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub unit: Option<String>,
100
101    /// Change point detection configuration
102    pub change_point: ChangePointConfig,
103
104    /// Whether this is from parent table fallback (vs measurement-specific)
105    pub from_parent_fallback: bool,
106}
107
108/// Display configuration information (implements config --list)
109pub fn list_config(
110    detailed: bool,
111    format: ConfigFormat,
112    validate: bool,
113    measurement_filter: Option<String>,
114) -> Result<()> {
115    // 1. Gather configuration information
116    let config_info = gather_config_info(validate, measurement_filter.as_deref())?;
117
118    // 2. Display based on format
119    match format {
120        ConfigFormat::Human => display_human_readable(&config_info, detailed)?,
121        ConfigFormat::Json => display_json(&config_info)?,
122    }
123
124    // 3. Exit with error if validation found issues
125    if validate {
126        if let Some(ref issues) = config_info.validation_issues {
127            if !issues.is_empty() {
128                return Err(anyhow::anyhow!(
129                    "Configuration validation found {} issue(s)",
130                    issues.len()
131                ));
132            }
133        }
134    }
135
136    Ok(())
137}
138
139/// Gather all configuration information
140fn gather_config_info(validate: bool, measurement_filter: Option<&str>) -> Result<ConfigInfo> {
141    let git_context = gather_git_context()?;
142    let config_sources = gather_config_sources()?;
143    let global_settings = gather_global_settings();
144    let measurements = gather_measurement_configs(measurement_filter)?;
145
146    let validation_issues = if validate {
147        Some(validate_config(&measurements)?)
148    } else {
149        None
150    };
151
152    Ok(ConfigInfo {
153        git_context,
154        config_sources,
155        global_settings,
156        measurements,
157        validation_issues,
158    })
159}
160
161/// Get git context information
162fn gather_git_context() -> Result<GitContext> {
163    // Get current branch name
164    let branch_output = std::process::Command::new("git")
165        .args(["rev-parse", "--abbrev-ref", "HEAD"])
166        .output()
167        .context("Failed to get current branch")?;
168
169    let branch = String::from_utf8_lossy(&branch_output.stdout)
170        .trim()
171        .to_string();
172
173    // Get repository root
174    let repo_root = get_repository_root()
175        .map_err(|e| anyhow::anyhow!("Failed to get repository root: {}", e))?;
176    let repository_root = PathBuf::from(repo_root);
177
178    Ok(GitContext {
179        branch,
180        repository_root,
181    })
182}
183
184/// Determine which config files are being used
185fn gather_config_sources() -> Result<ConfigSources> {
186    // System config
187    let system_config = find_system_config();
188
189    // Local config - get repository config path
190    let local_config = get_local_config_path();
191
192    Ok(ConfigSources {
193        system_config,
194        local_config,
195    })
196}
197
198/// Find system config path if it exists
199fn find_system_config() -> Option<PathBuf> {
200    use std::env;
201
202    if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") {
203        let path = PathBuf::from(xdg_config_home)
204            .join("git-perf")
205            .join("config.toml");
206        if path.exists() {
207            return Some(path);
208        }
209    }
210
211    if let Some(home) = dirs_next::home_dir() {
212        let path = home.join(".config").join("git-perf").join("config.toml");
213        if path.exists() {
214            return Some(path);
215        }
216    }
217
218    None
219}
220
221/// Get the local repository config path (if it exists)
222fn get_local_config_path() -> Option<PathBuf> {
223    let repo_root = get_repository_root().ok()?;
224    let path = PathBuf::from(repo_root).join(".gitperfconfig");
225    if path.exists() {
226        Some(path)
227    } else {
228        None
229    }
230}
231
232/// Gather global (non-measurement) settings
233fn gather_global_settings() -> GlobalSettings {
234    GlobalSettings {
235        backoff_max_elapsed_seconds: backoff_max_elapsed_seconds(),
236    }
237}
238
239/// Gather measurement configurations
240fn gather_measurement_configs(
241    measurement_filter: Option<&str>,
242) -> Result<HashMap<String, MeasurementConfig>> {
243    let mut measurements = HashMap::new();
244
245    // Get hierarchical config
246    let config = match read_hierarchical_config() {
247        Ok(c) => c,
248        Err(_) => {
249            // No config found, return empty map
250            return Ok(measurements);
251        }
252    };
253
254    // Extract all measurement names from config
255    let measurement_names = extract_measurement_names(&config)?;
256
257    // Filter if requested
258    let filtered_names: Vec<String> = if let Some(filter) = measurement_filter {
259        measurement_names
260            .into_iter()
261            .filter(|name| name == filter)
262            .collect()
263    } else {
264        measurement_names
265    };
266
267    // Gather config for each measurement
268    for name in filtered_names {
269        let measurement_config = gather_single_measurement_config(&name, &config);
270        measurements.insert(name.clone(), measurement_config);
271    }
272
273    Ok(measurements)
274}
275
276/// Extract measurement names from config
277fn extract_measurement_names(config: &Config) -> Result<Vec<String>> {
278    let mut names = Vec::new();
279
280    // Try to get the measurement table
281    if let Ok(table) = config.get_table("measurement") {
282        for (key, value) in table {
283            // Skip non-table values (these are parent defaults)
284            if matches!(value.kind, config::ValueKind::Table(_)) {
285                names.push(key);
286            }
287        }
288    }
289
290    Ok(names)
291}
292
293/// Gather configuration for a single measurement
294fn gather_single_measurement_config(name: &str, config: &Config) -> MeasurementConfig {
295    // Check if settings are measurement-specific or from parent fallback
296    let has_specific_config = config.get_table(&format!("measurement.{}", name)).is_ok();
297
298    MeasurementConfig {
299        name: name.to_string(),
300        epoch: determine_epoch_from_config(name).map(|e| format!("{:08x}", e)),
301        min_relative_deviation: audit_min_relative_deviation(name),
302        min_absolute_deviation: audit_min_absolute_deviation(name),
303        dispersion_method: format!("{:?}", audit_dispersion_method(name)).to_lowercase(),
304        min_measurements: audit_min_measurements(name),
305        aggregate_by: audit_aggregate_by(name).map(|f| format!("{:?}", f).to_lowercase()),
306        sigma: audit_sigma(name),
307        unit: measurement_unit(name),
308        change_point: change_point_config(name),
309        from_parent_fallback: !has_specific_config,
310    }
311}
312
313/// Validate configuration
314fn validate_config(measurements: &HashMap<String, MeasurementConfig>) -> Result<Vec<String>> {
315    let mut issues = Vec::new();
316
317    for (name, config) in measurements {
318        // Check for missing epoch
319        if config.epoch.is_none() {
320            issues.push(format!(
321                "Measurement '{}': No epoch configured (run 'git perf bump-epoch -m {}')",
322                name, name
323            ));
324        }
325
326        // Check for invalid sigma values
327        if let Some(sigma) = config.sigma {
328            if sigma <= 0.0 {
329                issues.push(format!(
330                    "Measurement '{}': Invalid sigma value {} (must be positive)",
331                    name, sigma
332                ));
333            }
334        }
335
336        // Check for invalid min_relative_deviation
337        if let Some(deviation) = config.min_relative_deviation {
338            if deviation < 0.0 {
339                issues.push(format!(
340                    "Measurement '{}': Invalid min_relative_deviation {} (must be non-negative)",
341                    name, deviation
342                ));
343            }
344        }
345
346        // Check for invalid min_absolute_deviation
347        if let Some(deviation) = config.min_absolute_deviation {
348            if deviation < 0.0 {
349                issues.push(format!(
350                    "Measurement '{}': Invalid min_absolute_deviation {} (must be non-negative)",
351                    name, deviation
352                ));
353            }
354        }
355
356        // Check for invalid min_measurements
357        if let Some(min_meas) = config.min_measurements {
358            if min_meas < 2 {
359                issues.push(format!(
360                    "Measurement '{}': Invalid min_measurements {} (must be at least 2)",
361                    name, min_meas
362                ));
363            }
364        }
365
366        // Check for invalid change_point.min_data_points
367        if config.change_point.min_data_points == 0 {
368            issues.push(format!(
369                "Measurement '{}': Invalid change_point.min_data_points {} (must be > 0)",
370                name, config.change_point.min_data_points
371            ));
372        }
373
374        // Check for invalid change_point.min_magnitude_pct
375        if config.change_point.min_magnitude_pct < 0.0 {
376            issues.push(format!(
377                "Measurement '{}': Invalid change_point.min_magnitude_pct {} (must be non-negative)",
378                name, config.change_point.min_magnitude_pct
379            ));
380        }
381
382        // Check for invalid change_point.penalty
383        if config.change_point.penalty <= 0.0 {
384            issues.push(format!(
385                "Measurement '{}': Invalid change_point.penalty {} (must be positive)",
386                name, config.change_point.penalty
387            ));
388        }
389    }
390
391    Ok(issues)
392}
393
394/// Display configuration in human-readable format
395fn display_human_readable(info: &ConfigInfo, detailed: bool) -> Result<()> {
396    println!("Git-Perf Configuration");
397    println!("======================");
398    println!();
399
400    // Git Context
401    println!("Git Context:");
402    println!("  Branch: {}", info.git_context.branch);
403    println!(
404        "  Repository: {}",
405        info.git_context.repository_root.display()
406    );
407    println!();
408
409    // Configuration Sources
410    println!("Configuration Sources:");
411    if let Some(ref system_path) = info.config_sources.system_config {
412        println!("  System config: {}", system_path.display());
413    } else {
414        println!("  System config: (none)");
415    }
416    if let Some(ref local_path) = info.config_sources.local_config {
417        println!("  Local config:  {}", local_path.display());
418    } else {
419        println!("  Local config:  (none)");
420    }
421    println!();
422
423    // Global Settings
424    println!("Global Settings:");
425    println!(
426        "  backoff.max_elapsed_seconds: {}",
427        info.global_settings.backoff_max_elapsed_seconds
428    );
429    println!();
430
431    // Measurements
432    if info.measurements.is_empty() {
433        println!("Measurements: (none configured)");
434    } else {
435        println!("Measurements: ({} configured)", info.measurements.len());
436        println!();
437
438        let mut sorted_measurements: Vec<_> = info.measurements.values().collect();
439        sorted_measurements.sort_by_key(|m| &m.name);
440
441        for measurement in sorted_measurements {
442            display_measurement_human(measurement, detailed);
443        }
444
445        if !detailed {
446            println!("  (use --detailed for full configuration)");
447        }
448    }
449
450    // Validation Issues
451    if let Some(ref issues) = info.validation_issues {
452        if !issues.is_empty() {
453            println!();
454            println!("Validation Issues:");
455            for issue in issues {
456                println!("  \u{26A0} {}", issue);
457            }
458        } else {
459            println!();
460            println!("\u{2713} Configuration is valid");
461        }
462    }
463
464    Ok(())
465}
466
467/// Display a single measurement configuration
468fn display_measurement_human(measurement: &MeasurementConfig, detailed: bool) {
469    if detailed {
470        println!("  [{}]", measurement.name);
471        if measurement.from_parent_fallback {
472            println!("    (using parent table defaults)");
473        }
474        println!(
475            "    epoch:                          {:?}",
476            measurement.epoch
477        );
478        println!(
479            "    min_relative_deviation:         {:?}",
480            measurement.min_relative_deviation
481        );
482        println!(
483            "    min_absolute_deviation:         {:?}",
484            measurement.min_absolute_deviation
485        );
486        println!(
487            "    dispersion_method:              {}",
488            measurement.dispersion_method
489        );
490        println!(
491            "    min_measurements:               {:?}",
492            measurement.min_measurements
493        );
494        println!(
495            "    aggregate_by:                   {:?}",
496            measurement.aggregate_by
497        );
498        println!(
499            "    sigma:                          {:?}",
500            measurement.sigma
501        );
502        println!("    unit:                           {:?}", measurement.unit);
503        println!(
504            "    change_point.enabled:           {}",
505            measurement.change_point.enabled
506        );
507        println!(
508            "    change_point.min_data_points:   {}",
509            measurement.change_point.min_data_points
510        );
511        println!(
512            "    change_point.min_magnitude_pct:  {}",
513            measurement.change_point.min_magnitude_pct
514        );
515        println!(
516            "    change_point.confidence_threshold: {}",
517            measurement.change_point.confidence_threshold
518        );
519        println!(
520            "    change_point.penalty:            {}",
521            measurement.change_point.penalty
522        );
523        println!();
524    } else {
525        // Summary view - just name and epoch
526        let epoch_display = measurement.epoch.as_deref().unwrap_or("(not set)");
527        let unit_display = measurement.unit.as_deref().unwrap_or("(not set)");
528        println!(
529            "  {} - epoch: {}, unit: {}",
530            measurement.name, epoch_display, unit_display
531        );
532    }
533}
534
535/// Display configuration as JSON
536fn display_json(info: &ConfigInfo) -> Result<()> {
537    let json =
538        serde_json::to_string_pretty(info).context("Failed to serialize configuration to JSON")?;
539    println!("{}", json);
540    Ok(())
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use crate::test_helpers::{with_isolated_test_setup, write_gitperfconfig};
547    use std::env;
548    use std::fs;
549    use std::path::Path;
550
551    #[test]
552    fn test_gather_git_context() {
553        with_isolated_test_setup(|_git_dir, _home_path| {
554            let context = gather_git_context().unwrap();
555            assert_eq!(context.branch, "master");
556            assert!(context.repository_root.exists());
557        });
558    }
559
560    #[test]
561    fn test_find_system_config_xdg() {
562        with_isolated_test_setup(|_git_dir, home_path| {
563            // Set XDG_CONFIG_HOME
564            let xdg_config_dir = Path::new(home_path).join("xdg_config");
565            env::set_var("XDG_CONFIG_HOME", &xdg_config_dir);
566
567            // Create system config
568            let system_config_dir = xdg_config_dir.join("git-perf");
569            fs::create_dir_all(&system_config_dir).unwrap();
570            let system_config_path = system_config_dir.join("config.toml");
571            fs::write(&system_config_path, "# test config\n").unwrap();
572
573            let result = find_system_config();
574            assert_eq!(result, Some(system_config_path));
575        });
576    }
577
578    #[test]
579    fn test_find_system_config_home_fallback() {
580        with_isolated_test_setup(|_git_dir, home_path| {
581            // Create config in HOME/.config
582            let config_dir = Path::new(home_path).join(".config").join("git-perf");
583            fs::create_dir_all(&config_dir).unwrap();
584            let config_path = config_dir.join("config.toml");
585            fs::write(&config_path, "# test config\n").unwrap();
586
587            let result = find_system_config();
588            assert_eq!(result, Some(config_path));
589        });
590    }
591
592    #[test]
593    fn test_find_system_config_none() {
594        with_isolated_test_setup(|_git_dir, _home_path| {
595            let result = find_system_config();
596            assert_eq!(result, None);
597        });
598    }
599
600    #[test]
601    fn test_get_local_config_path_exists() {
602        with_isolated_test_setup(|git_dir, _home_path| {
603            write_gitperfconfig(git_dir, "[measurement]\n");
604
605            let result = get_local_config_path();
606            // Canonicalize both paths to handle symlinks (e.g., /var -> /private/var on macOS)
607            assert_eq!(
608                result.map(|p| p.canonicalize().unwrap()),
609                Some(git_dir.join(".gitperfconfig").canonicalize().unwrap())
610            );
611        });
612    }
613
614    #[test]
615    fn test_get_local_config_path_none() {
616        with_isolated_test_setup(|_git_dir, _home_path| {
617            let result = get_local_config_path();
618            assert_eq!(result, None);
619        });
620    }
621
622    #[test]
623    fn test_gather_config_sources() {
624        with_isolated_test_setup(|git_dir, home_path| {
625            // Create system config in HOME/.config
626            let system_config_dir = Path::new(home_path).join(".config").join("git-perf");
627            fs::create_dir_all(&system_config_dir).unwrap();
628            let system_config_path = system_config_dir.join("config.toml");
629            fs::write(&system_config_path, "# system config\n").unwrap();
630
631            write_gitperfconfig(git_dir, "[measurement]\n");
632
633            let sources = gather_config_sources().unwrap();
634            assert_eq!(sources.system_config, Some(system_config_path));
635            // Canonicalize both paths to handle symlinks (e.g., /var -> /private/var on macOS)
636            assert_eq!(
637                sources.local_config.map(|p| p.canonicalize().unwrap()),
638                Some(git_dir.join(".gitperfconfig").canonicalize().unwrap())
639            );
640        });
641    }
642
643    #[test]
644    fn test_gather_global_settings() {
645        with_isolated_test_setup(|_git_dir, _home_path| {
646            let settings = gather_global_settings();
647            // Default value is 60 seconds
648            assert_eq!(settings.backoff_max_elapsed_seconds, 60);
649        });
650    }
651
652    #[test]
653    fn test_extract_measurement_names_empty() {
654        with_isolated_test_setup(|_git_dir, _home_path| {
655            let config = Config::builder().build().unwrap();
656            let names = extract_measurement_names(&config).unwrap();
657            assert!(names.is_empty());
658        });
659    }
660
661    #[test]
662    fn test_extract_measurement_names_with_measurements() {
663        with_isolated_test_setup(|git_dir, _home_path| {
664            write_gitperfconfig(
665                git_dir,
666                r#"
667[measurement.build_time]
668epoch = 0x12345678
669
670[measurement.test_time]
671epoch = 0x87654321
672"#,
673            );
674
675            let config = read_hierarchical_config().unwrap();
676            let mut names = extract_measurement_names(&config).unwrap();
677            names.sort(); // Sort for consistent comparison
678
679            assert_eq!(names, vec!["build_time", "test_time"]);
680        });
681    }
682
683    #[test]
684    fn test_gather_single_measurement_config() {
685        with_isolated_test_setup(|git_dir, _home_path| {
686            write_gitperfconfig(
687                git_dir,
688                r#"
689[measurement.build_time]
690epoch = "12345678"
691min_relative_deviation = 5.0
692dispersion_method = "mad"
693min_measurements = 10
694aggregate_by = "median"
695sigma = 2.0
696unit = "ms"
697"#,
698            );
699
700            let config = read_hierarchical_config().unwrap();
701            let meas_config = gather_single_measurement_config("build_time", &config);
702
703            assert_eq!(meas_config.name, "build_time");
704            assert_eq!(meas_config.epoch, Some("12345678".to_string()));
705            assert_eq!(meas_config.min_relative_deviation, Some(5.0));
706            assert_eq!(meas_config.dispersion_method, "medianabsolutedeviation");
707            assert_eq!(meas_config.min_measurements, Some(10));
708            assert_eq!(meas_config.aggregate_by, Some("median".to_string()));
709            assert_eq!(meas_config.sigma, Some(2.0));
710            assert_eq!(meas_config.unit, Some("ms".to_string()));
711            assert!(!meas_config.from_parent_fallback);
712        });
713    }
714
715    #[test]
716    fn test_gather_single_measurement_config_parent_fallback() {
717        with_isolated_test_setup(|git_dir, _home_path| {
718            write_gitperfconfig(
719                git_dir,
720                r#"
721[measurement]
722dispersion_method = "stddev"
723"#,
724            );
725
726            let config = read_hierarchical_config().unwrap();
727            let meas_config = gather_single_measurement_config("build_time", &config);
728
729            assert_eq!(meas_config.name, "build_time");
730            assert_eq!(meas_config.dispersion_method, "standarddeviation");
731            assert!(meas_config.from_parent_fallback);
732        });
733    }
734
735    #[test]
736    fn test_validate_config_valid() {
737        let mut measurements = HashMap::new();
738        measurements.insert(
739            "build_time".to_string(),
740            MeasurementConfig {
741                name: "build_time".to_string(),
742                epoch: Some("12345678".to_string()),
743                min_relative_deviation: Some(5.0),
744                min_absolute_deviation: None,
745                dispersion_method: "stddev".to_string(),
746                min_measurements: Some(10),
747                aggregate_by: Some("mean".to_string()),
748                sigma: Some(3.0),
749                unit: Some("ms".to_string()),
750                change_point: ChangePointConfig::default(),
751                from_parent_fallback: false,
752            },
753        );
754
755        let issues = validate_config(&measurements).unwrap();
756        assert!(issues.is_empty());
757    }
758
759    #[test]
760    fn test_validate_config_missing_epoch() {
761        let mut measurements = HashMap::new();
762        measurements.insert(
763            "build_time".to_string(),
764            MeasurementConfig {
765                name: "build_time".to_string(),
766                epoch: None,
767                min_relative_deviation: Some(5.0),
768                min_absolute_deviation: None,
769                dispersion_method: "stddev".to_string(),
770                min_measurements: Some(10),
771                aggregate_by: Some("mean".to_string()),
772                sigma: Some(3.0),
773                unit: Some("ms".to_string()),
774                change_point: ChangePointConfig::default(),
775                from_parent_fallback: false,
776            },
777        );
778
779        let issues = validate_config(&measurements).unwrap();
780        assert_eq!(issues.len(), 1);
781        assert!(issues[0].contains("No epoch configured"));
782    }
783
784    #[test]
785    fn test_validate_config_invalid_sigma() {
786        let mut measurements = HashMap::new();
787        measurements.insert(
788            "build_time".to_string(),
789            MeasurementConfig {
790                name: "build_time".to_string(),
791                epoch: Some("12345678".to_string()),
792                min_relative_deviation: Some(5.0),
793                min_absolute_deviation: None,
794                dispersion_method: "stddev".to_string(),
795                min_measurements: Some(10),
796                aggregate_by: Some("mean".to_string()),
797                sigma: Some(-1.0),
798                unit: Some("ms".to_string()),
799                change_point: ChangePointConfig::default(),
800                from_parent_fallback: false,
801            },
802        );
803
804        let issues = validate_config(&measurements).unwrap();
805        assert_eq!(issues.len(), 1);
806        assert!(issues[0].contains("Invalid sigma value"));
807    }
808
809    #[test]
810    fn test_validate_config_invalid_min_relative_deviation() {
811        let mut measurements = HashMap::new();
812        measurements.insert(
813            "build_time".to_string(),
814            MeasurementConfig {
815                name: "build_time".to_string(),
816                epoch: Some("12345678".to_string()),
817                min_relative_deviation: Some(-5.0),
818                min_absolute_deviation: None,
819                dispersion_method: "stddev".to_string(),
820                min_measurements: Some(10),
821                aggregate_by: Some("mean".to_string()),
822                sigma: Some(3.0),
823                unit: Some("ms".to_string()),
824                change_point: ChangePointConfig::default(),
825                from_parent_fallback: false,
826            },
827        );
828
829        let issues = validate_config(&measurements).unwrap();
830        assert_eq!(issues.len(), 1);
831        assert!(issues[0].contains("Invalid min_relative_deviation"));
832    }
833
834    #[test]
835    fn test_validate_config_invalid_min_measurements() {
836        let mut measurements = HashMap::new();
837        measurements.insert(
838            "build_time".to_string(),
839            MeasurementConfig {
840                name: "build_time".to_string(),
841                epoch: Some("12345678".to_string()),
842                min_relative_deviation: Some(5.0),
843                min_absolute_deviation: None,
844                dispersion_method: "stddev".to_string(),
845                min_measurements: Some(1),
846                aggregate_by: Some("mean".to_string()),
847                sigma: Some(3.0),
848                unit: Some("ms".to_string()),
849                change_point: ChangePointConfig::default(),
850                from_parent_fallback: false,
851            },
852        );
853
854        let issues = validate_config(&measurements).unwrap();
855        assert_eq!(issues.len(), 1);
856        assert!(issues[0].contains("Invalid min_measurements"));
857    }
858
859    #[test]
860    fn test_validate_config_invalid_min_absolute_deviation() {
861        let mut measurements = HashMap::new();
862        measurements.insert(
863            "build_time".to_string(),
864            MeasurementConfig {
865                name: "build_time".to_string(),
866                epoch: Some("12345678".to_string()),
867                min_relative_deviation: Some(5.0),
868                min_absolute_deviation: Some(-1.0),
869                dispersion_method: "stddev".to_string(),
870                min_measurements: Some(10),
871                aggregate_by: Some("mean".to_string()),
872                sigma: Some(3.0),
873                unit: Some("ms".to_string()),
874                change_point: ChangePointConfig::default(),
875                from_parent_fallback: false,
876            },
877        );
878
879        let issues = validate_config(&measurements).unwrap();
880        assert_eq!(issues.len(), 1);
881        assert!(issues[0].contains("Invalid min_absolute_deviation"));
882    }
883
884    #[test]
885    fn test_validate_config_min_absolute_deviation_zero_is_valid() {
886        // 0.0 is non-negative, so it should be allowed (catches < vs <= mutation)
887        let mut measurements = HashMap::new();
888        measurements.insert(
889            "build_time".to_string(),
890            MeasurementConfig {
891                name: "build_time".to_string(),
892                epoch: Some("12345678".to_string()),
893                min_relative_deviation: Some(5.0),
894                min_absolute_deviation: Some(0.0),
895                dispersion_method: "stddev".to_string(),
896                min_measurements: Some(10),
897                aggregate_by: Some("mean".to_string()),
898                sigma: Some(3.0),
899                unit: Some("ms".to_string()),
900                change_point: ChangePointConfig::default(),
901                from_parent_fallback: false,
902            },
903        );
904
905        let issues = validate_config(&measurements).unwrap();
906        assert!(
907            issues.is_empty(),
908            "min_absolute_deviation = 0.0 should be valid (non-negative). Got issues: {:?}",
909            issues
910        );
911    }
912
913    #[test]
914    fn test_validate_config_multiple_issues() {
915        let mut measurements = HashMap::new();
916        measurements.insert(
917            "build_time".to_string(),
918            MeasurementConfig {
919                name: "build_time".to_string(),
920                epoch: None,
921                min_relative_deviation: Some(-5.0),
922                min_absolute_deviation: None,
923                dispersion_method: "stddev".to_string(),
924                min_measurements: Some(1),
925                aggregate_by: Some("mean".to_string()),
926                sigma: Some(-3.0),
927                unit: Some("ms".to_string()),
928                change_point: ChangePointConfig::default(),
929                from_parent_fallback: false,
930            },
931        );
932
933        let issues = validate_config(&measurements).unwrap();
934        assert_eq!(issues.len(), 4); // epoch, sigma, min_relative_deviation, min_measurements
935    }
936
937    #[test]
938    fn test_gather_measurement_configs_empty() {
939        with_isolated_test_setup(|_git_dir, _home_path| {
940            // No config file
941            let measurements = gather_measurement_configs(None).unwrap();
942            assert!(measurements.is_empty());
943        });
944    }
945
946    #[test]
947    fn test_gather_measurement_configs_with_filter() {
948        with_isolated_test_setup(|git_dir, _home_path| {
949            write_gitperfconfig(
950                git_dir,
951                r#"
952[measurement.build_time]
953epoch = 0x12345678
954
955[measurement.test_time]
956epoch = 0x87654321
957"#,
958            );
959
960            let measurements = gather_measurement_configs(Some("build_time")).unwrap();
961            assert_eq!(measurements.len(), 1);
962            assert!(measurements.contains_key("build_time"));
963            assert!(!measurements.contains_key("test_time"));
964        });
965    }
966
967    #[test]
968    fn test_config_info_serialization() {
969        with_isolated_test_setup(|_git_dir, home_path| {
970            let config_info = ConfigInfo {
971                git_context: GitContext {
972                    branch: "master".to_string(),
973                    repository_root: PathBuf::from(home_path),
974                },
975                config_sources: ConfigSources {
976                    system_config: None,
977                    local_config: Some(PathBuf::from(home_path).join(".gitperfconfig")),
978                },
979                global_settings: GlobalSettings {
980                    backoff_max_elapsed_seconds: 60,
981                },
982                measurements: HashMap::new(),
983                validation_issues: None,
984            };
985
986            // Test that it serializes to JSON without errors
987            let json = serde_json::to_string_pretty(&config_info).unwrap();
988            assert!(json.contains("master"));
989            assert!(json.contains("backoff_max_elapsed_seconds"));
990
991            // Test that it deserializes back
992            let deserialized: ConfigInfo = serde_json::from_str(&json).unwrap();
993            assert_eq!(deserialized.git_context.branch, "master");
994        });
995    }
996
997    #[test]
998    fn test_display_measurement_human_detailed() {
999        let measurement = MeasurementConfig {
1000            name: "build_time".to_string(),
1001            epoch: Some("12345678".to_string()),
1002            min_relative_deviation: Some(5.0),
1003            min_absolute_deviation: None,
1004            dispersion_method: "stddev".to_string(),
1005            min_measurements: Some(10),
1006            aggregate_by: Some("mean".to_string()),
1007            sigma: Some(3.0),
1008            unit: Some("ms".to_string()),
1009            change_point: ChangePointConfig::default(),
1010            from_parent_fallback: false,
1011        };
1012
1013        // This test just ensures the function doesn't panic
1014        display_measurement_human(&measurement, true);
1015    }
1016
1017    #[test]
1018    fn test_display_measurement_human_summary() {
1019        let measurement = MeasurementConfig {
1020            name: "build_time".to_string(),
1021            epoch: Some("12345678".to_string()),
1022            min_relative_deviation: Some(5.0),
1023            min_absolute_deviation: None,
1024            dispersion_method: "stddev".to_string(),
1025            min_measurements: Some(10),
1026            aggregate_by: Some("mean".to_string()),
1027            sigma: Some(3.0),
1028            unit: Some("ms".to_string()),
1029            change_point: ChangePointConfig::default(),
1030            from_parent_fallback: false,
1031        };
1032
1033        // This test just ensures the function doesn't panic
1034        display_measurement_human(&measurement, false);
1035    }
1036
1037    #[test]
1038    fn test_gather_single_measurement_config_change_point_defaults() {
1039        with_isolated_test_setup(|git_dir, _home_path| {
1040            write_gitperfconfig(
1041                git_dir,
1042                r#"
1043[measurement.build_time]
1044epoch = "12345678"
1045"#,
1046            );
1047
1048            let config = read_hierarchical_config().unwrap();
1049            let meas_config = gather_single_measurement_config("build_time", &config);
1050
1051            assert!(meas_config.change_point.enabled);
1052            assert_eq!(meas_config.change_point.min_data_points, 10);
1053            assert_eq!(meas_config.change_point.min_magnitude_pct, 5.0);
1054            assert_eq!(meas_config.change_point.penalty, 0.5);
1055        });
1056    }
1057
1058    #[test]
1059    fn test_gather_single_measurement_config_change_point_from_config() {
1060        with_isolated_test_setup(|git_dir, _home_path| {
1061            write_gitperfconfig(
1062                git_dir,
1063                r#"
1064[measurement.build_time]
1065epoch = "12345678"
1066
1067[change_point]
1068enabled = false
1069min_data_points = 20
1070min_magnitude_pct = 10.0
1071penalty = 1.5
1072"#,
1073            );
1074
1075            let config = read_hierarchical_config().unwrap();
1076            let meas_config = gather_single_measurement_config("build_time", &config);
1077
1078            assert!(!meas_config.change_point.enabled);
1079            assert_eq!(meas_config.change_point.min_data_points, 20);
1080            assert_eq!(meas_config.change_point.min_magnitude_pct, 10.0);
1081            assert_eq!(meas_config.change_point.penalty, 1.5);
1082        });
1083    }
1084
1085    #[test]
1086    fn test_validate_config_invalid_change_point_min_data_points() {
1087        let mut measurements = HashMap::new();
1088        measurements.insert(
1089            "build_time".to_string(),
1090            MeasurementConfig {
1091                name: "build_time".to_string(),
1092                epoch: Some("12345678".to_string()),
1093                min_relative_deviation: Some(5.0),
1094                min_absolute_deviation: None,
1095                dispersion_method: "stddev".to_string(),
1096                min_measurements: Some(10),
1097                aggregate_by: Some("mean".to_string()),
1098                sigma: Some(3.0),
1099                unit: Some("ms".to_string()),
1100                change_point: ChangePointConfig {
1101                    min_data_points: 0,
1102                    ..ChangePointConfig::default()
1103                },
1104                from_parent_fallback: false,
1105            },
1106        );
1107
1108        let issues = validate_config(&measurements).unwrap();
1109        assert_eq!(issues.len(), 1);
1110        assert!(issues[0].contains("change_point.min_data_points"));
1111    }
1112
1113    #[test]
1114    fn test_validate_config_invalid_change_point_min_magnitude_pct() {
1115        let mut measurements = HashMap::new();
1116        measurements.insert(
1117            "build_time".to_string(),
1118            MeasurementConfig {
1119                name: "build_time".to_string(),
1120                epoch: Some("12345678".to_string()),
1121                min_relative_deviation: Some(5.0),
1122                min_absolute_deviation: None,
1123                dispersion_method: "stddev".to_string(),
1124                min_measurements: Some(10),
1125                aggregate_by: Some("mean".to_string()),
1126                sigma: Some(3.0),
1127                unit: Some("ms".to_string()),
1128                change_point: ChangePointConfig {
1129                    min_magnitude_pct: -1.0,
1130                    ..ChangePointConfig::default()
1131                },
1132                from_parent_fallback: false,
1133            },
1134        );
1135
1136        let issues = validate_config(&measurements).unwrap();
1137        assert_eq!(issues.len(), 1);
1138        assert!(issues[0].contains("change_point.min_magnitude_pct"));
1139    }
1140
1141    #[test]
1142    fn test_validate_config_invalid_change_point_penalty() {
1143        let mut measurements = HashMap::new();
1144        measurements.insert(
1145            "build_time".to_string(),
1146            MeasurementConfig {
1147                name: "build_time".to_string(),
1148                epoch: Some("12345678".to_string()),
1149                min_relative_deviation: Some(5.0),
1150                min_absolute_deviation: None,
1151                dispersion_method: "stddev".to_string(),
1152                min_measurements: Some(10),
1153                aggregate_by: Some("mean".to_string()),
1154                sigma: Some(3.0),
1155                unit: Some("ms".to_string()),
1156                change_point: ChangePointConfig {
1157                    penalty: 0.0,
1158                    ..ChangePointConfig::default()
1159                },
1160                from_parent_fallback: false,
1161            },
1162        );
1163
1164        let issues = validate_config(&measurements).unwrap();
1165        assert_eq!(issues.len(), 1);
1166        assert!(issues[0].contains("change_point.penalty"));
1167    }
1168
1169    #[test]
1170    fn test_validate_config_change_point_min_magnitude_pct_zero_is_valid() {
1171        // 0.0 is non-negative, so it should be valid
1172        let mut measurements = HashMap::new();
1173        measurements.insert(
1174            "build_time".to_string(),
1175            MeasurementConfig {
1176                name: "build_time".to_string(),
1177                epoch: Some("12345678".to_string()),
1178                min_relative_deviation: Some(5.0),
1179                min_absolute_deviation: None,
1180                dispersion_method: "stddev".to_string(),
1181                min_measurements: Some(10),
1182                aggregate_by: Some("mean".to_string()),
1183                sigma: Some(3.0),
1184                unit: Some("ms".to_string()),
1185                change_point: ChangePointConfig {
1186                    min_magnitude_pct: 0.0,
1187                    ..ChangePointConfig::default()
1188                },
1189                from_parent_fallback: false,
1190            },
1191        );
1192
1193        let issues = validate_config(&measurements).unwrap();
1194        assert!(
1195            issues.is_empty(),
1196            "change_point.min_magnitude_pct = 0.0 should be valid. Got issues: {:?}",
1197            issues
1198        );
1199    }
1200
1201    #[test]
1202    fn test_config_info_json_contains_change_point() {
1203        with_isolated_test_setup(|git_dir, home_path| {
1204            write_gitperfconfig(
1205                git_dir,
1206                r#"
1207[measurement.build_time]
1208epoch = "12345678"
1209
1210[change_point]
1211penalty = 1.0
1212"#,
1213            );
1214
1215            let config_info = gather_config_info(false, None).unwrap();
1216            let json = serde_json::to_string_pretty(&config_info).unwrap();
1217
1218            assert!(
1219                json.contains("change_point"),
1220                "JSON should contain change_point: {}",
1221                json
1222            );
1223            assert!(
1224                json.contains("penalty"),
1225                "JSON should contain penalty: {}",
1226                json
1227            );
1228            let _ = home_path; // suppress unused warning
1229        });
1230    }
1231}