git_perf/
config_cmd.rs

1use crate::config::{
2    audit_aggregate_by, audit_dispersion_method, audit_min_measurements,
3    audit_min_relative_deviation, audit_sigma, backoff_max_elapsed_seconds,
4    determine_epoch_from_config, measurement_unit, read_hierarchical_config,
5};
6use crate::git::git_interop::get_repository_root;
7use anyhow::{Context, Result};
8use config::Config;
9use git_perf_cli_types::ConfigFormat;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::PathBuf;
13
14/// Complete configuration information
15#[derive(Debug, Serialize, Deserialize)]
16pub struct ConfigInfo {
17    /// Git context information
18    pub git_context: GitContext,
19
20    /// Configuration sources being used
21    pub config_sources: ConfigSources,
22
23    /// Global settings (not measurement-specific)
24    pub global_settings: GlobalSettings,
25
26    /// Measurement-specific configurations
27    pub measurements: HashMap<String, MeasurementConfig>,
28
29    /// Validation issues (if validation was requested)
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub validation_issues: Option<Vec<String>>,
32}
33
34/// Git repository context
35#[derive(Debug, Serialize, Deserialize)]
36pub struct GitContext {
37    /// Current branch name
38    pub branch: String,
39
40    /// Repository root path
41    pub repository_root: PathBuf,
42}
43
44/// Configuration file sources
45#[derive(Debug, Serialize, Deserialize)]
46pub struct ConfigSources {
47    /// System-wide config path (if exists)
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub system_config: Option<PathBuf>,
50
51    /// Local repository config path (if exists)
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub local_config: Option<PathBuf>,
54}
55
56/// Global configuration settings
57#[derive(Debug, Serialize, Deserialize)]
58pub struct GlobalSettings {
59    /// Backoff max elapsed seconds
60    pub backoff_max_elapsed_seconds: u64,
61}
62
63/// Configuration for a specific measurement
64#[derive(Debug, Serialize, Deserialize)]
65pub struct MeasurementConfig {
66    /// Measurement name
67    pub name: String,
68
69    /// Epoch (8-char hex string)
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub epoch: Option<String>,
72
73    /// Minimum relative deviation threshold (%)
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub min_relative_deviation: Option<f64>,
76
77    /// Dispersion method (stddev or mad)
78    pub dispersion_method: String,
79
80    /// Minimum measurements required
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub min_measurements: Option<u16>,
83
84    /// Aggregation function
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub aggregate_by: Option<String>,
87
88    /// Sigma threshold
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub sigma: Option<f64>,
91
92    /// Measurement unit
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub unit: Option<String>,
95
96    /// Whether this is from parent table fallback (vs measurement-specific)
97    pub from_parent_fallback: bool,
98}
99
100/// Display configuration information (implements config --list)
101pub fn list_config(
102    detailed: bool,
103    format: ConfigFormat,
104    validate: bool,
105    measurement_filter: Option<String>,
106) -> Result<()> {
107    // 1. Gather configuration information
108    let config_info = gather_config_info(validate, measurement_filter.as_deref())?;
109
110    // 2. Display based on format
111    match format {
112        ConfigFormat::Human => display_human_readable(&config_info, detailed)?,
113        ConfigFormat::Json => display_json(&config_info)?,
114    }
115
116    // 3. Exit with error if validation found issues
117    if validate {
118        if let Some(ref issues) = config_info.validation_issues {
119            if !issues.is_empty() {
120                return Err(anyhow::anyhow!(
121                    "Configuration validation found {} issue(s)",
122                    issues.len()
123                ));
124            }
125        }
126    }
127
128    Ok(())
129}
130
131/// Gather all configuration information
132fn gather_config_info(validate: bool, measurement_filter: Option<&str>) -> Result<ConfigInfo> {
133    let git_context = gather_git_context()?;
134    let config_sources = gather_config_sources()?;
135    let global_settings = gather_global_settings();
136    let measurements = gather_measurement_configs(measurement_filter)?;
137
138    let validation_issues = if validate {
139        Some(validate_config(&measurements)?)
140    } else {
141        None
142    };
143
144    Ok(ConfigInfo {
145        git_context,
146        config_sources,
147        global_settings,
148        measurements,
149        validation_issues,
150    })
151}
152
153/// Get git context information
154fn gather_git_context() -> Result<GitContext> {
155    // Get current branch name
156    let branch_output = std::process::Command::new("git")
157        .args(["rev-parse", "--abbrev-ref", "HEAD"])
158        .output()
159        .context("Failed to get current branch")?;
160
161    let branch = String::from_utf8_lossy(&branch_output.stdout)
162        .trim()
163        .to_string();
164
165    // Get repository root
166    let repo_root = get_repository_root()
167        .map_err(|e| anyhow::anyhow!("Failed to get repository root: {}", e))?;
168    let repository_root = PathBuf::from(repo_root);
169
170    Ok(GitContext {
171        branch,
172        repository_root,
173    })
174}
175
176/// Determine which config files are being used
177fn gather_config_sources() -> Result<ConfigSources> {
178    // System config
179    let system_config = find_system_config();
180
181    // Local config - get repository config path
182    let local_config = get_local_config_path();
183
184    Ok(ConfigSources {
185        system_config,
186        local_config,
187    })
188}
189
190/// Find system config path if it exists
191fn find_system_config() -> Option<PathBuf> {
192    use std::env;
193
194    if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") {
195        let path = PathBuf::from(xdg_config_home)
196            .join("git-perf")
197            .join("config.toml");
198        if path.exists() {
199            return Some(path);
200        }
201    }
202
203    if let Some(home) = dirs_next::home_dir() {
204        let path = home.join(".config").join("git-perf").join("config.toml");
205        if path.exists() {
206            return Some(path);
207        }
208    }
209
210    None
211}
212
213/// Get the local repository config path (if it exists)
214fn get_local_config_path() -> Option<PathBuf> {
215    let repo_root = get_repository_root().ok()?;
216    let path = PathBuf::from(repo_root).join(".gitperfconfig");
217    if path.exists() {
218        Some(path)
219    } else {
220        None
221    }
222}
223
224/// Gather global (non-measurement) settings
225fn gather_global_settings() -> GlobalSettings {
226    GlobalSettings {
227        backoff_max_elapsed_seconds: backoff_max_elapsed_seconds(),
228    }
229}
230
231/// Gather measurement configurations
232fn gather_measurement_configs(
233    measurement_filter: Option<&str>,
234) -> Result<HashMap<String, MeasurementConfig>> {
235    let mut measurements = HashMap::new();
236
237    // Get hierarchical config
238    let config = match read_hierarchical_config() {
239        Ok(c) => c,
240        Err(_) => {
241            // No config found, return empty map
242            return Ok(measurements);
243        }
244    };
245
246    // Extract all measurement names from config
247    let measurement_names = extract_measurement_names(&config)?;
248
249    // Filter if requested
250    let filtered_names: Vec<String> = if let Some(filter) = measurement_filter {
251        measurement_names
252            .into_iter()
253            .filter(|name| name == filter)
254            .collect()
255    } else {
256        measurement_names
257    };
258
259    // Gather config for each measurement
260    for name in filtered_names {
261        let measurement_config = gather_single_measurement_config(&name, &config);
262        measurements.insert(name.clone(), measurement_config);
263    }
264
265    Ok(measurements)
266}
267
268/// Extract measurement names from config
269fn extract_measurement_names(config: &Config) -> Result<Vec<String>> {
270    let mut names = Vec::new();
271
272    // Try to get the measurement table
273    if let Ok(table) = config.get_table("measurement") {
274        for (key, value) in table {
275            // Skip non-table values (these are parent defaults)
276            if matches!(value.kind, config::ValueKind::Table(_)) {
277                names.push(key);
278            }
279        }
280    }
281
282    Ok(names)
283}
284
285/// Gather configuration for a single measurement
286fn gather_single_measurement_config(name: &str, config: &Config) -> MeasurementConfig {
287    // Check if settings are measurement-specific or from parent fallback
288    let has_specific_config = config.get_table(&format!("measurement.{}", name)).is_ok();
289
290    MeasurementConfig {
291        name: name.to_string(),
292        epoch: determine_epoch_from_config(name).map(|e| format!("{:08x}", e)),
293        min_relative_deviation: audit_min_relative_deviation(name),
294        dispersion_method: format!("{:?}", audit_dispersion_method(name)).to_lowercase(),
295        min_measurements: audit_min_measurements(name),
296        aggregate_by: audit_aggregate_by(name).map(|f| format!("{:?}", f).to_lowercase()),
297        sigma: audit_sigma(name),
298        unit: measurement_unit(name),
299        from_parent_fallback: !has_specific_config,
300    }
301}
302
303/// Validate configuration
304fn validate_config(measurements: &HashMap<String, MeasurementConfig>) -> Result<Vec<String>> {
305    let mut issues = Vec::new();
306
307    for (name, config) in measurements {
308        // Check for missing epoch
309        if config.epoch.is_none() {
310            issues.push(format!(
311                "Measurement '{}': No epoch configured (run 'git perf bump-epoch -m {}')",
312                name, name
313            ));
314        }
315
316        // Check for invalid sigma values
317        if let Some(sigma) = config.sigma {
318            if sigma <= 0.0 {
319                issues.push(format!(
320                    "Measurement '{}': Invalid sigma value {} (must be positive)",
321                    name, sigma
322                ));
323            }
324        }
325
326        // Check for invalid min_relative_deviation
327        if let Some(deviation) = config.min_relative_deviation {
328            if deviation < 0.0 {
329                issues.push(format!(
330                    "Measurement '{}': Invalid min_relative_deviation {} (must be non-negative)",
331                    name, deviation
332                ));
333            }
334        }
335
336        // Check for invalid min_measurements
337        if let Some(min_meas) = config.min_measurements {
338            if min_meas < 2 {
339                issues.push(format!(
340                    "Measurement '{}': Invalid min_measurements {} (must be at least 2)",
341                    name, min_meas
342                ));
343            }
344        }
345    }
346
347    Ok(issues)
348}
349
350/// Display configuration in human-readable format
351fn display_human_readable(info: &ConfigInfo, detailed: bool) -> Result<()> {
352    println!("Git-Perf Configuration");
353    println!("======================");
354    println!();
355
356    // Git Context
357    println!("Git Context:");
358    println!("  Branch: {}", info.git_context.branch);
359    println!(
360        "  Repository: {}",
361        info.git_context.repository_root.display()
362    );
363    println!();
364
365    // Configuration Sources
366    println!("Configuration Sources:");
367    if let Some(ref system_path) = info.config_sources.system_config {
368        println!("  System config: {}", system_path.display());
369    } else {
370        println!("  System config: (none)");
371    }
372    if let Some(ref local_path) = info.config_sources.local_config {
373        println!("  Local config:  {}", local_path.display());
374    } else {
375        println!("  Local config:  (none)");
376    }
377    println!();
378
379    // Global Settings
380    println!("Global Settings:");
381    println!(
382        "  backoff.max_elapsed_seconds: {}",
383        info.global_settings.backoff_max_elapsed_seconds
384    );
385    println!();
386
387    // Measurements
388    if info.measurements.is_empty() {
389        println!("Measurements: (none configured)");
390    } else {
391        println!("Measurements: ({} configured)", info.measurements.len());
392        println!();
393
394        let mut sorted_measurements: Vec<_> = info.measurements.values().collect();
395        sorted_measurements.sort_by_key(|m| &m.name);
396
397        for measurement in sorted_measurements {
398            display_measurement_human(measurement, detailed);
399        }
400    }
401
402    // Validation Issues
403    if let Some(ref issues) = info.validation_issues {
404        if !issues.is_empty() {
405            println!();
406            println!("Validation Issues:");
407            for issue in issues {
408                println!("  \u{26A0} {}", issue);
409            }
410        } else {
411            println!();
412            println!("\u{2713} Configuration is valid");
413        }
414    }
415
416    Ok(())
417}
418
419/// Display a single measurement configuration
420fn display_measurement_human(measurement: &MeasurementConfig, detailed: bool) {
421    if detailed {
422        println!("  [{}]", measurement.name);
423        if measurement.from_parent_fallback {
424            println!("    (using parent table defaults)");
425        }
426        println!("    epoch:                  {:?}", measurement.epoch);
427        println!(
428            "    min_relative_deviation: {:?}",
429            measurement.min_relative_deviation
430        );
431        println!(
432            "    dispersion_method:      {}",
433            measurement.dispersion_method
434        );
435        println!(
436            "    min_measurements:       {:?}",
437            measurement.min_measurements
438        );
439        println!("    aggregate_by:           {:?}", measurement.aggregate_by);
440        println!("    sigma:                  {:?}", measurement.sigma);
441        println!("    unit:                   {:?}", measurement.unit);
442        println!();
443    } else {
444        // Summary view - just name and epoch
445        let epoch_display = measurement.epoch.as_deref().unwrap_or("(not set)");
446        let unit_display = measurement.unit.as_deref().unwrap_or("(not set)");
447        println!(
448            "  {} - epoch: {}, unit: {}",
449            measurement.name, epoch_display, unit_display
450        );
451    }
452}
453
454/// Display configuration as JSON
455fn display_json(info: &ConfigInfo) -> Result<()> {
456    let json =
457        serde_json::to_string_pretty(info).context("Failed to serialize configuration to JSON")?;
458    println!("{}", json);
459    Ok(())
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use crate::test_helpers::{
466        dir_with_repo, hermetic_git_env, with_isolated_home, write_gitperfconfig, DirGuard,
467    };
468    use std::env;
469    use std::fs;
470    use std::path::Path;
471
472    #[test]
473    fn test_gather_git_context() {
474        hermetic_git_env();
475        with_isolated_home(|home_path| {
476            let temp_dir = dir_with_repo();
477            let _guard = DirGuard::new(temp_dir.path());
478            env::set_var("HOME", home_path);
479
480            let context = gather_git_context().unwrap();
481            assert_eq!(context.branch, "master");
482            assert!(context.repository_root.exists());
483        });
484    }
485
486    #[test]
487    fn test_find_system_config_xdg() {
488        hermetic_git_env();
489        with_isolated_home(|home_path| {
490            // Set XDG_CONFIG_HOME
491            let xdg_config_dir = Path::new(home_path).join("xdg_config");
492            env::set_var("XDG_CONFIG_HOME", &xdg_config_dir);
493
494            // Create system config
495            let system_config_dir = xdg_config_dir.join("git-perf");
496            fs::create_dir_all(&system_config_dir).unwrap();
497            let system_config_path = system_config_dir.join("config.toml");
498            fs::write(&system_config_path, "# test config\n").unwrap();
499
500            let result = find_system_config();
501            assert_eq!(result, Some(system_config_path));
502        });
503    }
504
505    #[test]
506    fn test_find_system_config_home_fallback() {
507        hermetic_git_env();
508        with_isolated_home(|home_path| {
509            // Create config in HOME/.config
510            let config_dir = Path::new(home_path).join(".config").join("git-perf");
511            fs::create_dir_all(&config_dir).unwrap();
512            let config_path = config_dir.join("config.toml");
513            fs::write(&config_path, "# test config\n").unwrap();
514
515            let result = find_system_config();
516            assert_eq!(result, Some(config_path));
517        });
518    }
519
520    #[test]
521    fn test_find_system_config_none() {
522        hermetic_git_env();
523        with_isolated_home(|_home_path| {
524            let result = find_system_config();
525            assert_eq!(result, None);
526        });
527    }
528
529    #[test]
530    fn test_get_local_config_path_exists() {
531        hermetic_git_env();
532        with_isolated_home(|home_path| {
533            let temp_dir = dir_with_repo();
534            let _guard = DirGuard::new(temp_dir.path());
535            env::set_var("HOME", home_path);
536
537            write_gitperfconfig(temp_dir.path(), "[measurement]\n");
538
539            let result = get_local_config_path();
540            assert_eq!(result, Some(temp_dir.path().join(".gitperfconfig")));
541        });
542    }
543
544    #[test]
545    fn test_get_local_config_path_none() {
546        hermetic_git_env();
547        with_isolated_home(|home_path| {
548            let temp_dir = dir_with_repo();
549            let _guard = DirGuard::new(temp_dir.path());
550            env::set_var("HOME", home_path);
551
552            let result = get_local_config_path();
553            assert_eq!(result, None);
554        });
555    }
556
557    #[test]
558    fn test_gather_config_sources() {
559        hermetic_git_env();
560        with_isolated_home(|home_path| {
561            let temp_dir = dir_with_repo();
562            let _guard = DirGuard::new(temp_dir.path());
563            env::set_var("HOME", home_path);
564
565            // Create system config in HOME/.config
566            let system_config_dir = Path::new(home_path).join(".config").join("git-perf");
567            fs::create_dir_all(&system_config_dir).unwrap();
568            let system_config_path = system_config_dir.join("config.toml");
569            fs::write(&system_config_path, "# system config\n").unwrap();
570
571            write_gitperfconfig(temp_dir.path(), "[measurement]\n");
572
573            let sources = gather_config_sources().unwrap();
574            assert_eq!(sources.system_config, Some(system_config_path));
575            assert_eq!(
576                sources.local_config,
577                Some(temp_dir.path().join(".gitperfconfig"))
578            );
579        });
580    }
581
582    #[test]
583    fn test_gather_global_settings() {
584        hermetic_git_env();
585        with_isolated_home(|_home_path| {
586            let settings = gather_global_settings();
587            // Default value is 60 seconds
588            assert_eq!(settings.backoff_max_elapsed_seconds, 60);
589        });
590    }
591
592    #[test]
593    fn test_extract_measurement_names_empty() {
594        hermetic_git_env();
595        with_isolated_home(|home_path| {
596            let temp_dir = dir_with_repo();
597            let _guard = DirGuard::new(temp_dir.path());
598            env::set_var("HOME", home_path);
599
600            let config = Config::builder().build().unwrap();
601            let names = extract_measurement_names(&config).unwrap();
602            assert!(names.is_empty());
603        });
604    }
605
606    #[test]
607    fn test_extract_measurement_names_with_measurements() {
608        hermetic_git_env();
609        with_isolated_home(|home_path| {
610            let temp_dir = dir_with_repo();
611            let _guard = DirGuard::new(temp_dir.path());
612            env::set_var("HOME", home_path);
613
614            write_gitperfconfig(
615                temp_dir.path(),
616                r#"
617[measurement.build_time]
618epoch = 0x12345678
619
620[measurement.test_time]
621epoch = 0x87654321
622"#,
623            );
624
625            let config = read_hierarchical_config().unwrap();
626            let mut names = extract_measurement_names(&config).unwrap();
627            names.sort(); // Sort for consistent comparison
628
629            assert_eq!(names, vec!["build_time", "test_time"]);
630        });
631    }
632
633    #[test]
634    fn test_gather_single_measurement_config() {
635        hermetic_git_env();
636        with_isolated_home(|home_path| {
637            let temp_dir = dir_with_repo();
638            let _guard = DirGuard::new(temp_dir.path());
639            env::set_var("HOME", home_path);
640
641            write_gitperfconfig(
642                temp_dir.path(),
643                r#"
644[measurement.build_time]
645epoch = "12345678"
646min_relative_deviation = 5.0
647dispersion_method = "mad"
648min_measurements = 10
649aggregate_by = "median"
650sigma = 2.0
651unit = "ms"
652"#,
653            );
654
655            let config = read_hierarchical_config().unwrap();
656            let meas_config = gather_single_measurement_config("build_time", &config);
657
658            assert_eq!(meas_config.name, "build_time");
659            assert_eq!(meas_config.epoch, Some("12345678".to_string()));
660            assert_eq!(meas_config.min_relative_deviation, Some(5.0));
661            assert_eq!(meas_config.dispersion_method, "medianabsolutedeviation");
662            assert_eq!(meas_config.min_measurements, Some(10));
663            assert_eq!(meas_config.aggregate_by, Some("median".to_string()));
664            assert_eq!(meas_config.sigma, Some(2.0));
665            assert_eq!(meas_config.unit, Some("ms".to_string()));
666            assert!(!meas_config.from_parent_fallback);
667        });
668    }
669
670    #[test]
671    fn test_gather_single_measurement_config_parent_fallback() {
672        hermetic_git_env();
673        with_isolated_home(|home_path| {
674            let temp_dir = dir_with_repo();
675            let _guard = DirGuard::new(temp_dir.path());
676            env::set_var("HOME", home_path);
677
678            write_gitperfconfig(
679                temp_dir.path(),
680                r#"
681[measurement]
682dispersion_method = "stddev"
683"#,
684            );
685
686            let config = read_hierarchical_config().unwrap();
687            let meas_config = gather_single_measurement_config("build_time", &config);
688
689            assert_eq!(meas_config.name, "build_time");
690            assert_eq!(meas_config.dispersion_method, "standarddeviation");
691            assert!(meas_config.from_parent_fallback);
692        });
693    }
694
695    #[test]
696    fn test_validate_config_valid() {
697        let mut measurements = HashMap::new();
698        measurements.insert(
699            "build_time".to_string(),
700            MeasurementConfig {
701                name: "build_time".to_string(),
702                epoch: Some("12345678".to_string()),
703                min_relative_deviation: Some(5.0),
704                dispersion_method: "stddev".to_string(),
705                min_measurements: Some(10),
706                aggregate_by: Some("mean".to_string()),
707                sigma: Some(3.0),
708                unit: Some("ms".to_string()),
709                from_parent_fallback: false,
710            },
711        );
712
713        let issues = validate_config(&measurements).unwrap();
714        assert!(issues.is_empty());
715    }
716
717    #[test]
718    fn test_validate_config_missing_epoch() {
719        let mut measurements = HashMap::new();
720        measurements.insert(
721            "build_time".to_string(),
722            MeasurementConfig {
723                name: "build_time".to_string(),
724                epoch: None,
725                min_relative_deviation: Some(5.0),
726                dispersion_method: "stddev".to_string(),
727                min_measurements: Some(10),
728                aggregate_by: Some("mean".to_string()),
729                sigma: Some(3.0),
730                unit: Some("ms".to_string()),
731                from_parent_fallback: false,
732            },
733        );
734
735        let issues = validate_config(&measurements).unwrap();
736        assert_eq!(issues.len(), 1);
737        assert!(issues[0].contains("No epoch configured"));
738    }
739
740    #[test]
741    fn test_validate_config_invalid_sigma() {
742        let mut measurements = HashMap::new();
743        measurements.insert(
744            "build_time".to_string(),
745            MeasurementConfig {
746                name: "build_time".to_string(),
747                epoch: Some("12345678".to_string()),
748                min_relative_deviation: Some(5.0),
749                dispersion_method: "stddev".to_string(),
750                min_measurements: Some(10),
751                aggregate_by: Some("mean".to_string()),
752                sigma: Some(-1.0),
753                unit: Some("ms".to_string()),
754                from_parent_fallback: false,
755            },
756        );
757
758        let issues = validate_config(&measurements).unwrap();
759        assert_eq!(issues.len(), 1);
760        assert!(issues[0].contains("Invalid sigma value"));
761    }
762
763    #[test]
764    fn test_validate_config_invalid_min_relative_deviation() {
765        let mut measurements = HashMap::new();
766        measurements.insert(
767            "build_time".to_string(),
768            MeasurementConfig {
769                name: "build_time".to_string(),
770                epoch: Some("12345678".to_string()),
771                min_relative_deviation: Some(-5.0),
772                dispersion_method: "stddev".to_string(),
773                min_measurements: Some(10),
774                aggregate_by: Some("mean".to_string()),
775                sigma: Some(3.0),
776                unit: Some("ms".to_string()),
777                from_parent_fallback: false,
778            },
779        );
780
781        let issues = validate_config(&measurements).unwrap();
782        assert_eq!(issues.len(), 1);
783        assert!(issues[0].contains("Invalid min_relative_deviation"));
784    }
785
786    #[test]
787    fn test_validate_config_invalid_min_measurements() {
788        let mut measurements = HashMap::new();
789        measurements.insert(
790            "build_time".to_string(),
791            MeasurementConfig {
792                name: "build_time".to_string(),
793                epoch: Some("12345678".to_string()),
794                min_relative_deviation: Some(5.0),
795                dispersion_method: "stddev".to_string(),
796                min_measurements: Some(1),
797                aggregate_by: Some("mean".to_string()),
798                sigma: Some(3.0),
799                unit: Some("ms".to_string()),
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 min_measurements"));
807    }
808
809    #[test]
810    fn test_validate_config_multiple_issues() {
811        let mut measurements = HashMap::new();
812        measurements.insert(
813            "build_time".to_string(),
814            MeasurementConfig {
815                name: "build_time".to_string(),
816                epoch: None,
817                min_relative_deviation: Some(-5.0),
818                dispersion_method: "stddev".to_string(),
819                min_measurements: Some(1),
820                aggregate_by: Some("mean".to_string()),
821                sigma: Some(-3.0),
822                unit: Some("ms".to_string()),
823                from_parent_fallback: false,
824            },
825        );
826
827        let issues = validate_config(&measurements).unwrap();
828        assert_eq!(issues.len(), 4); // epoch, sigma, min_relative_deviation, min_measurements
829    }
830
831    #[test]
832    fn test_gather_measurement_configs_empty() {
833        hermetic_git_env();
834        with_isolated_home(|home_path| {
835            let temp_dir = dir_with_repo();
836            let _guard = DirGuard::new(temp_dir.path());
837            env::set_var("HOME", home_path);
838
839            // No config file
840            let measurements = gather_measurement_configs(None).unwrap();
841            assert!(measurements.is_empty());
842        });
843    }
844
845    #[test]
846    fn test_gather_measurement_configs_with_filter() {
847        hermetic_git_env();
848        with_isolated_home(|home_path| {
849            let temp_dir = dir_with_repo();
850            let _guard = DirGuard::new(temp_dir.path());
851            env::set_var("HOME", home_path);
852
853            write_gitperfconfig(
854                temp_dir.path(),
855                r#"
856[measurement.build_time]
857epoch = 0x12345678
858
859[measurement.test_time]
860epoch = 0x87654321
861"#,
862            );
863
864            let measurements = gather_measurement_configs(Some("build_time")).unwrap();
865            assert_eq!(measurements.len(), 1);
866            assert!(measurements.contains_key("build_time"));
867            assert!(!measurements.contains_key("test_time"));
868        });
869    }
870
871    #[test]
872    fn test_config_info_serialization() {
873        hermetic_git_env();
874        with_isolated_home(|home_path| {
875            let config_info = ConfigInfo {
876                git_context: GitContext {
877                    branch: "master".to_string(),
878                    repository_root: PathBuf::from(home_path),
879                },
880                config_sources: ConfigSources {
881                    system_config: None,
882                    local_config: Some(PathBuf::from(home_path).join(".gitperfconfig")),
883                },
884                global_settings: GlobalSettings {
885                    backoff_max_elapsed_seconds: 60,
886                },
887                measurements: HashMap::new(),
888                validation_issues: None,
889            };
890
891            // Test that it serializes to JSON without errors
892            let json = serde_json::to_string_pretty(&config_info).unwrap();
893            assert!(json.contains("master"));
894            assert!(json.contains("backoff_max_elapsed_seconds"));
895
896            // Test that it deserializes back
897            let deserialized: ConfigInfo = serde_json::from_str(&json).unwrap();
898            assert_eq!(deserialized.git_context.branch, "master");
899        });
900    }
901
902    #[test]
903    fn test_display_measurement_human_detailed() {
904        let measurement = MeasurementConfig {
905            name: "build_time".to_string(),
906            epoch: Some("12345678".to_string()),
907            min_relative_deviation: Some(5.0),
908            dispersion_method: "stddev".to_string(),
909            min_measurements: Some(10),
910            aggregate_by: Some("mean".to_string()),
911            sigma: Some(3.0),
912            unit: Some("ms".to_string()),
913            from_parent_fallback: false,
914        };
915
916        // This test just ensures the function doesn't panic
917        display_measurement_human(&measurement, true);
918    }
919
920    #[test]
921    fn test_display_measurement_human_summary() {
922        let measurement = MeasurementConfig {
923            name: "build_time".to_string(),
924            epoch: Some("12345678".to_string()),
925            min_relative_deviation: Some(5.0),
926            dispersion_method: "stddev".to_string(),
927            min_measurements: Some(10),
928            aggregate_by: Some("mean".to_string()),
929            sigma: Some(3.0),
930            unit: Some("ms".to_string()),
931            from_parent_fallback: false,
932        };
933
934        // This test just ensures the function doesn't panic
935        display_measurement_human(&measurement, false);
936    }
937}