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#[cfg(test)]
222mod test {
223    use super::*;
224    use std::fs;
225    use tempfile::TempDir;
226
227    /// Test helper to set up an independent HOME directory
228    /// This eliminates the need for #[serial] tests by ensuring each test
229    /// has its own isolated environment
230    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        // Set up isolated HOME directory
237        env::set_var("HOME", temp_dir.path());
238        env::remove_var("XDG_CONFIG_HOME");
239
240        f(temp_dir.path())
241    }
242
243    /// Initialize a git repository in the given directory
244    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    /// Initialize a git repository with an initial commit in the given directory
253    fn init_git_repo_with_commit(dir: &Path) {
254        init_git_repo(dir);
255
256        // Create a test file and commit it
257        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    /// Create a HOME config directory structure and return the config path
271    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            // Create a git repository
281            env::set_current_dir(temp_dir).unwrap();
282            init_git_repo(temp_dir);
283
284            // Create workspace config with epochs
285            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            // Create a temporary git repository for this test
314            env::set_current_dir(temp_dir).unwrap();
315
316            // Set up hermetic git environment
317            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            // Initialize git repository with initial commit
325            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            // Create a temporary git repository for this test
351            env::set_current_dir(temp_dir).unwrap();
352
353            // Set up hermetic git environment
354            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            // Initialize git repository with initial commit
362            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            // Write the config to a file and test reading it
368            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            // Create git repository
380            env::set_current_dir(temp_dir).unwrap();
381            init_git_repo(temp_dir);
382
383            // Create workspace config with explicit value
384            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            // Test with explicit value
389            assert_eq!(super::backoff_max_elapsed_seconds(), 42);
390
391            // Remove config file and test default
392            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            // Create git repository
401            env::set_current_dir(temp_dir).unwrap();
402            init_git_repo(temp_dir);
403
404            // Create workspace config with measurement-specific settings
405            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            // Test measurement-specific settings
419            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) // Now falls back to parent table
430            );
431
432            // Test global (now parent table) setting
433            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            // Test precedence - measurement-specific overrides global
444            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            // Test no config
462            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            // Create git repository
471            env::set_current_dir(temp_dir).unwrap();
472            init_git_repo(temp_dir);
473
474            // Create workspace config with measurement-specific settings
475            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            // Test measurement-specific settings
489            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            // Test global (now parent table) setting
503            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            // Test precedence - measurement-specific overrides global
514            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            // Test no config (should return StandardDeviation)
532            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        // We need to test the production bump_epoch_in_conf function, but it calls get_head_revision()
543        // which requires a git repo. Let's temporarily modify the environment to make it work.
544        with_isolated_home(|temp_dir| {
545            env::set_current_dir(temp_dir).unwrap();
546
547            // Set up minimal git environment
548            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            // Test case 1: Empty config string should create proper table structure
558            let mut empty_config = String::new();
559
560            // This calls the actual production function!
561            bump_epoch_in_conf("mymeasurement", &mut empty_config).unwrap();
562
563            // Verify that proper table structure is created (not inline tables)
564            assert!(empty_config.contains("[measurement]"));
565            assert!(empty_config.contains("[measurement.mymeasurement]"));
566            assert!(empty_config.contains("epoch ="));
567            // Ensure it's NOT using inline table syntax
568            assert!(!empty_config.contains("measurement = {"));
569            assert!(!empty_config.contains("mymeasurement = {"));
570
571            // Test case 2: Existing config should preserve structure and add new measurement
572            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            // Verify it maintains existing structure and adds new measurement with proper table format
583            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            // Create a git repository
594            env::set_current_dir(temp_dir).unwrap();
595
596            // Initialize git repository
597            init_git_repo(temp_dir);
598
599            // Create config in git root
600            let config_path = temp_dir.join(".gitperfconfig");
601            fs::write(
602                &config_path,
603                "[measurement.\"test\"]\nepoch = \"12345678\"\n",
604            )
605            .unwrap();
606
607            // Test that find_config_path finds it
608            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            // Create a git repository but no .gitperfconfig
618            env::set_current_dir(temp_dir).unwrap();
619
620            // Initialize git repository
621            init_git_repo(temp_dir);
622
623            // Test that find_config_path returns None when no .gitperfconfig exists
624            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            // Create a git repository
633            env::set_current_dir(temp_dir).unwrap();
634
635            // Initialize git repository
636            init_git_repo(temp_dir);
637
638            // Create home config
639            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            // Create workspace config that overrides some values
651            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            // Set HOME to our temp directory
662            env::set_var("HOME", temp_dir);
663            env::remove_var("XDG_CONFIG_HOME");
664
665            // Read hierarchical config and verify workspace overrides home
666            let config = read_hierarchical_config().unwrap();
667
668            // backoff_max_elapsed_seconds should be overridden by workspace config
669            let backoff: i32 = config
670                .get("measurement.test.backoff_max_elapsed_seconds")
671                .unwrap();
672            assert_eq!(backoff, 60);
673
674            // audit_min_relative_deviation should come from home config
675            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        // Test that missing config file doesn't panic and returns None
685        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            // Create git repository
710            env::set_current_dir(temp_dir).unwrap();
711            init_git_repo(temp_dir);
712
713            // Create a subdirectory to test that config is written to repo root
714            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            // Config should be written to repo root, not subdirectory
722            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            // Create system config (home directory config)
737            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            // Create git repository
749            env::set_current_dir(temp_dir).unwrap();
750            init_git_repo(temp_dir);
751
752            // Create workspace config that overrides system config
753            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            // Test hierarchical config reading
765            let config = read_hierarchical_config().unwrap();
766
767            // Test that local parent table overrides system config via helper
768            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            ); // Not overridden in local for parent fallback
787
788            // Test measurement-specific override
789            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            // Test that system config is still available for non-overridden values
803            assert_eq!(config.get_int("backoff.max_elapsed_seconds").unwrap(), 120);
804
805            // Test the convenience functions
806            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        // Should return error, not Ok(String::new())
829        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        // This would catch the mutant that returns Ok(String::new())
847        assert!(!content.is_empty());
848    }
849}