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::{with_isolated_test_setup, write_gitperfconfig};
466    use std::env;
467    use std::fs;
468    use std::path::Path;
469
470    #[test]
471    fn test_gather_git_context() {
472        with_isolated_test_setup(|_git_dir, _home_path| {
473            let context = gather_git_context().unwrap();
474            assert_eq!(context.branch, "master");
475            assert!(context.repository_root.exists());
476        });
477    }
478
479    #[test]
480    fn test_find_system_config_xdg() {
481        with_isolated_test_setup(|_git_dir, home_path| {
482            // Set XDG_CONFIG_HOME
483            let xdg_config_dir = Path::new(home_path).join("xdg_config");
484            env::set_var("XDG_CONFIG_HOME", &xdg_config_dir);
485
486            // Create system config
487            let system_config_dir = xdg_config_dir.join("git-perf");
488            fs::create_dir_all(&system_config_dir).unwrap();
489            let system_config_path = system_config_dir.join("config.toml");
490            fs::write(&system_config_path, "# test config\n").unwrap();
491
492            let result = find_system_config();
493            assert_eq!(result, Some(system_config_path));
494        });
495    }
496
497    #[test]
498    fn test_find_system_config_home_fallback() {
499        with_isolated_test_setup(|_git_dir, home_path| {
500            // Create config in HOME/.config
501            let config_dir = Path::new(home_path).join(".config").join("git-perf");
502            fs::create_dir_all(&config_dir).unwrap();
503            let config_path = config_dir.join("config.toml");
504            fs::write(&config_path, "# test config\n").unwrap();
505
506            let result = find_system_config();
507            assert_eq!(result, Some(config_path));
508        });
509    }
510
511    #[test]
512    fn test_find_system_config_none() {
513        with_isolated_test_setup(|_git_dir, _home_path| {
514            let result = find_system_config();
515            assert_eq!(result, None);
516        });
517    }
518
519    #[test]
520    fn test_get_local_config_path_exists() {
521        with_isolated_test_setup(|git_dir, _home_path| {
522            write_gitperfconfig(git_dir, "[measurement]\n");
523
524            let result = get_local_config_path();
525            // Canonicalize both paths to handle symlinks (e.g., /var -> /private/var on macOS)
526            assert_eq!(
527                result.map(|p| p.canonicalize().unwrap()),
528                Some(git_dir.join(".gitperfconfig").canonicalize().unwrap())
529            );
530        });
531    }
532
533    #[test]
534    fn test_get_local_config_path_none() {
535        with_isolated_test_setup(|_git_dir, _home_path| {
536            let result = get_local_config_path();
537            assert_eq!(result, None);
538        });
539    }
540
541    #[test]
542    fn test_gather_config_sources() {
543        with_isolated_test_setup(|git_dir, home_path| {
544            // Create system config in HOME/.config
545            let system_config_dir = Path::new(home_path).join(".config").join("git-perf");
546            fs::create_dir_all(&system_config_dir).unwrap();
547            let system_config_path = system_config_dir.join("config.toml");
548            fs::write(&system_config_path, "# system config\n").unwrap();
549
550            write_gitperfconfig(git_dir, "[measurement]\n");
551
552            let sources = gather_config_sources().unwrap();
553            assert_eq!(sources.system_config, Some(system_config_path));
554            // Canonicalize both paths to handle symlinks (e.g., /var -> /private/var on macOS)
555            assert_eq!(
556                sources.local_config.map(|p| p.canonicalize().unwrap()),
557                Some(git_dir.join(".gitperfconfig").canonicalize().unwrap())
558            );
559        });
560    }
561
562    #[test]
563    fn test_gather_global_settings() {
564        with_isolated_test_setup(|_git_dir, _home_path| {
565            let settings = gather_global_settings();
566            // Default value is 60 seconds
567            assert_eq!(settings.backoff_max_elapsed_seconds, 60);
568        });
569    }
570
571    #[test]
572    fn test_extract_measurement_names_empty() {
573        with_isolated_test_setup(|_git_dir, _home_path| {
574            let config = Config::builder().build().unwrap();
575            let names = extract_measurement_names(&config).unwrap();
576            assert!(names.is_empty());
577        });
578    }
579
580    #[test]
581    fn test_extract_measurement_names_with_measurements() {
582        with_isolated_test_setup(|git_dir, _home_path| {
583            write_gitperfconfig(
584                git_dir,
585                r#"
586[measurement.build_time]
587epoch = 0x12345678
588
589[measurement.test_time]
590epoch = 0x87654321
591"#,
592            );
593
594            let config = read_hierarchical_config().unwrap();
595            let mut names = extract_measurement_names(&config).unwrap();
596            names.sort(); // Sort for consistent comparison
597
598            assert_eq!(names, vec!["build_time", "test_time"]);
599        });
600    }
601
602    #[test]
603    fn test_gather_single_measurement_config() {
604        with_isolated_test_setup(|git_dir, _home_path| {
605            write_gitperfconfig(
606                git_dir,
607                r#"
608[measurement.build_time]
609epoch = "12345678"
610min_relative_deviation = 5.0
611dispersion_method = "mad"
612min_measurements = 10
613aggregate_by = "median"
614sigma = 2.0
615unit = "ms"
616"#,
617            );
618
619            let config = read_hierarchical_config().unwrap();
620            let meas_config = gather_single_measurement_config("build_time", &config);
621
622            assert_eq!(meas_config.name, "build_time");
623            assert_eq!(meas_config.epoch, Some("12345678".to_string()));
624            assert_eq!(meas_config.min_relative_deviation, Some(5.0));
625            assert_eq!(meas_config.dispersion_method, "medianabsolutedeviation");
626            assert_eq!(meas_config.min_measurements, Some(10));
627            assert_eq!(meas_config.aggregate_by, Some("median".to_string()));
628            assert_eq!(meas_config.sigma, Some(2.0));
629            assert_eq!(meas_config.unit, Some("ms".to_string()));
630            assert!(!meas_config.from_parent_fallback);
631        });
632    }
633
634    #[test]
635    fn test_gather_single_measurement_config_parent_fallback() {
636        with_isolated_test_setup(|git_dir, _home_path| {
637            write_gitperfconfig(
638                git_dir,
639                r#"
640[measurement]
641dispersion_method = "stddev"
642"#,
643            );
644
645            let config = read_hierarchical_config().unwrap();
646            let meas_config = gather_single_measurement_config("build_time", &config);
647
648            assert_eq!(meas_config.name, "build_time");
649            assert_eq!(meas_config.dispersion_method, "standarddeviation");
650            assert!(meas_config.from_parent_fallback);
651        });
652    }
653
654    #[test]
655    fn test_validate_config_valid() {
656        let mut measurements = HashMap::new();
657        measurements.insert(
658            "build_time".to_string(),
659            MeasurementConfig {
660                name: "build_time".to_string(),
661                epoch: Some("12345678".to_string()),
662                min_relative_deviation: Some(5.0),
663                dispersion_method: "stddev".to_string(),
664                min_measurements: Some(10),
665                aggregate_by: Some("mean".to_string()),
666                sigma: Some(3.0),
667                unit: Some("ms".to_string()),
668                from_parent_fallback: false,
669            },
670        );
671
672        let issues = validate_config(&measurements).unwrap();
673        assert!(issues.is_empty());
674    }
675
676    #[test]
677    fn test_validate_config_missing_epoch() {
678        let mut measurements = HashMap::new();
679        measurements.insert(
680            "build_time".to_string(),
681            MeasurementConfig {
682                name: "build_time".to_string(),
683                epoch: None,
684                min_relative_deviation: Some(5.0),
685                dispersion_method: "stddev".to_string(),
686                min_measurements: Some(10),
687                aggregate_by: Some("mean".to_string()),
688                sigma: Some(3.0),
689                unit: Some("ms".to_string()),
690                from_parent_fallback: false,
691            },
692        );
693
694        let issues = validate_config(&measurements).unwrap();
695        assert_eq!(issues.len(), 1);
696        assert!(issues[0].contains("No epoch configured"));
697    }
698
699    #[test]
700    fn test_validate_config_invalid_sigma() {
701        let mut measurements = HashMap::new();
702        measurements.insert(
703            "build_time".to_string(),
704            MeasurementConfig {
705                name: "build_time".to_string(),
706                epoch: Some("12345678".to_string()),
707                min_relative_deviation: Some(5.0),
708                dispersion_method: "stddev".to_string(),
709                min_measurements: Some(10),
710                aggregate_by: Some("mean".to_string()),
711                sigma: Some(-1.0),
712                unit: Some("ms".to_string()),
713                from_parent_fallback: false,
714            },
715        );
716
717        let issues = validate_config(&measurements).unwrap();
718        assert_eq!(issues.len(), 1);
719        assert!(issues[0].contains("Invalid sigma value"));
720    }
721
722    #[test]
723    fn test_validate_config_invalid_min_relative_deviation() {
724        let mut measurements = HashMap::new();
725        measurements.insert(
726            "build_time".to_string(),
727            MeasurementConfig {
728                name: "build_time".to_string(),
729                epoch: Some("12345678".to_string()),
730                min_relative_deviation: Some(-5.0),
731                dispersion_method: "stddev".to_string(),
732                min_measurements: Some(10),
733                aggregate_by: Some("mean".to_string()),
734                sigma: Some(3.0),
735                unit: Some("ms".to_string()),
736                from_parent_fallback: false,
737            },
738        );
739
740        let issues = validate_config(&measurements).unwrap();
741        assert_eq!(issues.len(), 1);
742        assert!(issues[0].contains("Invalid min_relative_deviation"));
743    }
744
745    #[test]
746    fn test_validate_config_invalid_min_measurements() {
747        let mut measurements = HashMap::new();
748        measurements.insert(
749            "build_time".to_string(),
750            MeasurementConfig {
751                name: "build_time".to_string(),
752                epoch: Some("12345678".to_string()),
753                min_relative_deviation: Some(5.0),
754                dispersion_method: "stddev".to_string(),
755                min_measurements: Some(1),
756                aggregate_by: Some("mean".to_string()),
757                sigma: Some(3.0),
758                unit: Some("ms".to_string()),
759                from_parent_fallback: false,
760            },
761        );
762
763        let issues = validate_config(&measurements).unwrap();
764        assert_eq!(issues.len(), 1);
765        assert!(issues[0].contains("Invalid min_measurements"));
766    }
767
768    #[test]
769    fn test_validate_config_multiple_issues() {
770        let mut measurements = HashMap::new();
771        measurements.insert(
772            "build_time".to_string(),
773            MeasurementConfig {
774                name: "build_time".to_string(),
775                epoch: None,
776                min_relative_deviation: Some(-5.0),
777                dispersion_method: "stddev".to_string(),
778                min_measurements: Some(1),
779                aggregate_by: Some("mean".to_string()),
780                sigma: Some(-3.0),
781                unit: Some("ms".to_string()),
782                from_parent_fallback: false,
783            },
784        );
785
786        let issues = validate_config(&measurements).unwrap();
787        assert_eq!(issues.len(), 4); // epoch, sigma, min_relative_deviation, min_measurements
788    }
789
790    #[test]
791    fn test_gather_measurement_configs_empty() {
792        with_isolated_test_setup(|_git_dir, _home_path| {
793            // No config file
794            let measurements = gather_measurement_configs(None).unwrap();
795            assert!(measurements.is_empty());
796        });
797    }
798
799    #[test]
800    fn test_gather_measurement_configs_with_filter() {
801        with_isolated_test_setup(|git_dir, _home_path| {
802            write_gitperfconfig(
803                git_dir,
804                r#"
805[measurement.build_time]
806epoch = 0x12345678
807
808[measurement.test_time]
809epoch = 0x87654321
810"#,
811            );
812
813            let measurements = gather_measurement_configs(Some("build_time")).unwrap();
814            assert_eq!(measurements.len(), 1);
815            assert!(measurements.contains_key("build_time"));
816            assert!(!measurements.contains_key("test_time"));
817        });
818    }
819
820    #[test]
821    fn test_config_info_serialization() {
822        with_isolated_test_setup(|_git_dir, home_path| {
823            let config_info = ConfigInfo {
824                git_context: GitContext {
825                    branch: "master".to_string(),
826                    repository_root: PathBuf::from(home_path),
827                },
828                config_sources: ConfigSources {
829                    system_config: None,
830                    local_config: Some(PathBuf::from(home_path).join(".gitperfconfig")),
831                },
832                global_settings: GlobalSettings {
833                    backoff_max_elapsed_seconds: 60,
834                },
835                measurements: HashMap::new(),
836                validation_issues: None,
837            };
838
839            // Test that it serializes to JSON without errors
840            let json = serde_json::to_string_pretty(&config_info).unwrap();
841            assert!(json.contains("master"));
842            assert!(json.contains("backoff_max_elapsed_seconds"));
843
844            // Test that it deserializes back
845            let deserialized: ConfigInfo = serde_json::from_str(&json).unwrap();
846            assert_eq!(deserialized.git_context.branch, "master");
847        });
848    }
849
850    #[test]
851    fn test_display_measurement_human_detailed() {
852        let measurement = MeasurementConfig {
853            name: "build_time".to_string(),
854            epoch: Some("12345678".to_string()),
855            min_relative_deviation: Some(5.0),
856            dispersion_method: "stddev".to_string(),
857            min_measurements: Some(10),
858            aggregate_by: Some("mean".to_string()),
859            sigma: Some(3.0),
860            unit: Some("ms".to_string()),
861            from_parent_fallback: false,
862        };
863
864        // This test just ensures the function doesn't panic
865        display_measurement_human(&measurement, true);
866    }
867
868    #[test]
869    fn test_display_measurement_human_summary() {
870        let measurement = MeasurementConfig {
871            name: "build_time".to_string(),
872            epoch: Some("12345678".to_string()),
873            min_relative_deviation: Some(5.0),
874            dispersion_method: "stddev".to_string(),
875            min_measurements: Some(10),
876            aggregate_by: Some("mean".to_string()),
877            sigma: Some(3.0),
878            unit: Some("ms".to_string()),
879            from_parent_fallback: false,
880        };
881
882        // This test just ensures the function doesn't panic
883        display_measurement_human(&measurement, false);
884    }
885}