1use anyhow::Result;
2use config::{Config, ConfigError, File, FileFormat};
3use std::{
4 env,
5 fs::File as StdFile,
6 io::{Read, Write},
7 path::{Path, PathBuf},
8};
9use toml_edit::{value, Document, Item, Table};
10
11use crate::git::git_interop::{get_head_revision, get_repository_root};
12
13use git_perf_cli_types::DispersionMethod;
15
16pub trait ConfigParentFallbackExt {
21 fn get_with_parent_fallback(&self, parent: &str, name: &str, key: &str) -> Option<String>;
27}
28
29impl ConfigParentFallbackExt for Config {
30 fn get_with_parent_fallback(&self, parent: &str, name: &str, key: &str) -> Option<String> {
31 let specific_key = format!("{}.{}.{}", parent, name, key);
33 if let Ok(v) = self.get_string(&specific_key) {
34 return Some(v);
35 }
36
37 let parent_key = format!("{}.{}", parent, key);
39 if let Ok(v) = self.get_string(&parent_key) {
40 return Some(v);
41 }
42
43 None
44 }
45}
46
47fn get_main_config_path() -> Result<PathBuf> {
49 let repo_root = get_repository_root().map_err(|e| {
51 anyhow::anyhow!(
52 "Failed to determine repository root - must be run from within a git repository: {}",
53 e
54 )
55 })?;
56
57 if repo_root.is_empty() {
58 return Err(anyhow::anyhow!(
59 "Repository root is empty - must be run from within a git repository"
60 ));
61 }
62
63 Ok(PathBuf::from(repo_root).join(".gitperfconfig"))
64}
65
66pub fn write_config(conf: &str) -> Result<()> {
68 let path = get_main_config_path()?;
69 let mut f = StdFile::create(path)?;
70 f.write_all(conf.as_bytes())?;
71 Ok(())
72}
73
74pub fn read_hierarchical_config() -> Result<Config, ConfigError> {
76 let mut builder = Config::builder();
77
78 if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") {
80 let system_config_path = Path::new(&xdg_config_home)
81 .join("git-perf")
82 .join("config.toml");
83 builder = builder.add_source(
84 File::from(system_config_path)
85 .format(FileFormat::Toml)
86 .required(false),
87 );
88 } else if let Some(home) = dirs_next::home_dir() {
89 let system_config_path = home.join(".config").join("git-perf").join("config.toml");
90 builder = builder.add_source(
91 File::from(system_config_path)
92 .format(FileFormat::Toml)
93 .required(false),
94 );
95 }
96
97 if let Some(local_path) = find_config_path() {
99 builder = builder.add_source(
100 File::from(local_path)
101 .format(FileFormat::Toml)
102 .required(false),
103 );
104 }
105
106 builder.build()
107}
108
109fn find_config_path() -> Option<PathBuf> {
110 let path = get_main_config_path().ok()?;
112 if path.is_file() {
113 Some(path)
114 } else {
115 None
116 }
117}
118
119fn read_config_from_file<P: AsRef<Path>>(file: P) -> Result<String> {
120 let mut conf_str = String::new();
121 StdFile::open(file)?.read_to_string(&mut conf_str)?;
122 Ok(conf_str)
123}
124
125pub fn determine_epoch_from_config(measurement: &str) -> Option<u32> {
126 let config = read_hierarchical_config()
127 .map_err(|e| {
128 log::debug!("Could not read hierarchical config: {}", e);
130 })
131 .ok()?;
132
133 config
135 .get_with_parent_fallback("measurement", measurement, "epoch")
136 .and_then(|s| u32::from_str_radix(&s, 16).ok())
137}
138
139pub fn bump_epoch_in_conf(measurement: &str, conf_str: &mut String) -> Result<()> {
140 let mut conf = conf_str
141 .parse::<Document>()
142 .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?;
143
144 let head_revision = get_head_revision()?;
145
146 if !conf.contains_key("measurement") {
148 conf["measurement"] = Item::Table(Table::new());
149 }
150 if !conf["measurement"]
151 .as_table()
152 .unwrap()
153 .contains_key(measurement)
154 {
155 conf["measurement"][measurement] = Item::Table(Table::new());
156 }
157
158 conf["measurement"][measurement]["epoch"] = value(&head_revision[0..8]);
159 *conf_str = conf.to_string();
160
161 Ok(())
162}
163
164pub fn bump_epoch(measurement: &str) -> Result<()> {
165 let config_path = get_main_config_path()?;
167 let mut conf_str = read_config_from_file(&config_path).unwrap_or_default();
168
169 bump_epoch_in_conf(measurement, &mut conf_str)?;
170 write_config(&conf_str)?;
171 Ok(())
172}
173
174pub fn backoff_max_elapsed_seconds() -> u64 {
176 match read_hierarchical_config() {
177 Ok(config) => {
178 if let Ok(seconds) = config.get_int("backoff.max_elapsed_seconds") {
179 seconds as u64
180 } else {
181 60 }
183 }
184 Err(_) => 60, }
186}
187
188pub fn audit_min_relative_deviation(measurement: &str) -> Option<f64> {
190 let config = read_hierarchical_config().ok()?;
191
192 if let Some(s) =
193 config.get_with_parent_fallback("measurement", measurement, "min_relative_deviation")
194 {
195 if let Ok(v) = s.parse::<f64>() {
196 return Some(v);
197 }
198 }
199
200 None
201}
202
203pub fn audit_dispersion_method(measurement: &str) -> DispersionMethod {
205 let Some(config) = read_hierarchical_config().ok() else {
206 return DispersionMethod::StandardDeviation;
207 };
208
209 if let Some(s) =
210 config.get_with_parent_fallback("measurement", measurement, "dispersion_method")
211 {
212 if let Ok(method) = s.parse::<DispersionMethod>() {
213 return method;
214 }
215 }
216
217 DispersionMethod::StandardDeviation
219}
220
221#[cfg(test)]
222mod test {
223 use super::*;
224 use std::fs;
225 use tempfile::TempDir;
226
227 fn with_isolated_home<F, R>(f: F) -> R
231 where
232 F: FnOnce(&Path) -> R,
233 {
234 let temp_dir = TempDir::new().unwrap();
235
236 env::set_var("HOME", temp_dir.path());
238 env::remove_var("XDG_CONFIG_HOME");
239
240 f(temp_dir.path())
241 }
242
243 fn init_git_repo(dir: &Path) {
245 std::process::Command::new("git")
246 .args(["init", "--initial-branch=master"])
247 .current_dir(dir)
248 .output()
249 .expect("Failed to initialize git repository");
250 }
251
252 fn init_git_repo_with_commit(dir: &Path) {
254 init_git_repo(dir);
255
256 fs::write(dir.join("test.txt"), "test content").unwrap();
258 std::process::Command::new("git")
259 .args(["add", "test.txt"])
260 .current_dir(dir)
261 .output()
262 .expect("Failed to add file");
263 std::process::Command::new("git")
264 .args(["commit", "-m", "test commit"])
265 .current_dir(dir)
266 .output()
267 .expect("Failed to commit");
268 }
269
270 fn create_home_config_dir(home_dir: &Path) -> PathBuf {
272 let config_dir = home_dir.join(".config").join("git-perf");
273 fs::create_dir_all(&config_dir).unwrap();
274 config_dir.join("config.toml")
275 }
276
277 #[test]
278 fn test_read_epochs() {
279 with_isolated_home(|temp_dir| {
280 env::set_current_dir(temp_dir).unwrap();
282 init_git_repo(temp_dir);
283
284 let workspace_config_path = temp_dir.join(".gitperfconfig");
286 let configfile = r#"[measurement]
287# General performance regression
288epoch="12344555"
289
290[measurement."something"]
291#My comment
292epoch="34567898"
293
294[measurement."somethingelse"]
295epoch="a3dead"
296"#;
297 fs::write(&workspace_config_path, configfile).unwrap();
298
299 let epoch = determine_epoch_from_config("something");
300 assert_eq!(epoch, Some(0x34567898));
301
302 let epoch = determine_epoch_from_config("somethingelse");
303 assert_eq!(epoch, Some(0xa3dead));
304
305 let epoch = determine_epoch_from_config("unspecified");
306 assert_eq!(epoch, Some(0x12344555));
307 });
308 }
309
310 #[test]
311 fn test_bump_epochs() {
312 with_isolated_home(|temp_dir| {
313 env::set_current_dir(temp_dir).unwrap();
315
316 env::set_var("GIT_CONFIG_NOSYSTEM", "true");
318 env::set_var("GIT_CONFIG_GLOBAL", "/dev/null");
319 env::set_var("GIT_AUTHOR_NAME", "testuser");
320 env::set_var("GIT_AUTHOR_EMAIL", "testuser@example.com");
321 env::set_var("GIT_COMMITTER_NAME", "testuser");
322 env::set_var("GIT_COMMITTER_EMAIL", "testuser@example.com");
323
324 init_git_repo_with_commit(temp_dir);
326
327 let configfile = r#"[measurement."something"]
328#My comment
329epoch = "34567898"
330"#;
331
332 let mut actual = String::from(configfile);
333 bump_epoch_in_conf("something", &mut actual).expect("Failed to bump epoch");
334
335 let expected = format!(
336 r#"[measurement."something"]
337#My comment
338epoch = "{}"
339"#,
340 &get_head_revision().expect("get_head_revision failed")[0..8],
341 );
342
343 assert_eq!(actual, expected);
344 });
345 }
346
347 #[test]
348 fn test_bump_new_epoch_and_read_it() {
349 with_isolated_home(|temp_dir| {
350 env::set_current_dir(temp_dir).unwrap();
352
353 env::set_var("GIT_CONFIG_NOSYSTEM", "true");
355 env::set_var("GIT_CONFIG_GLOBAL", "/dev/null");
356 env::set_var("GIT_AUTHOR_NAME", "testuser");
357 env::set_var("GIT_AUTHOR_EMAIL", "testuser@example.com");
358 env::set_var("GIT_COMMITTER_NAME", "testuser");
359 env::set_var("GIT_COMMITTER_EMAIL", "testuser@example.com");
360
361 init_git_repo_with_commit(temp_dir);
363
364 let mut conf = String::new();
365 bump_epoch_in_conf("mymeasurement", &mut conf).expect("Failed to bump epoch");
366
367 let config_path = temp_dir.join(".gitperfconfig");
369 fs::write(&config_path, &conf).unwrap();
370
371 let epoch = determine_epoch_from_config("mymeasurement");
372 assert!(epoch.is_some());
373 });
374 }
375
376 #[test]
377 fn test_backoff_max_elapsed_seconds() {
378 with_isolated_home(|temp_dir| {
379 env::set_current_dir(temp_dir).unwrap();
381 init_git_repo(temp_dir);
382
383 let workspace_config_path = temp_dir.join(".gitperfconfig");
385 let local_config = "[backoff]\nmax_elapsed_seconds = 42\n";
386 fs::write(&workspace_config_path, local_config).unwrap();
387
388 assert_eq!(super::backoff_max_elapsed_seconds(), 42);
390
391 fs::remove_file(&workspace_config_path).unwrap();
393 assert_eq!(super::backoff_max_elapsed_seconds(), 60);
394 });
395 }
396
397 #[test]
398 fn test_audit_min_relative_deviation() {
399 with_isolated_home(|temp_dir| {
400 env::set_current_dir(temp_dir).unwrap();
402 init_git_repo(temp_dir);
403
404 let workspace_config_path = temp_dir.join(".gitperfconfig");
406 let local_config = r#"
407[measurement]
408min_relative_deviation = 5.0
409
410[measurement."build_time"]
411min_relative_deviation = 10.0
412
413[measurement."memory_usage"]
414min_relative_deviation = 2.5
415"#;
416 fs::write(&workspace_config_path, local_config).unwrap();
417
418 assert_eq!(
420 super::audit_min_relative_deviation("build_time"),
421 Some(10.0)
422 );
423 assert_eq!(
424 super::audit_min_relative_deviation("memory_usage"),
425 Some(2.5)
426 );
427 assert_eq!(
428 super::audit_min_relative_deviation("other_measurement"),
429 Some(5.0) );
431
432 let global_config = r#"
434[measurement]
435min_relative_deviation = 5.0
436"#;
437 fs::write(&workspace_config_path, global_config).unwrap();
438 assert_eq!(
439 super::audit_min_relative_deviation("any_measurement"),
440 Some(5.0)
441 );
442
443 let precedence_config = r#"
445[measurement]
446min_relative_deviation = 5.0
447
448[measurement."build_time"]
449min_relative_deviation = 10.0
450"#;
451 fs::write(&workspace_config_path, precedence_config).unwrap();
452 assert_eq!(
453 super::audit_min_relative_deviation("build_time"),
454 Some(10.0)
455 );
456 assert_eq!(
457 super::audit_min_relative_deviation("other_measurement"),
458 Some(5.0)
459 );
460
461 fs::remove_file(&workspace_config_path).unwrap();
463 assert_eq!(super::audit_min_relative_deviation("any_measurement"), None);
464 });
465 }
466
467 #[test]
468 fn test_audit_dispersion_method() {
469 with_isolated_home(|temp_dir| {
470 env::set_current_dir(temp_dir).unwrap();
472 init_git_repo(temp_dir);
473
474 let workspace_config_path = temp_dir.join(".gitperfconfig");
476 let local_config = r#"
477[measurement]
478dispersion_method = "stddev"
479
480[measurement."build_time"]
481dispersion_method = "mad"
482
483[measurement."memory_usage"]
484dispersion_method = "stddev"
485"#;
486 fs::write(&workspace_config_path, local_config).unwrap();
487
488 assert_eq!(
490 super::audit_dispersion_method("build_time"),
491 git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
492 );
493 assert_eq!(
494 super::audit_dispersion_method("memory_usage"),
495 git_perf_cli_types::DispersionMethod::StandardDeviation
496 );
497 assert_eq!(
498 super::audit_dispersion_method("other_measurement"),
499 git_perf_cli_types::DispersionMethod::StandardDeviation
500 );
501
502 let global_config = r#"
504[measurement]
505dispersion_method = "mad"
506"#;
507 fs::write(&workspace_config_path, global_config).unwrap();
508 assert_eq!(
509 super::audit_dispersion_method("any_measurement"),
510 git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
511 );
512
513 let precedence_config = r#"
515[measurement]
516dispersion_method = "mad"
517
518[measurement."build_time"]
519dispersion_method = "stddev"
520"#;
521 fs::write(&workspace_config_path, precedence_config).unwrap();
522 assert_eq!(
523 super::audit_dispersion_method("build_time"),
524 git_perf_cli_types::DispersionMethod::StandardDeviation
525 );
526 assert_eq!(
527 super::audit_dispersion_method("other_measurement"),
528 git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
529 );
530
531 fs::remove_file(&workspace_config_path).unwrap();
533 assert_eq!(
534 super::audit_dispersion_method("any_measurement"),
535 git_perf_cli_types::DispersionMethod::StandardDeviation
536 );
537 });
538 }
539
540 #[test]
541 fn test_bump_epoch_in_conf_creates_proper_tables() {
542 with_isolated_home(|temp_dir| {
545 env::set_current_dir(temp_dir).unwrap();
546
547 env::set_var("GIT_CONFIG_NOSYSTEM", "true");
549 env::set_var("GIT_CONFIG_GLOBAL", "/dev/null");
550 env::set_var("GIT_AUTHOR_NAME", "testuser");
551 env::set_var("GIT_AUTHOR_EMAIL", "testuser@example.com");
552 env::set_var("GIT_COMMITTER_NAME", "testuser");
553 env::set_var("GIT_COMMITTER_EMAIL", "testuser@example.com");
554
555 init_git_repo_with_commit(temp_dir);
556
557 let mut empty_config = String::new();
559
560 bump_epoch_in_conf("mymeasurement", &mut empty_config).unwrap();
562
563 assert!(empty_config.contains("[measurement]"));
565 assert!(empty_config.contains("[measurement.mymeasurement]"));
566 assert!(empty_config.contains("epoch ="));
567 assert!(!empty_config.contains("measurement = {"));
569 assert!(!empty_config.contains("mymeasurement = {"));
570
571 let mut existing_config = r#"[measurement]
573existing_setting = "value"
574
575[measurement."other"]
576epoch = "oldvalue"
577"#
578 .to_string();
579
580 bump_epoch_in_conf("newmeasurement", &mut existing_config).unwrap();
581
582 assert!(existing_config.contains("[measurement.newmeasurement]"));
584 assert!(existing_config.contains("existing_setting = \"value\""));
585 assert!(existing_config.contains("[measurement.\"other\"]"));
586 assert!(!existing_config.contains("newmeasurement = {"));
587 });
588 }
589
590 #[test]
591 fn test_find_config_path_in_git_root() {
592 with_isolated_home(|temp_dir| {
593 env::set_current_dir(temp_dir).unwrap();
595
596 init_git_repo(temp_dir);
598
599 let config_path = temp_dir.join(".gitperfconfig");
601 fs::write(
602 &config_path,
603 "[measurement.\"test\"]\nepoch = \"12345678\"\n",
604 )
605 .unwrap();
606
607 let found_path = find_config_path();
609 assert!(found_path.is_some());
610 assert_eq!(found_path.unwrap(), config_path);
611 });
612 }
613
614 #[test]
615 fn test_find_config_path_not_found() {
616 with_isolated_home(|temp_dir| {
617 env::set_current_dir(temp_dir).unwrap();
619
620 init_git_repo(temp_dir);
622
623 let found_path = find_config_path();
625 assert!(found_path.is_none());
626 });
627 }
628
629 #[test]
630 fn test_hierarchical_config_workspace_overrides_home() {
631 with_isolated_home(|temp_dir| {
632 env::set_current_dir(temp_dir).unwrap();
634
635 init_git_repo(temp_dir);
637
638 let home_config_path = create_home_config_dir(temp_dir);
640 fs::write(
641 &home_config_path,
642 r#"
643[measurement."test"]
644backoff_max_elapsed_seconds = 30
645audit_min_relative_deviation = 1.0
646"#,
647 )
648 .unwrap();
649
650 let workspace_config_path = temp_dir.join(".gitperfconfig");
652 fs::write(
653 &workspace_config_path,
654 r#"
655[measurement."test"]
656backoff_max_elapsed_seconds = 60
657"#,
658 )
659 .unwrap();
660
661 env::set_var("HOME", temp_dir);
663 env::remove_var("XDG_CONFIG_HOME");
664
665 let config = read_hierarchical_config().unwrap();
667
668 let backoff: i32 = config
670 .get("measurement.test.backoff_max_elapsed_seconds")
671 .unwrap();
672 assert_eq!(backoff, 60);
673
674 let deviation: f64 = config
676 .get("measurement.test.audit_min_relative_deviation")
677 .unwrap();
678 assert_eq!(deviation, 1.0);
679 });
680 }
681
682 #[test]
683 fn test_determine_epoch_from_config_with_missing_file() {
684 let temp_dir = TempDir::new().unwrap();
686 fs::create_dir_all(temp_dir.path()).unwrap();
687 env::set_current_dir(temp_dir.path()).unwrap();
688
689 let epoch = determine_epoch_from_config("test_measurement");
690 assert!(epoch.is_none());
691 }
692
693 #[test]
694 fn test_determine_epoch_from_config_with_invalid_toml() {
695 let temp_dir = TempDir::new().unwrap();
696 let config_path = temp_dir.path().join(".gitperfconfig");
697 fs::write(&config_path, "invalid toml content").unwrap();
698
699 fs::create_dir_all(temp_dir.path()).unwrap();
700 env::set_current_dir(temp_dir.path()).unwrap();
701
702 let epoch = determine_epoch_from_config("test_measurement");
703 assert!(epoch.is_none());
704 }
705
706 #[test]
707 fn test_write_config_creates_file() {
708 with_isolated_home(|temp_dir| {
709 env::set_current_dir(temp_dir).unwrap();
711 init_git_repo(temp_dir);
712
713 let subdir = temp_dir.join("a").join("b").join("c");
715 fs::create_dir_all(&subdir).unwrap();
716 env::set_current_dir(&subdir).unwrap();
717
718 let config_content = "[measurement.\"test\"]\nepoch = \"12345678\"\n";
719 write_config(config_content).unwrap();
720
721 let repo_config_path = temp_dir.join(".gitperfconfig");
723 let subdir_config_path = subdir.join(".gitperfconfig");
724
725 assert!(repo_config_path.is_file());
726 assert!(!subdir_config_path.is_file());
727
728 let content = fs::read_to_string(&repo_config_path).unwrap();
729 assert_eq!(content, config_content);
730 });
731 }
732
733 #[test]
734 fn test_hierarchical_config_system_override() {
735 with_isolated_home(|temp_dir| {
736 let system_config_path = create_home_config_dir(temp_dir);
738 let system_config = r#"
739[measurement]
740min_relative_deviation = 5.0
741dispersion_method = "mad"
742
743[backoff]
744max_elapsed_seconds = 120
745"#;
746 fs::write(&system_config_path, system_config).unwrap();
747
748 env::set_current_dir(temp_dir).unwrap();
750 init_git_repo(temp_dir);
751
752 let workspace_config_path = temp_dir.join(".gitperfconfig");
754 let local_config = r#"
755[measurement]
756min_relative_deviation = 10.0
757
758[measurement."build_time"]
759min_relative_deviation = 15.0
760dispersion_method = "stddev"
761"#;
762 fs::write(&workspace_config_path, local_config).unwrap();
763
764 let config = read_hierarchical_config().unwrap();
766
767 use super::ConfigParentFallbackExt;
769 assert_eq!(
770 config
771 .get_with_parent_fallback(
772 "measurement",
773 "any_measurement",
774 "min_relative_deviation"
775 )
776 .unwrap()
777 .parse::<f64>()
778 .unwrap(),
779 10.0
780 );
781 assert_eq!(
782 config
783 .get_with_parent_fallback("measurement", "any_measurement", "dispersion_method")
784 .unwrap(),
785 "mad"
786 ); assert_eq!(
790 config
791 .get_float("measurement.build_time.min_relative_deviation")
792 .unwrap(),
793 15.0
794 );
795 assert_eq!(
796 config
797 .get_string("measurement.build_time.dispersion_method")
798 .unwrap(),
799 "stddev"
800 );
801
802 assert_eq!(config.get_int("backoff.max_elapsed_seconds").unwrap(), 120);
804
805 assert_eq!(audit_min_relative_deviation("build_time"), Some(15.0));
807 assert_eq!(
808 audit_min_relative_deviation("other_measurement"),
809 Some(10.0)
810 );
811 assert_eq!(
812 audit_dispersion_method("build_time"),
813 git_perf_cli_types::DispersionMethod::StandardDeviation
814 );
815 assert_eq!(
816 audit_dispersion_method("other_measurement"),
817 git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
818 );
819 assert_eq!(backoff_max_elapsed_seconds(), 120);
820 });
821 }
822
823 #[test]
824 fn test_read_config_from_file_missing_file() {
825 let temp_dir = TempDir::new().unwrap();
826 let nonexistent_file = temp_dir.path().join("does_not_exist.toml");
827
828 let result = read_config_from_file(&nonexistent_file);
830 assert!(result.is_err());
831 }
832
833 #[test]
834 fn test_read_config_from_file_valid_content() {
835 let temp_dir = TempDir::new().unwrap();
836 let config_file = temp_dir.path().join("test_config.toml");
837 let expected_content = "[measurement]\nepoch = \"12345678\"\n";
838
839 fs::write(&config_file, expected_content).unwrap();
840
841 let result = read_config_from_file(&config_file);
842 assert!(result.is_ok());
843 let content = result.unwrap();
844 assert_eq!(content, expected_content);
845
846 assert!(!content.is_empty());
848 }
849}