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#[derive(Debug, Serialize, Deserialize)]
17pub struct ConfigInfo {
18 pub git_context: GitContext,
20
21 pub config_sources: ConfigSources,
23
24 pub global_settings: GlobalSettings,
26
27 pub measurements: HashMap<String, MeasurementConfig>,
29
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub validation_issues: Option<Vec<String>>,
33}
34
35#[derive(Debug, Serialize, Deserialize)]
37pub struct GitContext {
38 pub branch: String,
40
41 pub repository_root: PathBuf,
43}
44
45#[derive(Debug, Serialize, Deserialize)]
47pub struct ConfigSources {
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub system_config: Option<PathBuf>,
51
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub local_config: Option<PathBuf>,
55}
56
57#[derive(Debug, Serialize, Deserialize)]
59pub struct GlobalSettings {
60 pub backoff_max_elapsed_seconds: u64,
62}
63
64#[derive(Debug, Serialize, Deserialize)]
66pub struct MeasurementConfig {
67 pub name: String,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub epoch: Option<String>,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub min_relative_deviation: Option<f64>,
77
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub min_absolute_deviation: Option<f64>,
81
82 pub dispersion_method: String,
84
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub min_measurements: Option<u16>,
88
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub aggregate_by: Option<String>,
92
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub sigma: Option<f64>,
96
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub unit: Option<String>,
100
101 pub change_point: ChangePointConfig,
103
104 pub from_parent_fallback: bool,
106}
107
108pub fn list_config(
110 detailed: bool,
111 format: ConfigFormat,
112 validate: bool,
113 measurement_filter: Option<String>,
114) -> Result<()> {
115 let config_info = gather_config_info(validate, measurement_filter.as_deref())?;
117
118 match format {
120 ConfigFormat::Human => display_human_readable(&config_info, detailed)?,
121 ConfigFormat::Json => display_json(&config_info)?,
122 }
123
124 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
139fn 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
161fn gather_git_context() -> Result<GitContext> {
163 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 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
184fn gather_config_sources() -> Result<ConfigSources> {
186 let system_config = find_system_config();
188
189 let local_config = get_local_config_path();
191
192 Ok(ConfigSources {
193 system_config,
194 local_config,
195 })
196}
197
198fn 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
221fn 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
232fn gather_global_settings() -> GlobalSettings {
234 GlobalSettings {
235 backoff_max_elapsed_seconds: backoff_max_elapsed_seconds(),
236 }
237}
238
239fn gather_measurement_configs(
241 measurement_filter: Option<&str>,
242) -> Result<HashMap<String, MeasurementConfig>> {
243 let mut measurements = HashMap::new();
244
245 let config = match read_hierarchical_config() {
247 Ok(c) => c,
248 Err(_) => {
249 return Ok(measurements);
251 }
252 };
253
254 let measurement_names = extract_measurement_names(&config)?;
256
257 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 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
276fn extract_measurement_names(config: &Config) -> Result<Vec<String>> {
278 let mut names = Vec::new();
279
280 if let Ok(table) = config.get_table("measurement") {
282 for (key, value) in table {
283 if matches!(value.kind, config::ValueKind::Table(_)) {
285 names.push(key);
286 }
287 }
288 }
289
290 Ok(names)
291}
292
293fn gather_single_measurement_config(name: &str, config: &Config) -> MeasurementConfig {
295 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
313fn validate_config(measurements: &HashMap<String, MeasurementConfig>) -> Result<Vec<String>> {
315 let mut issues = Vec::new();
316
317 for (name, config) in measurements {
318 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 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 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 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 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 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 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 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
394fn display_human_readable(info: &ConfigInfo, detailed: bool) -> Result<()> {
396 println!("Git-Perf Configuration");
397 println!("======================");
398 println!();
399
400 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 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 println!("Global Settings:");
425 println!(
426 " backoff.max_elapsed_seconds: {}",
427 info.global_settings.backoff_max_elapsed_seconds
428 );
429 println!();
430
431 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 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
467fn 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 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
535fn 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 let xdg_config_dir = Path::new(home_path).join("xdg_config");
565 env::set_var("XDG_CONFIG_HOME", &xdg_config_dir);
566
567 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 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 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 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 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 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(); 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 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); }
936
937 #[test]
938 fn test_gather_measurement_configs_empty() {
939 with_isolated_test_setup(|_git_dir, _home_path| {
940 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 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 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 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 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 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; });
1230 }
1231}