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, DocumentMut, Item, Table};
10
11use crate::defaults;
12use crate::git::git_interop::{get_head_revision, get_repository_root};
13
14// Import the CLI types for dispersion method
15use git_perf_cli_types::DispersionMethod;
16
17/// Extension trait to get values with parent table fallback.
18///
19/// This provides a consistent way to retrieve a value for a given logical name
20/// and fall back to the parent table when the specific name is not present.
21pub trait ConfigParentFallbackExt {
22    /// Returns a string value for `{parent}.{name}.{key}` if available.
23    /// Otherwise falls back to `{parent}.{key}` (parent table defaults).
24    ///
25    /// The `parent` is the parent table name (e.g., "measurement").
26    /// The `name` is the specific identifier within that parent.
27    fn get_with_parent_fallback(&self, parent: &str, name: &str, key: &str) -> Option<String>;
28}
29
30impl ConfigParentFallbackExt for Config {
31    fn get_with_parent_fallback(&self, parent: &str, name: &str, key: &str) -> Option<String> {
32        // Try specific measurement first: parent.name.key
33        let specific_key = format!("{}.{}.{}", parent, name, key);
34        if let Ok(v) = self.get_string(&specific_key) {
35            return Some(v);
36        }
37
38        // Fallback to parent table: parent.key
39        let parent_key = format!("{}.{}", parent, key);
40        if let Ok(v) = self.get_string(&parent_key) {
41            return Some(v);
42        }
43
44        None
45    }
46}
47
48/// Get the main repository config path (always in repo root)
49fn get_main_config_path() -> Result<PathBuf> {
50    // Use git to find the repository root
51    let repo_root = get_repository_root().map_err(|e| {
52        anyhow::anyhow!(
53            "Failed to determine repository root - must be run from within a git repository: {}",
54            e
55        )
56    })?;
57
58    if repo_root.is_empty() {
59        return Err(anyhow::anyhow!(
60            "Repository root is empty - must be run from within a git repository"
61        ));
62    }
63
64    Ok(PathBuf::from(repo_root).join(".gitperfconfig"))
65}
66
67/// Write config to the main repository directory (always in repo root)
68pub fn write_config(conf: &str) -> Result<()> {
69    let path = get_main_config_path()?;
70    let mut f = StdFile::create(path)?;
71    f.write_all(conf.as_bytes())?;
72    Ok(())
73}
74
75/// Read hierarchical configuration (system -> local override)
76pub fn read_hierarchical_config() -> Result<Config, ConfigError> {
77    let mut builder = Config::builder();
78
79    // 1. System-wide config (XDG_CONFIG_HOME or ~/.config/git-perf/config.toml)
80    if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") {
81        let system_config_path = Path::new(&xdg_config_home)
82            .join("git-perf")
83            .join("config.toml");
84        builder = builder.add_source(
85            File::from(system_config_path)
86                .format(FileFormat::Toml)
87                .required(false),
88        );
89    } else if let Some(home) = dirs_next::home_dir() {
90        let system_config_path = home.join(".config").join("git-perf").join("config.toml");
91        builder = builder.add_source(
92            File::from(system_config_path)
93                .format(FileFormat::Toml)
94                .required(false),
95        );
96    }
97
98    // 2. Local config (repository .gitperfconfig) - this overrides system config
99    if let Some(local_path) = find_config_path() {
100        builder = builder.add_source(
101            File::from(local_path)
102                .format(FileFormat::Toml)
103                .required(false),
104        );
105    }
106
107    builder.build()
108}
109
110fn find_config_path() -> Option<PathBuf> {
111    // Use get_main_config_path but handle errors gracefully
112    let path = get_main_config_path().ok()?;
113    if path.is_file() {
114        Some(path)
115    } else {
116        None
117    }
118}
119
120fn read_config_from_file<P: AsRef<Path>>(file: P) -> Result<String> {
121    let mut conf_str = String::new();
122    StdFile::open(file)?.read_to_string(&mut conf_str)?;
123    Ok(conf_str)
124}
125
126#[must_use]
127pub fn determine_epoch_from_config(measurement: &str) -> Option<u32> {
128    let config = read_hierarchical_config()
129        .map_err(|e| {
130            // Log the error but don't fail - this is expected when no config exists
131            log::debug!("Could not read hierarchical config: {}", e);
132        })
133        .ok()?;
134
135    // Use parent fallback for measurement epoch
136    config
137        .get_with_parent_fallback("measurement", measurement, "epoch")
138        .and_then(|s| u32::from_str_radix(&s, 16).ok())
139}
140
141pub fn bump_epoch_in_conf(measurement: &str, conf_str: &mut String) -> Result<()> {
142    let mut conf = conf_str
143        .parse::<DocumentMut>()
144        .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?;
145
146    let head_revision = get_head_revision()?;
147
148    // Ensure that non-inline tables are written in an empty config file
149    if !conf.contains_key("measurement") {
150        conf["measurement"] = Item::Table(Table::new());
151    }
152    if !conf["measurement"]
153        .as_table()
154        .unwrap()
155        .contains_key(measurement)
156    {
157        conf["measurement"][measurement] = Item::Table(Table::new());
158    }
159
160    conf["measurement"][measurement]["epoch"] = value(&head_revision[0..8]);
161    *conf_str = conf.to_string();
162
163    Ok(())
164}
165
166pub fn bump_epoch(measurement: &str) -> Result<()> {
167    // Read existing config from the main config path
168    let config_path = get_main_config_path()?;
169    let mut conf_str = read_config_from_file(&config_path).unwrap_or_default();
170
171    bump_epoch_in_conf(measurement, &mut conf_str)?;
172    write_config(&conf_str)?;
173    Ok(())
174}
175
176/// Returns the backoff max elapsed seconds from config, or the default if not set.
177#[must_use]
178pub fn backoff_max_elapsed_seconds() -> u64 {
179    match read_hierarchical_config() {
180        Ok(config) => {
181            if let Ok(seconds) = config.get_int("backoff.max_elapsed_seconds") {
182                seconds as u64
183            } else {
184                defaults::DEFAULT_BACKOFF_MAX_ELAPSED_SECONDS
185            }
186        }
187        Err(_) => defaults::DEFAULT_BACKOFF_MAX_ELAPSED_SECONDS,
188    }
189}
190
191/// Returns the minimum relative deviation threshold from config, or None if not set.
192#[must_use]
193pub fn audit_min_relative_deviation(measurement: &str) -> Option<f64> {
194    let config = read_hierarchical_config().ok()?;
195
196    if let Some(s) =
197        config.get_with_parent_fallback("measurement", measurement, "min_relative_deviation")
198    {
199        if let Ok(v) = s.parse::<f64>() {
200            return Some(v);
201        }
202    }
203
204    None
205}
206
207/// Returns the dispersion method from config, or StandardDeviation if not set.
208#[must_use]
209pub fn audit_dispersion_method(measurement: &str) -> DispersionMethod {
210    let Some(config) = read_hierarchical_config().ok() else {
211        return DispersionMethod::StandardDeviation;
212    };
213
214    if let Some(s) =
215        config.get_with_parent_fallback("measurement", measurement, "dispersion_method")
216    {
217        if let Ok(method) = s.parse::<DispersionMethod>() {
218            return method;
219        }
220    }
221
222    DispersionMethod::StandardDeviation
223}
224
225/// Returns the minimum measurements from config, or None if not set.
226#[must_use]
227pub fn audit_min_measurements(measurement: &str) -> Option<u16> {
228    let config = read_hierarchical_config().ok()?;
229
230    if let Some(s) = config.get_with_parent_fallback("measurement", measurement, "min_measurements")
231    {
232        if let Ok(v) = s.parse::<u16>() {
233            return Some(v);
234        }
235    }
236
237    None
238}
239
240/// Returns the aggregate-by reduction function from config, or None if not set.
241#[must_use]
242pub fn audit_aggregate_by(measurement: &str) -> Option<git_perf_cli_types::ReductionFunc> {
243    let config = read_hierarchical_config().ok()?;
244
245    let s = config.get_with_parent_fallback("measurement", measurement, "aggregate_by")?;
246
247    // Parse the string to ReductionFunc
248    match s.to_lowercase().as_str() {
249        "min" => Some(git_perf_cli_types::ReductionFunc::Min),
250        "max" => Some(git_perf_cli_types::ReductionFunc::Max),
251        "median" => Some(git_perf_cli_types::ReductionFunc::Median),
252        "mean" => Some(git_perf_cli_types::ReductionFunc::Mean),
253        _ => None,
254    }
255}
256
257/// Returns the sigma value from config, or None if not set.
258#[must_use]
259pub fn audit_sigma(measurement: &str) -> Option<f64> {
260    let config = read_hierarchical_config().ok()?;
261
262    if let Some(s) = config.get_with_parent_fallback("measurement", measurement, "sigma") {
263        if let Ok(v) = s.parse::<f64>() {
264            return Some(v);
265        }
266    }
267
268    None
269}
270
271/// Returns the configured unit for a measurement, or None if not set.
272#[must_use]
273pub fn measurement_unit(measurement: &str) -> Option<String> {
274    let config = read_hierarchical_config().ok()?;
275    config.get_with_parent_fallback("measurement", measurement, "unit")
276}
277
278/// Returns the report template path from config, or None if not set.
279#[must_use]
280pub fn report_template_path() -> Option<PathBuf> {
281    let config = read_hierarchical_config().ok()?;
282    let path_str = config.get_string("report.template_path").ok()?;
283    Some(PathBuf::from(path_str))
284}
285
286/// Returns the report custom CSS path from config, or None if not set.
287#[must_use]
288pub fn report_custom_css_path() -> Option<PathBuf> {
289    let config = read_hierarchical_config().ok()?;
290    let path_str = config.get_string("report.custom_css_path").ok()?;
291    Some(PathBuf::from(path_str))
292}
293
294/// Returns the report title from config, or None if not set.
295#[must_use]
296pub fn report_title() -> Option<String> {
297    let config = read_hierarchical_config().ok()?;
298    config.get_string("report.title").ok()
299}
300
301/// Returns the change point configuration for a measurement, applying fallback rules.
302///
303/// Configuration keys under `[change_point]` or `[change_point."measurement_name"]`:
304/// - `enabled`: Enable/disable change point detection (default: true)
305/// - `min_data_points`: Minimum data points required (default: 10)
306/// - `min_magnitude_pct`: Minimum percentage change to consider significant (default: 5.0)
307/// - `penalty`: Penalty factor for PELT algorithm (default: 0.5, lower = more sensitive)
308#[must_use]
309pub fn change_point_config(measurement: &str) -> crate::change_point::ChangePointConfig {
310    let mut config = crate::change_point::ChangePointConfig::default();
311
312    let Ok(file_config) = read_hierarchical_config() else {
313        return config;
314    };
315
316    // Check if change point detection is disabled globally or per-measurement
317    if let Some(enabled_str) =
318        file_config.get_with_parent_fallback("change_point", measurement, "enabled")
319    {
320        if let Ok(enabled) = enabled_str.parse::<bool>() {
321            if !enabled {
322                // If disabled, return a config that will skip detection
323                config.min_data_points = usize::MAX;
324                return config;
325            }
326        }
327    }
328
329    // min_data_points
330    if let Some(s) =
331        file_config.get_with_parent_fallback("change_point", measurement, "min_data_points")
332    {
333        if let Ok(v) = s.parse::<usize>() {
334            config.min_data_points = v;
335        }
336    }
337
338    // min_magnitude_pct
339    if let Some(s) =
340        file_config.get_with_parent_fallback("change_point", measurement, "min_magnitude_pct")
341    {
342        if let Ok(v) = s.parse::<f64>() {
343            config.min_magnitude_pct = v;
344        }
345    }
346
347    // penalty
348    if let Some(s) = file_config.get_with_parent_fallback("change_point", measurement, "penalty") {
349        if let Ok(v) = s.parse::<f64>() {
350            config.penalty = v;
351        }
352    }
353
354    config
355}
356
357#[cfg(test)]
358mod test {
359    use super::*;
360    use crate::test_helpers::{
361        hermetic_git_env, init_repo, init_repo_with_file, with_isolated_home,
362    };
363    use std::fs;
364    use tempfile::TempDir;
365
366    /// Create a HOME config directory structure and return the config path
367    fn create_home_config_dir(home_dir: &Path) -> PathBuf {
368        let config_dir = home_dir.join(".config").join("git-perf");
369        fs::create_dir_all(&config_dir).unwrap();
370        config_dir.join("config.toml")
371    }
372
373    #[test]
374    fn test_read_epochs() {
375        with_isolated_home(|temp_dir| {
376            // Create a git repository
377            env::set_current_dir(temp_dir).unwrap();
378            init_repo(temp_dir);
379
380            // Create workspace config with epochs
381            let workspace_config_path = temp_dir.join(".gitperfconfig");
382            let configfile = r#"[measurement]
383# General performance regression
384epoch="12344555"
385
386[measurement."something"]
387#My comment
388epoch="34567898"
389
390[measurement."somethingelse"]
391epoch="a3dead"
392"#;
393            fs::write(&workspace_config_path, configfile).unwrap();
394
395            let epoch = determine_epoch_from_config("something");
396            assert_eq!(epoch, Some(0x34567898));
397
398            let epoch = determine_epoch_from_config("somethingelse");
399            assert_eq!(epoch, Some(0xa3dead));
400
401            let epoch = determine_epoch_from_config("unspecified");
402            assert_eq!(epoch, Some(0x12344555));
403        });
404    }
405
406    #[test]
407    fn test_bump_epochs() {
408        with_isolated_home(|temp_dir| {
409            // Create a temporary git repository for this test
410            env::set_current_dir(temp_dir).unwrap();
411
412            // Set up hermetic git environment
413            hermetic_git_env();
414
415            // Initialize git repository with initial commit
416            init_repo_with_file(temp_dir);
417
418            let configfile = r#"[measurement."something"]
419#My comment
420epoch = "34567898"
421"#;
422
423            let mut actual = String::from(configfile);
424            bump_epoch_in_conf("something", &mut actual).expect("Failed to bump epoch");
425
426            let expected = format!(
427                r#"[measurement."something"]
428#My comment
429epoch = "{}"
430"#,
431                &get_head_revision().expect("get_head_revision failed")[0..8],
432            );
433
434            assert_eq!(actual, expected);
435        });
436    }
437
438    #[test]
439    fn test_bump_new_epoch_and_read_it() {
440        with_isolated_home(|temp_dir| {
441            // Create a temporary git repository for this test
442            env::set_current_dir(temp_dir).unwrap();
443
444            // Set up hermetic git environment
445            hermetic_git_env();
446
447            // Initialize git repository with initial commit
448            init_repo_with_file(temp_dir);
449
450            let mut conf = String::new();
451            bump_epoch_in_conf("mymeasurement", &mut conf).expect("Failed to bump epoch");
452
453            // Write the config to a file and test reading it
454            let config_path = temp_dir.join(".gitperfconfig");
455            fs::write(&config_path, &conf).unwrap();
456
457            let epoch = determine_epoch_from_config("mymeasurement");
458            assert!(epoch.is_some());
459        });
460    }
461
462    #[test]
463    fn test_backoff_max_elapsed_seconds() {
464        with_isolated_home(|temp_dir| {
465            // Create git repository
466            env::set_current_dir(temp_dir).unwrap();
467            init_repo(temp_dir);
468
469            // Create workspace config with explicit value
470            let workspace_config_path = temp_dir.join(".gitperfconfig");
471            let local_config = "[backoff]\nmax_elapsed_seconds = 42\n";
472            fs::write(&workspace_config_path, local_config).unwrap();
473
474            // Test with explicit value
475            assert_eq!(super::backoff_max_elapsed_seconds(), 42);
476
477            // Remove config file and test default
478            fs::remove_file(&workspace_config_path).unwrap();
479            assert_eq!(super::backoff_max_elapsed_seconds(), 60);
480        });
481    }
482
483    #[test]
484    fn test_audit_min_relative_deviation() {
485        with_isolated_home(|temp_dir| {
486            // Create git repository
487            env::set_current_dir(temp_dir).unwrap();
488            init_repo(temp_dir);
489
490            // Create workspace config with measurement-specific settings
491            let workspace_config_path = temp_dir.join(".gitperfconfig");
492            let local_config = r#"
493[measurement]
494min_relative_deviation = 5.0
495
496[measurement."build_time"]
497min_relative_deviation = 10.0
498
499[measurement."memory_usage"]
500min_relative_deviation = 2.5
501"#;
502            fs::write(&workspace_config_path, local_config).unwrap();
503
504            // Test measurement-specific settings
505            assert_eq!(
506                super::audit_min_relative_deviation("build_time"),
507                Some(10.0)
508            );
509            assert_eq!(
510                super::audit_min_relative_deviation("memory_usage"),
511                Some(2.5)
512            );
513            assert_eq!(
514                super::audit_min_relative_deviation("other_measurement"),
515                Some(5.0) // Now falls back to parent table
516            );
517
518            // Test global (now parent table) setting
519            let global_config = r#"
520[measurement]
521min_relative_deviation = 5.0
522"#;
523            fs::write(&workspace_config_path, global_config).unwrap();
524            assert_eq!(
525                super::audit_min_relative_deviation("any_measurement"),
526                Some(5.0)
527            );
528
529            // Test precedence - measurement-specific overrides global
530            let precedence_config = r#"
531[measurement]
532min_relative_deviation = 5.0
533
534[measurement."build_time"]
535min_relative_deviation = 10.0
536"#;
537            fs::write(&workspace_config_path, precedence_config).unwrap();
538            assert_eq!(
539                super::audit_min_relative_deviation("build_time"),
540                Some(10.0)
541            );
542            assert_eq!(
543                super::audit_min_relative_deviation("other_measurement"),
544                Some(5.0)
545            );
546
547            // Test no config
548            fs::remove_file(&workspace_config_path).unwrap();
549            assert_eq!(super::audit_min_relative_deviation("any_measurement"), None);
550        });
551    }
552
553    #[test]
554    fn test_audit_dispersion_method() {
555        with_isolated_home(|temp_dir| {
556            // Create git repository
557            env::set_current_dir(temp_dir).unwrap();
558            init_repo(temp_dir);
559
560            // Create workspace config with measurement-specific settings
561            let workspace_config_path = temp_dir.join(".gitperfconfig");
562            let local_config = r#"
563[measurement]
564dispersion_method = "stddev"
565
566[measurement."build_time"]
567dispersion_method = "mad"
568
569[measurement."memory_usage"]
570dispersion_method = "stddev"
571"#;
572            fs::write(&workspace_config_path, local_config).unwrap();
573
574            // Test measurement-specific settings
575            assert_eq!(
576                super::audit_dispersion_method("build_time"),
577                git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
578            );
579            assert_eq!(
580                super::audit_dispersion_method("memory_usage"),
581                git_perf_cli_types::DispersionMethod::StandardDeviation
582            );
583            assert_eq!(
584                super::audit_dispersion_method("other_measurement"),
585                git_perf_cli_types::DispersionMethod::StandardDeviation
586            );
587
588            // Test global (now parent table) setting
589            let global_config = r#"
590[measurement]
591dispersion_method = "mad"
592"#;
593            fs::write(&workspace_config_path, global_config).unwrap();
594            assert_eq!(
595                super::audit_dispersion_method("any_measurement"),
596                git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
597            );
598
599            // Test precedence - measurement-specific overrides global
600            let precedence_config = r#"
601[measurement]
602dispersion_method = "mad"
603
604[measurement."build_time"]
605dispersion_method = "stddev"
606"#;
607            fs::write(&workspace_config_path, precedence_config).unwrap();
608            assert_eq!(
609                super::audit_dispersion_method("build_time"),
610                git_perf_cli_types::DispersionMethod::StandardDeviation
611            );
612            assert_eq!(
613                super::audit_dispersion_method("other_measurement"),
614                git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
615            );
616
617            // Test no config (should return StandardDeviation)
618            fs::remove_file(&workspace_config_path).unwrap();
619            assert_eq!(
620                super::audit_dispersion_method("any_measurement"),
621                git_perf_cli_types::DispersionMethod::StandardDeviation
622            );
623        });
624    }
625
626    #[test]
627    fn test_bump_epoch_in_conf_creates_proper_tables() {
628        // We need to test the production bump_epoch_in_conf function, but it calls get_head_revision()
629        // which requires a git repo. Let's temporarily modify the environment to make it work.
630        with_isolated_home(|temp_dir| {
631            env::set_current_dir(temp_dir).unwrap();
632
633            // Set up minimal git environment
634            hermetic_git_env();
635
636            init_repo_with_file(temp_dir);
637
638            // Test case 1: Empty config string should create proper table structure
639            let mut empty_config = String::new();
640
641            // This calls the actual production function!
642            bump_epoch_in_conf("mymeasurement", &mut empty_config).unwrap();
643
644            // Verify that proper table structure is created (not inline tables)
645            assert!(empty_config.contains("[measurement]"));
646            assert!(empty_config.contains("[measurement.mymeasurement]"));
647            assert!(empty_config.contains("epoch ="));
648            // Ensure it's NOT using inline table syntax
649            assert!(!empty_config.contains("measurement = {"));
650            assert!(!empty_config.contains("mymeasurement = {"));
651
652            // Test case 2: Existing config should preserve structure and add new measurement
653            let mut existing_config = r#"[measurement]
654existing_setting = "value"
655
656[measurement."other"]
657epoch = "oldvalue"
658"#
659            .to_string();
660
661            bump_epoch_in_conf("newmeasurement", &mut existing_config).unwrap();
662
663            // Verify it maintains existing structure and adds new measurement with proper table format
664            assert!(existing_config.contains("[measurement.newmeasurement]"));
665            assert!(existing_config.contains("existing_setting = \"value\""));
666            assert!(existing_config.contains("[measurement.\"other\"]"));
667            assert!(!existing_config.contains("newmeasurement = {"));
668        });
669    }
670
671    #[test]
672    fn test_find_config_path_in_git_root() {
673        with_isolated_home(|temp_dir| {
674            // Create a git repository
675            env::set_current_dir(temp_dir).unwrap();
676
677            // Initialize git repository
678            init_repo(temp_dir);
679
680            // Create config in git root
681            let config_path = temp_dir.join(".gitperfconfig");
682            fs::write(
683                &config_path,
684                "[measurement.\"test\"]\nepoch = \"12345678\"\n",
685            )
686            .unwrap();
687
688            // Test that find_config_path finds it
689            let found_path = find_config_path();
690            assert!(found_path.is_some());
691            // Canonicalize both paths to handle symlinks (e.g., /var -> /private/var on macOS)
692            assert_eq!(
693                found_path.unwrap().canonicalize().unwrap(),
694                config_path.canonicalize().unwrap()
695            );
696        });
697    }
698
699    #[test]
700    fn test_find_config_path_not_found() {
701        with_isolated_home(|temp_dir| {
702            // Create a git repository but no .gitperfconfig
703            env::set_current_dir(temp_dir).unwrap();
704
705            // Initialize git repository
706            init_repo(temp_dir);
707
708            // Test that find_config_path returns None when no .gitperfconfig exists
709            let found_path = find_config_path();
710            assert!(found_path.is_none());
711        });
712    }
713
714    #[test]
715    fn test_hierarchical_config_workspace_overrides_home() {
716        with_isolated_home(|temp_dir| {
717            // Create a git repository
718            env::set_current_dir(temp_dir).unwrap();
719
720            // Initialize git repository
721            init_repo(temp_dir);
722
723            // Create home config
724            let home_config_path = create_home_config_dir(temp_dir);
725            fs::write(
726                &home_config_path,
727                r#"
728[measurement."test"]
729backoff_max_elapsed_seconds = 30
730audit_min_relative_deviation = 1.0
731"#,
732            )
733            .unwrap();
734
735            // Create workspace config that overrides some values
736            let workspace_config_path = temp_dir.join(".gitperfconfig");
737            fs::write(
738                &workspace_config_path,
739                r#"
740[measurement."test"]
741backoff_max_elapsed_seconds = 60
742"#,
743            )
744            .unwrap();
745
746            // Set HOME to our temp directory
747            env::set_var("HOME", temp_dir);
748            env::remove_var("XDG_CONFIG_HOME");
749
750            // Read hierarchical config and verify workspace overrides home
751            let config = read_hierarchical_config().unwrap();
752
753            // backoff_max_elapsed_seconds should be overridden by workspace config
754            let backoff: i32 = config
755                .get("measurement.test.backoff_max_elapsed_seconds")
756                .unwrap();
757            assert_eq!(backoff, 60);
758
759            // audit_min_relative_deviation should come from home config
760            let deviation: f64 = config
761                .get("measurement.test.audit_min_relative_deviation")
762                .unwrap();
763            assert_eq!(deviation, 1.0);
764        });
765    }
766
767    #[test]
768    fn test_determine_epoch_from_config_with_missing_file() {
769        // Test that missing config file doesn't panic and returns None
770        let temp_dir = TempDir::new().unwrap();
771        fs::create_dir_all(temp_dir.path()).unwrap();
772        env::set_current_dir(temp_dir.path()).unwrap();
773
774        let epoch = determine_epoch_from_config("test_measurement");
775        assert!(epoch.is_none());
776    }
777
778    #[test]
779    fn test_determine_epoch_from_config_with_invalid_toml() {
780        let temp_dir = TempDir::new().unwrap();
781        let config_path = temp_dir.path().join(".gitperfconfig");
782        fs::write(&config_path, "invalid toml content").unwrap();
783
784        fs::create_dir_all(temp_dir.path()).unwrap();
785        env::set_current_dir(temp_dir.path()).unwrap();
786
787        let epoch = determine_epoch_from_config("test_measurement");
788        assert!(epoch.is_none());
789    }
790
791    #[test]
792    fn test_write_config_creates_file() {
793        with_isolated_home(|temp_dir| {
794            // Create git repository
795            env::set_current_dir(temp_dir).unwrap();
796            init_repo(temp_dir);
797
798            // Create a subdirectory to test that config is written to repo root
799            let subdir = temp_dir.join("a").join("b").join("c");
800            fs::create_dir_all(&subdir).unwrap();
801            env::set_current_dir(&subdir).unwrap();
802
803            let config_content = "[measurement.\"test\"]\nepoch = \"12345678\"\n";
804            write_config(config_content).unwrap();
805
806            // Config should be written to repo root, not subdirectory
807            let repo_config_path = temp_dir.join(".gitperfconfig");
808            let subdir_config_path = subdir.join(".gitperfconfig");
809
810            assert!(repo_config_path.is_file());
811            assert!(!subdir_config_path.is_file());
812
813            let content = fs::read_to_string(&repo_config_path).unwrap();
814            assert_eq!(content, config_content);
815        });
816    }
817
818    #[test]
819    fn test_hierarchical_config_system_override() {
820        with_isolated_home(|temp_dir| {
821            // Create system config (home directory config)
822            let system_config_path = create_home_config_dir(temp_dir);
823            let system_config = r#"
824[measurement]
825min_relative_deviation = 5.0
826dispersion_method = "mad"
827
828[backoff]
829max_elapsed_seconds = 120
830"#;
831            fs::write(&system_config_path, system_config).unwrap();
832
833            // Create git repository
834            env::set_current_dir(temp_dir).unwrap();
835            init_repo(temp_dir);
836
837            // Create workspace config that overrides system config
838            let workspace_config_path = temp_dir.join(".gitperfconfig");
839            let local_config = r#"
840[measurement]
841min_relative_deviation = 10.0
842
843[measurement."build_time"]
844min_relative_deviation = 15.0
845dispersion_method = "stddev"
846"#;
847            fs::write(&workspace_config_path, local_config).unwrap();
848
849            // Test hierarchical config reading
850            let config = read_hierarchical_config().unwrap();
851
852            // Test that local parent table overrides system config via helper
853            use super::ConfigParentFallbackExt;
854            assert_eq!(
855                config
856                    .get_with_parent_fallback(
857                        "measurement",
858                        "any_measurement",
859                        "min_relative_deviation"
860                    )
861                    .unwrap()
862                    .parse::<f64>()
863                    .unwrap(),
864                10.0
865            );
866            assert_eq!(
867                config
868                    .get_with_parent_fallback("measurement", "any_measurement", "dispersion_method")
869                    .unwrap(),
870                "mad"
871            ); // Not overridden in local for parent fallback
872
873            // Test measurement-specific override
874            assert_eq!(
875                config
876                    .get_float("measurement.build_time.min_relative_deviation")
877                    .unwrap(),
878                15.0
879            );
880            assert_eq!(
881                config
882                    .get_string("measurement.build_time.dispersion_method")
883                    .unwrap(),
884                "stddev"
885            );
886
887            // Test that system config is still available for non-overridden values
888            assert_eq!(config.get_int("backoff.max_elapsed_seconds").unwrap(), 120);
889
890            // Test the convenience functions
891            assert_eq!(audit_min_relative_deviation("build_time"), Some(15.0));
892            assert_eq!(
893                audit_min_relative_deviation("other_measurement"),
894                Some(10.0)
895            );
896            assert_eq!(
897                audit_dispersion_method("build_time"),
898                git_perf_cli_types::DispersionMethod::StandardDeviation
899            );
900            assert_eq!(
901                audit_dispersion_method("other_measurement"),
902                git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
903            );
904            assert_eq!(backoff_max_elapsed_seconds(), 120);
905        });
906    }
907
908    #[test]
909    fn test_read_config_from_file_missing_file() {
910        let temp_dir = TempDir::new().unwrap();
911        let nonexistent_file = temp_dir.path().join("does_not_exist.toml");
912
913        // Should return error, not Ok(String::new())
914        let result = read_config_from_file(&nonexistent_file);
915        assert!(result.is_err());
916    }
917
918    #[test]
919    fn test_read_config_from_file_valid_content() {
920        let temp_dir = TempDir::new().unwrap();
921        let config_file = temp_dir.path().join("test_config.toml");
922        let expected_content = "[measurement]\nepoch = \"12345678\"\n";
923
924        fs::write(&config_file, expected_content).unwrap();
925
926        let result = read_config_from_file(&config_file);
927        assert!(result.is_ok());
928        let content = result.unwrap();
929        assert_eq!(content, expected_content);
930
931        // This would catch the mutant that returns Ok(String::new())
932        assert!(!content.is_empty());
933    }
934
935    #[test]
936    fn test_audit_min_measurements() {
937        with_isolated_home(|temp_dir| {
938            // Create git repository
939            env::set_current_dir(temp_dir).unwrap();
940            init_repo(temp_dir);
941
942            // Create workspace config with measurement-specific settings
943            let workspace_config_path = temp_dir.join(".gitperfconfig");
944            let local_config = r#"
945[measurement]
946min_measurements = 5
947
948[measurement."build_time"]
949min_measurements = 10
950
951[measurement."memory_usage"]
952min_measurements = 3
953"#;
954            fs::write(&workspace_config_path, local_config).unwrap();
955
956            // Test measurement-specific settings
957            assert_eq!(super::audit_min_measurements("build_time"), Some(10));
958            assert_eq!(super::audit_min_measurements("memory_usage"), Some(3));
959            assert_eq!(super::audit_min_measurements("other_measurement"), Some(5));
960
961            // Test no config
962            fs::remove_file(&workspace_config_path).unwrap();
963            assert_eq!(super::audit_min_measurements("any_measurement"), None);
964        });
965    }
966
967    #[test]
968    fn test_audit_aggregate_by() {
969        with_isolated_home(|temp_dir| {
970            // Create git repository
971            env::set_current_dir(temp_dir).unwrap();
972            init_repo(temp_dir);
973
974            // Create workspace config with measurement-specific settings
975            let workspace_config_path = temp_dir.join(".gitperfconfig");
976            let local_config = r#"
977[measurement]
978aggregate_by = "median"
979
980[measurement."build_time"]
981aggregate_by = "max"
982
983[measurement."memory_usage"]
984aggregate_by = "mean"
985"#;
986            fs::write(&workspace_config_path, local_config).unwrap();
987
988            // Test measurement-specific settings
989            assert_eq!(
990                super::audit_aggregate_by("build_time"),
991                Some(git_perf_cli_types::ReductionFunc::Max)
992            );
993            assert_eq!(
994                super::audit_aggregate_by("memory_usage"),
995                Some(git_perf_cli_types::ReductionFunc::Mean)
996            );
997            assert_eq!(
998                super::audit_aggregate_by("other_measurement"),
999                Some(git_perf_cli_types::ReductionFunc::Median)
1000            );
1001
1002            // Test no config
1003            fs::remove_file(&workspace_config_path).unwrap();
1004            assert_eq!(super::audit_aggregate_by("any_measurement"), None);
1005        });
1006    }
1007
1008    #[test]
1009    fn test_audit_sigma() {
1010        with_isolated_home(|temp_dir| {
1011            // Create git repository
1012            env::set_current_dir(temp_dir).unwrap();
1013            init_repo(temp_dir);
1014
1015            // Create workspace config with measurement-specific settings
1016            let workspace_config_path = temp_dir.join(".gitperfconfig");
1017            let local_config = r#"
1018[measurement]
1019sigma = 3.0
1020
1021[measurement."build_time"]
1022sigma = 5.5
1023
1024[measurement."memory_usage"]
1025sigma = 2.0
1026"#;
1027            fs::write(&workspace_config_path, local_config).unwrap();
1028
1029            // Test measurement-specific settings
1030            assert_eq!(super::audit_sigma("build_time"), Some(5.5));
1031            assert_eq!(super::audit_sigma("memory_usage"), Some(2.0));
1032            assert_eq!(super::audit_sigma("other_measurement"), Some(3.0));
1033
1034            // Test no config
1035            fs::remove_file(&workspace_config_path).unwrap();
1036            assert_eq!(super::audit_sigma("any_measurement"), None);
1037        });
1038    }
1039
1040    #[test]
1041    fn test_measurement_unit() {
1042        with_isolated_home(|temp_dir| {
1043            // Create git repository
1044            env::set_current_dir(temp_dir).unwrap();
1045            init_repo(temp_dir);
1046
1047            // Create workspace config with measurement-specific units
1048            let workspace_config_path = temp_dir.join(".gitperfconfig");
1049            let local_config = r#"
1050[measurement]
1051unit = "ms"
1052
1053[measurement."build_time"]
1054unit = "ms"
1055
1056[measurement."memory_usage"]
1057unit = "bytes"
1058
1059[measurement."throughput"]
1060unit = "requests/sec"
1061"#;
1062            fs::write(&workspace_config_path, local_config).unwrap();
1063
1064            // Test measurement-specific settings
1065            assert_eq!(
1066                super::measurement_unit("build_time"),
1067                Some("ms".to_string())
1068            );
1069            assert_eq!(
1070                super::measurement_unit("memory_usage"),
1071                Some("bytes".to_string())
1072            );
1073            assert_eq!(
1074                super::measurement_unit("throughput"),
1075                Some("requests/sec".to_string())
1076            );
1077
1078            // Test fallback to parent table default
1079            assert_eq!(
1080                super::measurement_unit("other_measurement"),
1081                Some("ms".to_string())
1082            );
1083
1084            // Test no config
1085            fs::remove_file(&workspace_config_path).unwrap();
1086            assert_eq!(super::measurement_unit("any_measurement"), None);
1087        });
1088    }
1089
1090    #[test]
1091    fn test_measurement_unit_precedence() {
1092        with_isolated_home(|temp_dir| {
1093            // Create git repository
1094            env::set_current_dir(temp_dir).unwrap();
1095            init_repo(temp_dir);
1096
1097            // Create workspace config testing precedence
1098            let workspace_config_path = temp_dir.join(".gitperfconfig");
1099            let precedence_config = r#"
1100[measurement]
1101unit = "ms"
1102
1103[measurement."build_time"]
1104unit = "seconds"
1105"#;
1106            fs::write(&workspace_config_path, precedence_config).unwrap();
1107
1108            // Measurement-specific should override parent default
1109            assert_eq!(
1110                super::measurement_unit("build_time"),
1111                Some("seconds".to_string())
1112            );
1113
1114            // Other measurements should use parent default
1115            assert_eq!(
1116                super::measurement_unit("other_measurement"),
1117                Some("ms".to_string())
1118            );
1119        });
1120    }
1121
1122    #[test]
1123    fn test_measurement_unit_no_parent_default() {
1124        with_isolated_home(|temp_dir| {
1125            // Create git repository
1126            env::set_current_dir(temp_dir).unwrap();
1127            init_repo(temp_dir);
1128
1129            // Create workspace config with only measurement-specific units (no parent default)
1130            let workspace_config_path = temp_dir.join(".gitperfconfig");
1131            let local_config = r#"
1132[measurement."build_time"]
1133unit = "ms"
1134
1135[measurement."memory_usage"]
1136unit = "bytes"
1137"#;
1138            fs::write(&workspace_config_path, local_config).unwrap();
1139
1140            // Test measurement-specific settings
1141            assert_eq!(
1142                super::measurement_unit("build_time"),
1143                Some("ms".to_string())
1144            );
1145            assert_eq!(
1146                super::measurement_unit("memory_usage"),
1147                Some("bytes".to_string())
1148            );
1149
1150            // Test measurement without unit (no parent default either)
1151            assert_eq!(super::measurement_unit("other_measurement"), None);
1152        });
1153    }
1154}