git_perf/
config.rs

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
13// Import the CLI types for dispersion method
14use git_perf_cli_types::DispersionMethod;
15
16/// Extension trait to get values with parent table fallback.
17///
18/// This provides a consistent way to retrieve a value for a given logical name
19/// and fall back to the parent table when the specific name is not present.
20pub trait ConfigParentFallbackExt {
21    /// Returns a string value for `{parent}.{name}.{key}` if available.
22    /// Otherwise falls back to `{parent}.{key}` (parent table defaults).
23    ///
24    /// The `parent` is the parent table name (e.g., "measurement").
25    /// The `name` is the specific identifier within that parent.
26    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        // Try specific measurement first: parent.name.key
32        let specific_key = format!("{}.{}.{}", parent, name, key);
33        if let Ok(v) = self.get_string(&specific_key) {
34            return Some(v);
35        }
36
37        // Fallback to parent table: parent.key
38        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
47/// Get the main repository config path (always in repo root)
48fn get_main_config_path() -> Result<PathBuf> {
49    // Use git to find the repository root
50    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
66/// Write config to the main repository directory (always in repo root)
67pub 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
74/// Read hierarchical configuration (system -> local override)
75pub fn read_hierarchical_config() -> Result<Config, ConfigError> {
76    let mut builder = Config::builder();
77
78    // 1. System-wide config (XDG_CONFIG_HOME or ~/.config/git-perf/config.toml)
79    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    // 2. Local config (repository .gitperfconfig) - this overrides system config
98    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    // Use get_main_config_path but handle errors gracefully
111    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 the error but don't fail - this is expected when no config exists
129            log::debug!("Could not read hierarchical config: {}", e);
130        })
131        .ok()?;
132
133    // Use parent fallback for measurement epoch
134    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    // Ensure that non-inline tables are written in an empty config file
147    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    // Read existing config from the main config path
166    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
174/// Returns the backoff max elapsed seconds from config, or 60 if not set.
175pub 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 // Default value
182            }
183        }
184        Err(_) => 60, // Default value when no config exists
185    }
186}
187
188/// Returns the minimum relative deviation threshold from config, or None if not set.
189pub 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
203/// Returns the dispersion method from config, or StandardDeviation if not set.
204pub 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    // Default to StandardDeviation
218    DispersionMethod::StandardDeviation
219}
220
221/// Returns the minimum measurements from config, or None if not set.
222pub fn audit_min_measurements(measurement: &str) -> Option<u16> {
223    let config = read_hierarchical_config().ok()?;
224
225    if let Some(s) = config.get_with_parent_fallback("measurement", measurement, "min_measurements")
226    {
227        if let Ok(v) = s.parse::<u16>() {
228            return Some(v);
229        }
230    }
231
232    None
233}
234
235/// Returns the aggregate-by reduction function from config, or None if not set.
236pub fn audit_aggregate_by(measurement: &str) -> Option<git_perf_cli_types::ReductionFunc> {
237    let config = read_hierarchical_config().ok()?;
238
239    let s = config.get_with_parent_fallback("measurement", measurement, "aggregate_by")?;
240
241    // Parse the string to ReductionFunc
242    match s.to_lowercase().as_str() {
243        "min" => Some(git_perf_cli_types::ReductionFunc::Min),
244        "max" => Some(git_perf_cli_types::ReductionFunc::Max),
245        "median" => Some(git_perf_cli_types::ReductionFunc::Median),
246        "mean" => Some(git_perf_cli_types::ReductionFunc::Mean),
247        _ => None,
248    }
249}
250
251/// Returns the sigma value from config, or None if not set.
252pub fn audit_sigma(measurement: &str) -> Option<f64> {
253    let config = read_hierarchical_config().ok()?;
254
255    if let Some(s) = config.get_with_parent_fallback("measurement", measurement, "sigma") {
256        if let Ok(v) = s.parse::<f64>() {
257            return Some(v);
258        }
259    }
260
261    None
262}
263
264/// Returns the configured unit for a measurement, or None if not set.
265pub fn measurement_unit(measurement: &str) -> Option<String> {
266    let config = read_hierarchical_config().ok()?;
267    config.get_with_parent_fallback("measurement", measurement, "unit")
268}
269
270#[cfg(test)]
271mod test {
272    use super::*;
273    use crate::test_helpers::{
274        hermetic_git_env, init_repo, init_repo_with_file, with_isolated_home,
275    };
276    use std::fs;
277    use tempfile::TempDir;
278
279    /// Create a HOME config directory structure and return the config path
280    fn create_home_config_dir(home_dir: &Path) -> PathBuf {
281        let config_dir = home_dir.join(".config").join("git-perf");
282        fs::create_dir_all(&config_dir).unwrap();
283        config_dir.join("config.toml")
284    }
285
286    #[test]
287    fn test_read_epochs() {
288        with_isolated_home(|temp_dir| {
289            // Create a git repository
290            env::set_current_dir(temp_dir).unwrap();
291            init_repo(temp_dir);
292
293            // Create workspace config with epochs
294            let workspace_config_path = temp_dir.join(".gitperfconfig");
295            let configfile = r#"[measurement]
296# General performance regression
297epoch="12344555"
298
299[measurement."something"]
300#My comment
301epoch="34567898"
302
303[measurement."somethingelse"]
304epoch="a3dead"
305"#;
306            fs::write(&workspace_config_path, configfile).unwrap();
307
308            let epoch = determine_epoch_from_config("something");
309            assert_eq!(epoch, Some(0x34567898));
310
311            let epoch = determine_epoch_from_config("somethingelse");
312            assert_eq!(epoch, Some(0xa3dead));
313
314            let epoch = determine_epoch_from_config("unspecified");
315            assert_eq!(epoch, Some(0x12344555));
316        });
317    }
318
319    #[test]
320    fn test_bump_epochs() {
321        with_isolated_home(|temp_dir| {
322            // Create a temporary git repository for this test
323            env::set_current_dir(temp_dir).unwrap();
324
325            // Set up hermetic git environment
326            hermetic_git_env();
327
328            // Initialize git repository with initial commit
329            init_repo_with_file(temp_dir);
330
331            let configfile = r#"[measurement."something"]
332#My comment
333epoch = "34567898"
334"#;
335
336            let mut actual = String::from(configfile);
337            bump_epoch_in_conf("something", &mut actual).expect("Failed to bump epoch");
338
339            let expected = format!(
340                r#"[measurement."something"]
341#My comment
342epoch = "{}"
343"#,
344                &get_head_revision().expect("get_head_revision failed")[0..8],
345            );
346
347            assert_eq!(actual, expected);
348        });
349    }
350
351    #[test]
352    fn test_bump_new_epoch_and_read_it() {
353        with_isolated_home(|temp_dir| {
354            // Create a temporary git repository for this test
355            env::set_current_dir(temp_dir).unwrap();
356
357            // Set up hermetic git environment
358            hermetic_git_env();
359
360            // Initialize git repository with initial commit
361            init_repo_with_file(temp_dir);
362
363            let mut conf = String::new();
364            bump_epoch_in_conf("mymeasurement", &mut conf).expect("Failed to bump epoch");
365
366            // Write the config to a file and test reading it
367            let config_path = temp_dir.join(".gitperfconfig");
368            fs::write(&config_path, &conf).unwrap();
369
370            let epoch = determine_epoch_from_config("mymeasurement");
371            assert!(epoch.is_some());
372        });
373    }
374
375    #[test]
376    fn test_backoff_max_elapsed_seconds() {
377        with_isolated_home(|temp_dir| {
378            // Create git repository
379            env::set_current_dir(temp_dir).unwrap();
380            init_repo(temp_dir);
381
382            // Create workspace config with explicit value
383            let workspace_config_path = temp_dir.join(".gitperfconfig");
384            let local_config = "[backoff]\nmax_elapsed_seconds = 42\n";
385            fs::write(&workspace_config_path, local_config).unwrap();
386
387            // Test with explicit value
388            assert_eq!(super::backoff_max_elapsed_seconds(), 42);
389
390            // Remove config file and test default
391            fs::remove_file(&workspace_config_path).unwrap();
392            assert_eq!(super::backoff_max_elapsed_seconds(), 60);
393        });
394    }
395
396    #[test]
397    fn test_audit_min_relative_deviation() {
398        with_isolated_home(|temp_dir| {
399            // Create git repository
400            env::set_current_dir(temp_dir).unwrap();
401            init_repo(temp_dir);
402
403            // Create workspace config with measurement-specific settings
404            let workspace_config_path = temp_dir.join(".gitperfconfig");
405            let local_config = r#"
406[measurement]
407min_relative_deviation = 5.0
408
409[measurement."build_time"]
410min_relative_deviation = 10.0
411
412[measurement."memory_usage"]
413min_relative_deviation = 2.5
414"#;
415            fs::write(&workspace_config_path, local_config).unwrap();
416
417            // Test measurement-specific settings
418            assert_eq!(
419                super::audit_min_relative_deviation("build_time"),
420                Some(10.0)
421            );
422            assert_eq!(
423                super::audit_min_relative_deviation("memory_usage"),
424                Some(2.5)
425            );
426            assert_eq!(
427                super::audit_min_relative_deviation("other_measurement"),
428                Some(5.0) // Now falls back to parent table
429            );
430
431            // Test global (now parent table) setting
432            let global_config = r#"
433[measurement]
434min_relative_deviation = 5.0
435"#;
436            fs::write(&workspace_config_path, global_config).unwrap();
437            assert_eq!(
438                super::audit_min_relative_deviation("any_measurement"),
439                Some(5.0)
440            );
441
442            // Test precedence - measurement-specific overrides global
443            let precedence_config = r#"
444[measurement]
445min_relative_deviation = 5.0
446
447[measurement."build_time"]
448min_relative_deviation = 10.0
449"#;
450            fs::write(&workspace_config_path, precedence_config).unwrap();
451            assert_eq!(
452                super::audit_min_relative_deviation("build_time"),
453                Some(10.0)
454            );
455            assert_eq!(
456                super::audit_min_relative_deviation("other_measurement"),
457                Some(5.0)
458            );
459
460            // Test no config
461            fs::remove_file(&workspace_config_path).unwrap();
462            assert_eq!(super::audit_min_relative_deviation("any_measurement"), None);
463        });
464    }
465
466    #[test]
467    fn test_audit_dispersion_method() {
468        with_isolated_home(|temp_dir| {
469            // Create git repository
470            env::set_current_dir(temp_dir).unwrap();
471            init_repo(temp_dir);
472
473            // Create workspace config with measurement-specific settings
474            let workspace_config_path = temp_dir.join(".gitperfconfig");
475            let local_config = r#"
476[measurement]
477dispersion_method = "stddev"
478
479[measurement."build_time"]
480dispersion_method = "mad"
481
482[measurement."memory_usage"]
483dispersion_method = "stddev"
484"#;
485            fs::write(&workspace_config_path, local_config).unwrap();
486
487            // Test measurement-specific settings
488            assert_eq!(
489                super::audit_dispersion_method("build_time"),
490                git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
491            );
492            assert_eq!(
493                super::audit_dispersion_method("memory_usage"),
494                git_perf_cli_types::DispersionMethod::StandardDeviation
495            );
496            assert_eq!(
497                super::audit_dispersion_method("other_measurement"),
498                git_perf_cli_types::DispersionMethod::StandardDeviation
499            );
500
501            // Test global (now parent table) setting
502            let global_config = r#"
503[measurement]
504dispersion_method = "mad"
505"#;
506            fs::write(&workspace_config_path, global_config).unwrap();
507            assert_eq!(
508                super::audit_dispersion_method("any_measurement"),
509                git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
510            );
511
512            // Test precedence - measurement-specific overrides global
513            let precedence_config = r#"
514[measurement]
515dispersion_method = "mad"
516
517[measurement."build_time"]
518dispersion_method = "stddev"
519"#;
520            fs::write(&workspace_config_path, precedence_config).unwrap();
521            assert_eq!(
522                super::audit_dispersion_method("build_time"),
523                git_perf_cli_types::DispersionMethod::StandardDeviation
524            );
525            assert_eq!(
526                super::audit_dispersion_method("other_measurement"),
527                git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
528            );
529
530            // Test no config (should return StandardDeviation)
531            fs::remove_file(&workspace_config_path).unwrap();
532            assert_eq!(
533                super::audit_dispersion_method("any_measurement"),
534                git_perf_cli_types::DispersionMethod::StandardDeviation
535            );
536        });
537    }
538
539    #[test]
540    fn test_bump_epoch_in_conf_creates_proper_tables() {
541        // We need to test the production bump_epoch_in_conf function, but it calls get_head_revision()
542        // which requires a git repo. Let's temporarily modify the environment to make it work.
543        with_isolated_home(|temp_dir| {
544            env::set_current_dir(temp_dir).unwrap();
545
546            // Set up minimal git environment
547            hermetic_git_env();
548
549            init_repo_with_file(temp_dir);
550
551            // Test case 1: Empty config string should create proper table structure
552            let mut empty_config = String::new();
553
554            // This calls the actual production function!
555            bump_epoch_in_conf("mymeasurement", &mut empty_config).unwrap();
556
557            // Verify that proper table structure is created (not inline tables)
558            assert!(empty_config.contains("[measurement]"));
559            assert!(empty_config.contains("[measurement.mymeasurement]"));
560            assert!(empty_config.contains("epoch ="));
561            // Ensure it's NOT using inline table syntax
562            assert!(!empty_config.contains("measurement = {"));
563            assert!(!empty_config.contains("mymeasurement = {"));
564
565            // Test case 2: Existing config should preserve structure and add new measurement
566            let mut existing_config = r#"[measurement]
567existing_setting = "value"
568
569[measurement."other"]
570epoch = "oldvalue"
571"#
572            .to_string();
573
574            bump_epoch_in_conf("newmeasurement", &mut existing_config).unwrap();
575
576            // Verify it maintains existing structure and adds new measurement with proper table format
577            assert!(existing_config.contains("[measurement.newmeasurement]"));
578            assert!(existing_config.contains("existing_setting = \"value\""));
579            assert!(existing_config.contains("[measurement.\"other\"]"));
580            assert!(!existing_config.contains("newmeasurement = {"));
581        });
582    }
583
584    #[test]
585    fn test_find_config_path_in_git_root() {
586        with_isolated_home(|temp_dir| {
587            // Create a git repository
588            env::set_current_dir(temp_dir).unwrap();
589
590            // Initialize git repository
591            init_repo(temp_dir);
592
593            // Create config in git root
594            let config_path = temp_dir.join(".gitperfconfig");
595            fs::write(
596                &config_path,
597                "[measurement.\"test\"]\nepoch = \"12345678\"\n",
598            )
599            .unwrap();
600
601            // Test that find_config_path finds it
602            let found_path = find_config_path();
603            assert!(found_path.is_some());
604            assert_eq!(found_path.unwrap(), config_path);
605        });
606    }
607
608    #[test]
609    fn test_find_config_path_not_found() {
610        with_isolated_home(|temp_dir| {
611            // Create a git repository but no .gitperfconfig
612            env::set_current_dir(temp_dir).unwrap();
613
614            // Initialize git repository
615            init_repo(temp_dir);
616
617            // Test that find_config_path returns None when no .gitperfconfig exists
618            let found_path = find_config_path();
619            assert!(found_path.is_none());
620        });
621    }
622
623    #[test]
624    fn test_hierarchical_config_workspace_overrides_home() {
625        with_isolated_home(|temp_dir| {
626            // Create a git repository
627            env::set_current_dir(temp_dir).unwrap();
628
629            // Initialize git repository
630            init_repo(temp_dir);
631
632            // Create home config
633            let home_config_path = create_home_config_dir(temp_dir);
634            fs::write(
635                &home_config_path,
636                r#"
637[measurement."test"]
638backoff_max_elapsed_seconds = 30
639audit_min_relative_deviation = 1.0
640"#,
641            )
642            .unwrap();
643
644            // Create workspace config that overrides some values
645            let workspace_config_path = temp_dir.join(".gitperfconfig");
646            fs::write(
647                &workspace_config_path,
648                r#"
649[measurement."test"]
650backoff_max_elapsed_seconds = 60
651"#,
652            )
653            .unwrap();
654
655            // Set HOME to our temp directory
656            env::set_var("HOME", temp_dir);
657            env::remove_var("XDG_CONFIG_HOME");
658
659            // Read hierarchical config and verify workspace overrides home
660            let config = read_hierarchical_config().unwrap();
661
662            // backoff_max_elapsed_seconds should be overridden by workspace config
663            let backoff: i32 = config
664                .get("measurement.test.backoff_max_elapsed_seconds")
665                .unwrap();
666            assert_eq!(backoff, 60);
667
668            // audit_min_relative_deviation should come from home config
669            let deviation: f64 = config
670                .get("measurement.test.audit_min_relative_deviation")
671                .unwrap();
672            assert_eq!(deviation, 1.0);
673        });
674    }
675
676    #[test]
677    fn test_determine_epoch_from_config_with_missing_file() {
678        // Test that missing config file doesn't panic and returns None
679        let temp_dir = TempDir::new().unwrap();
680        fs::create_dir_all(temp_dir.path()).unwrap();
681        env::set_current_dir(temp_dir.path()).unwrap();
682
683        let epoch = determine_epoch_from_config("test_measurement");
684        assert!(epoch.is_none());
685    }
686
687    #[test]
688    fn test_determine_epoch_from_config_with_invalid_toml() {
689        let temp_dir = TempDir::new().unwrap();
690        let config_path = temp_dir.path().join(".gitperfconfig");
691        fs::write(&config_path, "invalid toml content").unwrap();
692
693        fs::create_dir_all(temp_dir.path()).unwrap();
694        env::set_current_dir(temp_dir.path()).unwrap();
695
696        let epoch = determine_epoch_from_config("test_measurement");
697        assert!(epoch.is_none());
698    }
699
700    #[test]
701    fn test_write_config_creates_file() {
702        with_isolated_home(|temp_dir| {
703            // Create git repository
704            env::set_current_dir(temp_dir).unwrap();
705            init_repo(temp_dir);
706
707            // Create a subdirectory to test that config is written to repo root
708            let subdir = temp_dir.join("a").join("b").join("c");
709            fs::create_dir_all(&subdir).unwrap();
710            env::set_current_dir(&subdir).unwrap();
711
712            let config_content = "[measurement.\"test\"]\nepoch = \"12345678\"\n";
713            write_config(config_content).unwrap();
714
715            // Config should be written to repo root, not subdirectory
716            let repo_config_path = temp_dir.join(".gitperfconfig");
717            let subdir_config_path = subdir.join(".gitperfconfig");
718
719            assert!(repo_config_path.is_file());
720            assert!(!subdir_config_path.is_file());
721
722            let content = fs::read_to_string(&repo_config_path).unwrap();
723            assert_eq!(content, config_content);
724        });
725    }
726
727    #[test]
728    fn test_hierarchical_config_system_override() {
729        with_isolated_home(|temp_dir| {
730            // Create system config (home directory config)
731            let system_config_path = create_home_config_dir(temp_dir);
732            let system_config = r#"
733[measurement]
734min_relative_deviation = 5.0
735dispersion_method = "mad"
736
737[backoff]
738max_elapsed_seconds = 120
739"#;
740            fs::write(&system_config_path, system_config).unwrap();
741
742            // Create git repository
743            env::set_current_dir(temp_dir).unwrap();
744            init_repo(temp_dir);
745
746            // Create workspace config that overrides system config
747            let workspace_config_path = temp_dir.join(".gitperfconfig");
748            let local_config = r#"
749[measurement]
750min_relative_deviation = 10.0
751
752[measurement."build_time"]
753min_relative_deviation = 15.0
754dispersion_method = "stddev"
755"#;
756            fs::write(&workspace_config_path, local_config).unwrap();
757
758            // Test hierarchical config reading
759            let config = read_hierarchical_config().unwrap();
760
761            // Test that local parent table overrides system config via helper
762            use super::ConfigParentFallbackExt;
763            assert_eq!(
764                config
765                    .get_with_parent_fallback(
766                        "measurement",
767                        "any_measurement",
768                        "min_relative_deviation"
769                    )
770                    .unwrap()
771                    .parse::<f64>()
772                    .unwrap(),
773                10.0
774            );
775            assert_eq!(
776                config
777                    .get_with_parent_fallback("measurement", "any_measurement", "dispersion_method")
778                    .unwrap(),
779                "mad"
780            ); // Not overridden in local for parent fallback
781
782            // Test measurement-specific override
783            assert_eq!(
784                config
785                    .get_float("measurement.build_time.min_relative_deviation")
786                    .unwrap(),
787                15.0
788            );
789            assert_eq!(
790                config
791                    .get_string("measurement.build_time.dispersion_method")
792                    .unwrap(),
793                "stddev"
794            );
795
796            // Test that system config is still available for non-overridden values
797            assert_eq!(config.get_int("backoff.max_elapsed_seconds").unwrap(), 120);
798
799            // Test the convenience functions
800            assert_eq!(audit_min_relative_deviation("build_time"), Some(15.0));
801            assert_eq!(
802                audit_min_relative_deviation("other_measurement"),
803                Some(10.0)
804            );
805            assert_eq!(
806                audit_dispersion_method("build_time"),
807                git_perf_cli_types::DispersionMethod::StandardDeviation
808            );
809            assert_eq!(
810                audit_dispersion_method("other_measurement"),
811                git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
812            );
813            assert_eq!(backoff_max_elapsed_seconds(), 120);
814        });
815    }
816
817    #[test]
818    fn test_read_config_from_file_missing_file() {
819        let temp_dir = TempDir::new().unwrap();
820        let nonexistent_file = temp_dir.path().join("does_not_exist.toml");
821
822        // Should return error, not Ok(String::new())
823        let result = read_config_from_file(&nonexistent_file);
824        assert!(result.is_err());
825    }
826
827    #[test]
828    fn test_read_config_from_file_valid_content() {
829        let temp_dir = TempDir::new().unwrap();
830        let config_file = temp_dir.path().join("test_config.toml");
831        let expected_content = "[measurement]\nepoch = \"12345678\"\n";
832
833        fs::write(&config_file, expected_content).unwrap();
834
835        let result = read_config_from_file(&config_file);
836        assert!(result.is_ok());
837        let content = result.unwrap();
838        assert_eq!(content, expected_content);
839
840        // This would catch the mutant that returns Ok(String::new())
841        assert!(!content.is_empty());
842    }
843
844    #[test]
845    fn test_audit_min_measurements() {
846        with_isolated_home(|temp_dir| {
847            // Create git repository
848            env::set_current_dir(temp_dir).unwrap();
849            init_repo(temp_dir);
850
851            // Create workspace config with measurement-specific settings
852            let workspace_config_path = temp_dir.join(".gitperfconfig");
853            let local_config = r#"
854[measurement]
855min_measurements = 5
856
857[measurement."build_time"]
858min_measurements = 10
859
860[measurement."memory_usage"]
861min_measurements = 3
862"#;
863            fs::write(&workspace_config_path, local_config).unwrap();
864
865            // Test measurement-specific settings
866            assert_eq!(super::audit_min_measurements("build_time"), Some(10));
867            assert_eq!(super::audit_min_measurements("memory_usage"), Some(3));
868            assert_eq!(super::audit_min_measurements("other_measurement"), Some(5));
869
870            // Test no config
871            fs::remove_file(&workspace_config_path).unwrap();
872            assert_eq!(super::audit_min_measurements("any_measurement"), None);
873        });
874    }
875
876    #[test]
877    fn test_audit_aggregate_by() {
878        with_isolated_home(|temp_dir| {
879            // Create git repository
880            env::set_current_dir(temp_dir).unwrap();
881            init_repo(temp_dir);
882
883            // Create workspace config with measurement-specific settings
884            let workspace_config_path = temp_dir.join(".gitperfconfig");
885            let local_config = r#"
886[measurement]
887aggregate_by = "median"
888
889[measurement."build_time"]
890aggregate_by = "max"
891
892[measurement."memory_usage"]
893aggregate_by = "mean"
894"#;
895            fs::write(&workspace_config_path, local_config).unwrap();
896
897            // Test measurement-specific settings
898            assert_eq!(
899                super::audit_aggregate_by("build_time"),
900                Some(git_perf_cli_types::ReductionFunc::Max)
901            );
902            assert_eq!(
903                super::audit_aggregate_by("memory_usage"),
904                Some(git_perf_cli_types::ReductionFunc::Mean)
905            );
906            assert_eq!(
907                super::audit_aggregate_by("other_measurement"),
908                Some(git_perf_cli_types::ReductionFunc::Median)
909            );
910
911            // Test no config
912            fs::remove_file(&workspace_config_path).unwrap();
913            assert_eq!(super::audit_aggregate_by("any_measurement"), None);
914        });
915    }
916
917    #[test]
918    fn test_audit_sigma() {
919        with_isolated_home(|temp_dir| {
920            // Create git repository
921            env::set_current_dir(temp_dir).unwrap();
922            init_repo(temp_dir);
923
924            // Create workspace config with measurement-specific settings
925            let workspace_config_path = temp_dir.join(".gitperfconfig");
926            let local_config = r#"
927[measurement]
928sigma = 3.0
929
930[measurement."build_time"]
931sigma = 5.5
932
933[measurement."memory_usage"]
934sigma = 2.0
935"#;
936            fs::write(&workspace_config_path, local_config).unwrap();
937
938            // Test measurement-specific settings
939            assert_eq!(super::audit_sigma("build_time"), Some(5.5));
940            assert_eq!(super::audit_sigma("memory_usage"), Some(2.0));
941            assert_eq!(super::audit_sigma("other_measurement"), Some(3.0));
942
943            // Test no config
944            fs::remove_file(&workspace_config_path).unwrap();
945            assert_eq!(super::audit_sigma("any_measurement"), None);
946        });
947    }
948
949    #[test]
950    fn test_measurement_unit() {
951        with_isolated_home(|temp_dir| {
952            // Create git repository
953            env::set_current_dir(temp_dir).unwrap();
954            init_repo(temp_dir);
955
956            // Create workspace config with measurement-specific units
957            let workspace_config_path = temp_dir.join(".gitperfconfig");
958            let local_config = r#"
959[measurement]
960unit = "ms"
961
962[measurement."build_time"]
963unit = "ms"
964
965[measurement."memory_usage"]
966unit = "bytes"
967
968[measurement."throughput"]
969unit = "requests/sec"
970"#;
971            fs::write(&workspace_config_path, local_config).unwrap();
972
973            // Test measurement-specific settings
974            assert_eq!(
975                super::measurement_unit("build_time"),
976                Some("ms".to_string())
977            );
978            assert_eq!(
979                super::measurement_unit("memory_usage"),
980                Some("bytes".to_string())
981            );
982            assert_eq!(
983                super::measurement_unit("throughput"),
984                Some("requests/sec".to_string())
985            );
986
987            // Test fallback to parent table default
988            assert_eq!(
989                super::measurement_unit("other_measurement"),
990                Some("ms".to_string())
991            );
992
993            // Test no config
994            fs::remove_file(&workspace_config_path).unwrap();
995            assert_eq!(super::measurement_unit("any_measurement"), None);
996        });
997    }
998
999    #[test]
1000    fn test_measurement_unit_precedence() {
1001        with_isolated_home(|temp_dir| {
1002            // Create git repository
1003            env::set_current_dir(temp_dir).unwrap();
1004            init_repo(temp_dir);
1005
1006            // Create workspace config testing precedence
1007            let workspace_config_path = temp_dir.join(".gitperfconfig");
1008            let precedence_config = r#"
1009[measurement]
1010unit = "ms"
1011
1012[measurement."build_time"]
1013unit = "seconds"
1014"#;
1015            fs::write(&workspace_config_path, precedence_config).unwrap();
1016
1017            // Measurement-specific should override parent default
1018            assert_eq!(
1019                super::measurement_unit("build_time"),
1020                Some("seconds".to_string())
1021            );
1022
1023            // Other measurements should use parent default
1024            assert_eq!(
1025                super::measurement_unit("other_measurement"),
1026                Some("ms".to_string())
1027            );
1028        });
1029    }
1030
1031    #[test]
1032    fn test_measurement_unit_no_parent_default() {
1033        with_isolated_home(|temp_dir| {
1034            // Create git repository
1035            env::set_current_dir(temp_dir).unwrap();
1036            init_repo(temp_dir);
1037
1038            // Create workspace config with only measurement-specific units (no parent default)
1039            let workspace_config_path = temp_dir.join(".gitperfconfig");
1040            let local_config = r#"
1041[measurement."build_time"]
1042unit = "ms"
1043
1044[measurement."memory_usage"]
1045unit = "bytes"
1046"#;
1047            fs::write(&workspace_config_path, local_config).unwrap();
1048
1049            // Test measurement-specific settings
1050            assert_eq!(
1051                super::measurement_unit("build_time"),
1052                Some("ms".to_string())
1053            );
1054            assert_eq!(
1055                super::measurement_unit("memory_usage"),
1056                Some("bytes".to_string())
1057            );
1058
1059            // Test measurement without unit (no parent default either)
1060            assert_eq!(super::measurement_unit("other_measurement"), None);
1061        });
1062    }
1063}