Skip to main content

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 minimum absolute deviation threshold from config, or None if not set.
208#[must_use]
209pub fn audit_min_absolute_deviation(measurement: &str) -> Option<f64> {
210    let config = read_hierarchical_config().ok()?;
211
212    if let Some(s) =
213        config.get_with_parent_fallback("measurement", measurement, "min_absolute_deviation")
214    {
215        if let Ok(v) = s.parse::<f64>() {
216            return Some(v);
217        }
218    }
219
220    None
221}
222
223/// Returns the dispersion method from config, or StandardDeviation if not set.
224#[must_use]
225pub fn audit_dispersion_method(measurement: &str) -> DispersionMethod {
226    let Some(config) = read_hierarchical_config().ok() else {
227        return DispersionMethod::StandardDeviation;
228    };
229
230    if let Some(s) =
231        config.get_with_parent_fallback("measurement", measurement, "dispersion_method")
232    {
233        if let Ok(method) = s.parse::<DispersionMethod>() {
234            return method;
235        }
236    }
237
238    DispersionMethod::StandardDeviation
239}
240
241/// Returns the minimum measurements from config, or None if not set.
242#[must_use]
243pub fn audit_min_measurements(measurement: &str) -> Option<u16> {
244    let config = read_hierarchical_config().ok()?;
245
246    if let Some(s) = config.get_with_parent_fallback("measurement", measurement, "min_measurements")
247    {
248        if let Ok(v) = s.parse::<u16>() {
249            return Some(v);
250        }
251    }
252
253    None
254}
255
256/// Returns the aggregate-by reduction function from config, or None if not set.
257#[must_use]
258pub fn audit_aggregate_by(measurement: &str) -> Option<git_perf_cli_types::ReductionFunc> {
259    let config = read_hierarchical_config().ok()?;
260
261    let s = config.get_with_parent_fallback("measurement", measurement, "aggregate_by")?;
262
263    // Parse the string to ReductionFunc
264    match s.to_lowercase().as_str() {
265        "min" => Some(git_perf_cli_types::ReductionFunc::Min),
266        "max" => Some(git_perf_cli_types::ReductionFunc::Max),
267        "median" => Some(git_perf_cli_types::ReductionFunc::Median),
268        "mean" => Some(git_perf_cli_types::ReductionFunc::Mean),
269        _ => None,
270    }
271}
272
273/// Returns the sigma value from config, or None if not set.
274#[must_use]
275pub fn audit_sigma(measurement: &str) -> Option<f64> {
276    let config = read_hierarchical_config().ok()?;
277
278    if let Some(s) = config.get_with_parent_fallback("measurement", measurement, "sigma") {
279        if let Ok(v) = s.parse::<f64>() {
280            return Some(v);
281        }
282    }
283
284    None
285}
286
287/// Returns the configured unit for a measurement, or None if not set.
288#[must_use]
289pub fn measurement_unit(measurement: &str) -> Option<String> {
290    let config = read_hierarchical_config().ok()?;
291    config.get_with_parent_fallback("measurement", measurement, "unit")
292}
293
294/// Returns the report template path from config, or None if not set.
295#[must_use]
296pub fn report_template_path() -> Option<PathBuf> {
297    let config = read_hierarchical_config().ok()?;
298    let path_str = config.get_string("report.template_path").ok()?;
299    Some(PathBuf::from(path_str))
300}
301
302/// Returns the report custom CSS path from config, or None if not set.
303#[must_use]
304pub fn report_custom_css_path() -> Option<PathBuf> {
305    let config = read_hierarchical_config().ok()?;
306    let path_str = config.get_string("report.custom_css_path").ok()?;
307    Some(PathBuf::from(path_str))
308}
309
310/// Returns the report title from config, or None if not set.
311#[must_use]
312pub fn report_title() -> Option<String> {
313    let config = read_hierarchical_config().ok()?;
314    config.get_string("report.title").ok()
315}
316
317/// Returns the change point configuration for a measurement, applying fallback rules.
318///
319/// Configuration keys under `[change_point]` or `[change_point."measurement_name"]`:
320/// - `enabled`: Enable/disable change point detection (default: true)
321/// - `min_data_points`: Minimum data points required (default: 10)
322/// - `min_magnitude_pct`: Minimum percentage change to consider significant (default: 5.0)
323/// - `confidence_threshold`: Minimum confidence to report a change point (0.0-1.0, default: 0.75)
324/// - `penalty`: Penalty factor for PELT algorithm (default: 0.5, lower = more sensitive)
325#[must_use]
326pub fn change_point_config(measurement: &str) -> crate::change_point::ChangePointConfig {
327    let mut config = crate::change_point::ChangePointConfig::default();
328
329    let Ok(file_config) = read_hierarchical_config() else {
330        return config;
331    };
332
333    // Check if change point detection is disabled globally or per-measurement
334    if let Some(enabled_str) =
335        file_config.get_with_parent_fallback("change_point", measurement, "enabled")
336    {
337        if let Ok(enabled) = enabled_str.parse::<bool>() {
338            config.enabled = enabled;
339        }
340    }
341
342    // min_data_points
343    if let Some(s) =
344        file_config.get_with_parent_fallback("change_point", measurement, "min_data_points")
345    {
346        if let Ok(v) = s.parse::<usize>() {
347            config.min_data_points = v;
348        }
349    }
350
351    // min_magnitude_pct
352    if let Some(s) =
353        file_config.get_with_parent_fallback("change_point", measurement, "min_magnitude_pct")
354    {
355        if let Ok(v) = s.parse::<f64>() {
356            config.min_magnitude_pct = v;
357        }
358    }
359
360    // confidence_threshold
361    if let Some(s) =
362        file_config.get_with_parent_fallback("change_point", measurement, "confidence_threshold")
363    {
364        if let Ok(v) = s.parse::<f64>() {
365            config.confidence_threshold = v;
366        }
367    }
368
369    // penalty
370    if let Some(s) = file_config.get_with_parent_fallback("change_point", measurement, "penalty") {
371        if let Ok(v) = s.parse::<f64>() {
372            config.penalty = v;
373        }
374    }
375
376    config
377}
378
379#[cfg(test)]
380mod test {
381    use super::*;
382    use crate::test_helpers::{
383        hermetic_git_env, init_repo, init_repo_with_file, with_isolated_home,
384    };
385    use std::fs;
386    use tempfile::TempDir;
387
388    /// Create a HOME config directory structure and return the config path
389    fn create_home_config_dir(home_dir: &Path) -> PathBuf {
390        let config_dir = home_dir.join(".config").join("git-perf");
391        fs::create_dir_all(&config_dir).unwrap();
392        config_dir.join("config.toml")
393    }
394
395    #[test]
396    fn test_read_epochs() {
397        with_isolated_home(|temp_dir| {
398            // Create a git repository
399            env::set_current_dir(temp_dir).unwrap();
400            init_repo(temp_dir);
401
402            // Create workspace config with epochs
403            let workspace_config_path = temp_dir.join(".gitperfconfig");
404            let configfile = r#"[measurement]
405# General performance regression
406epoch="12344555"
407
408[measurement."something"]
409#My comment
410epoch="34567898"
411
412[measurement."somethingelse"]
413epoch="a3dead"
414"#;
415            fs::write(&workspace_config_path, configfile).unwrap();
416
417            let epoch = determine_epoch_from_config("something");
418            assert_eq!(epoch, Some(0x34567898));
419
420            let epoch = determine_epoch_from_config("somethingelse");
421            assert_eq!(epoch, Some(0xa3dead));
422
423            let epoch = determine_epoch_from_config("unspecified");
424            assert_eq!(epoch, Some(0x12344555));
425        });
426    }
427
428    #[test]
429    fn test_bump_epochs() {
430        with_isolated_home(|temp_dir| {
431            // Create a temporary git repository for this test
432            env::set_current_dir(temp_dir).unwrap();
433
434            // Set up hermetic git environment
435            hermetic_git_env();
436
437            // Initialize git repository with initial commit
438            init_repo_with_file(temp_dir);
439
440            let configfile = r#"[measurement."something"]
441#My comment
442epoch = "34567898"
443"#;
444
445            let mut actual = String::from(configfile);
446            bump_epoch_in_conf("something", &mut actual).expect("Failed to bump epoch");
447
448            let expected = format!(
449                r#"[measurement."something"]
450#My comment
451epoch = "{}"
452"#,
453                &get_head_revision().expect("get_head_revision failed")[0..8],
454            );
455
456            assert_eq!(actual, expected);
457        });
458    }
459
460    #[test]
461    fn test_bump_new_epoch_and_read_it() {
462        with_isolated_home(|temp_dir| {
463            // Create a temporary git repository for this test
464            env::set_current_dir(temp_dir).unwrap();
465
466            // Set up hermetic git environment
467            hermetic_git_env();
468
469            // Initialize git repository with initial commit
470            init_repo_with_file(temp_dir);
471
472            let mut conf = String::new();
473            bump_epoch_in_conf("mymeasurement", &mut conf).expect("Failed to bump epoch");
474
475            // Write the config to a file and test reading it
476            let config_path = temp_dir.join(".gitperfconfig");
477            fs::write(&config_path, &conf).unwrap();
478
479            let epoch = determine_epoch_from_config("mymeasurement");
480            assert!(epoch.is_some());
481        });
482    }
483
484    #[test]
485    fn test_backoff_max_elapsed_seconds() {
486        with_isolated_home(|temp_dir| {
487            // Create git repository
488            env::set_current_dir(temp_dir).unwrap();
489            init_repo(temp_dir);
490
491            // Create workspace config with explicit value
492            let workspace_config_path = temp_dir.join(".gitperfconfig");
493            let local_config = "[backoff]\nmax_elapsed_seconds = 42\n";
494            fs::write(&workspace_config_path, local_config).unwrap();
495
496            // Test with explicit value
497            assert_eq!(super::backoff_max_elapsed_seconds(), 42);
498
499            // Remove config file and test default
500            fs::remove_file(&workspace_config_path).unwrap();
501            assert_eq!(super::backoff_max_elapsed_seconds(), 60);
502        });
503    }
504
505    #[test]
506    fn test_audit_min_relative_deviation() {
507        with_isolated_home(|temp_dir| {
508            // Create git repository
509            env::set_current_dir(temp_dir).unwrap();
510            init_repo(temp_dir);
511
512            // Create workspace config with measurement-specific settings
513            let workspace_config_path = temp_dir.join(".gitperfconfig");
514            let local_config = r#"
515[measurement]
516min_relative_deviation = 5.0
517
518[measurement."build_time"]
519min_relative_deviation = 10.0
520
521[measurement."memory_usage"]
522min_relative_deviation = 2.5
523"#;
524            fs::write(&workspace_config_path, local_config).unwrap();
525
526            // Test measurement-specific settings
527            assert_eq!(
528                super::audit_min_relative_deviation("build_time"),
529                Some(10.0)
530            );
531            assert_eq!(
532                super::audit_min_relative_deviation("memory_usage"),
533                Some(2.5)
534            );
535            assert_eq!(
536                super::audit_min_relative_deviation("other_measurement"),
537                Some(5.0) // Now falls back to parent table
538            );
539
540            // Test global (now parent table) setting
541            let global_config = r#"
542[measurement]
543min_relative_deviation = 5.0
544"#;
545            fs::write(&workspace_config_path, global_config).unwrap();
546            assert_eq!(
547                super::audit_min_relative_deviation("any_measurement"),
548                Some(5.0)
549            );
550
551            // Test precedence - measurement-specific overrides global
552            let precedence_config = r#"
553[measurement]
554min_relative_deviation = 5.0
555
556[measurement."build_time"]
557min_relative_deviation = 10.0
558"#;
559            fs::write(&workspace_config_path, precedence_config).unwrap();
560            assert_eq!(
561                super::audit_min_relative_deviation("build_time"),
562                Some(10.0)
563            );
564            assert_eq!(
565                super::audit_min_relative_deviation("other_measurement"),
566                Some(5.0)
567            );
568
569            // Test no config
570            fs::remove_file(&workspace_config_path).unwrap();
571            assert_eq!(super::audit_min_relative_deviation("any_measurement"), None);
572        });
573    }
574
575    #[test]
576    fn test_audit_min_absolute_deviation() {
577        with_isolated_home(|temp_dir| {
578            // Create git repository
579            env::set_current_dir(temp_dir).unwrap();
580            init_repo(temp_dir);
581
582            // Create workspace config with measurement-specific settings
583            let workspace_config_path = temp_dir.join(".gitperfconfig");
584            let local_config = r#"
585[measurement]
586min_absolute_deviation = 5.0
587
588[measurement."build_time"]
589min_absolute_deviation = 10.0
590
591[measurement."memory_usage"]
592min_absolute_deviation = 2.5
593"#;
594            fs::write(&workspace_config_path, local_config).unwrap();
595
596            // Test measurement-specific settings
597            assert_eq!(
598                super::audit_min_absolute_deviation("build_time"),
599                Some(10.0)
600            );
601            assert_eq!(
602                super::audit_min_absolute_deviation("memory_usage"),
603                Some(2.5)
604            );
605            assert_eq!(
606                super::audit_min_absolute_deviation("other_measurement"),
607                Some(5.0) // falls back to parent table
608            );
609
610            // Test global (parent table) setting
611            let global_config = r#"
612[measurement]
613min_absolute_deviation = 5.0
614"#;
615            fs::write(&workspace_config_path, global_config).unwrap();
616            assert_eq!(
617                super::audit_min_absolute_deviation("any_measurement"),
618                Some(5.0)
619            );
620
621            // Test precedence - measurement-specific overrides global
622            let precedence_config = r#"
623[measurement]
624min_absolute_deviation = 5.0
625
626[measurement."build_time"]
627min_absolute_deviation = 10.0
628"#;
629            fs::write(&workspace_config_path, precedence_config).unwrap();
630            assert_eq!(
631                super::audit_min_absolute_deviation("build_time"),
632                Some(10.0)
633            );
634            assert_eq!(
635                super::audit_min_absolute_deviation("other_measurement"),
636                Some(5.0)
637            );
638
639            // Test no config
640            fs::remove_file(&workspace_config_path).unwrap();
641            assert_eq!(super::audit_min_absolute_deviation("any_measurement"), None);
642        });
643    }
644
645    #[test]
646    fn test_audit_dispersion_method() {
647        with_isolated_home(|temp_dir| {
648            // Create git repository
649            env::set_current_dir(temp_dir).unwrap();
650            init_repo(temp_dir);
651
652            // Create workspace config with measurement-specific settings
653            let workspace_config_path = temp_dir.join(".gitperfconfig");
654            let local_config = r#"
655[measurement]
656dispersion_method = "stddev"
657
658[measurement."build_time"]
659dispersion_method = "mad"
660
661[measurement."memory_usage"]
662dispersion_method = "stddev"
663"#;
664            fs::write(&workspace_config_path, local_config).unwrap();
665
666            // Test measurement-specific settings
667            assert_eq!(
668                super::audit_dispersion_method("build_time"),
669                git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
670            );
671            assert_eq!(
672                super::audit_dispersion_method("memory_usage"),
673                git_perf_cli_types::DispersionMethod::StandardDeviation
674            );
675            assert_eq!(
676                super::audit_dispersion_method("other_measurement"),
677                git_perf_cli_types::DispersionMethod::StandardDeviation
678            );
679
680            // Test global (now parent table) setting
681            let global_config = r#"
682[measurement]
683dispersion_method = "mad"
684"#;
685            fs::write(&workspace_config_path, global_config).unwrap();
686            assert_eq!(
687                super::audit_dispersion_method("any_measurement"),
688                git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
689            );
690
691            // Test precedence - measurement-specific overrides global
692            let precedence_config = r#"
693[measurement]
694dispersion_method = "mad"
695
696[measurement."build_time"]
697dispersion_method = "stddev"
698"#;
699            fs::write(&workspace_config_path, precedence_config).unwrap();
700            assert_eq!(
701                super::audit_dispersion_method("build_time"),
702                git_perf_cli_types::DispersionMethod::StandardDeviation
703            );
704            assert_eq!(
705                super::audit_dispersion_method("other_measurement"),
706                git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
707            );
708
709            // Test no config (should return StandardDeviation)
710            fs::remove_file(&workspace_config_path).unwrap();
711            assert_eq!(
712                super::audit_dispersion_method("any_measurement"),
713                git_perf_cli_types::DispersionMethod::StandardDeviation
714            );
715        });
716    }
717
718    #[test]
719    fn test_bump_epoch_in_conf_creates_proper_tables() {
720        // We need to test the production bump_epoch_in_conf function, but it calls get_head_revision()
721        // which requires a git repo. Let's temporarily modify the environment to make it work.
722        with_isolated_home(|temp_dir| {
723            env::set_current_dir(temp_dir).unwrap();
724
725            // Set up minimal git environment
726            hermetic_git_env();
727
728            init_repo_with_file(temp_dir);
729
730            // Test case 1: Empty config string should create proper table structure
731            let mut empty_config = String::new();
732
733            // This calls the actual production function!
734            bump_epoch_in_conf("mymeasurement", &mut empty_config).unwrap();
735
736            // Verify that proper table structure is created (not inline tables)
737            assert!(empty_config.contains("[measurement]"));
738            assert!(empty_config.contains("[measurement.mymeasurement]"));
739            assert!(empty_config.contains("epoch ="));
740            // Ensure it's NOT using inline table syntax
741            assert!(!empty_config.contains("measurement = {"));
742            assert!(!empty_config.contains("mymeasurement = {"));
743
744            // Test case 2: Existing config should preserve structure and add new measurement
745            let mut existing_config = r#"[measurement]
746existing_setting = "value"
747
748[measurement."other"]
749epoch = "oldvalue"
750"#
751            .to_string();
752
753            bump_epoch_in_conf("newmeasurement", &mut existing_config).unwrap();
754
755            // Verify it maintains existing structure and adds new measurement with proper table format
756            assert!(existing_config.contains("[measurement.newmeasurement]"));
757            assert!(existing_config.contains("existing_setting = \"value\""));
758            assert!(existing_config.contains("[measurement.\"other\"]"));
759            assert!(!existing_config.contains("newmeasurement = {"));
760        });
761    }
762
763    #[test]
764    fn test_find_config_path_in_git_root() {
765        with_isolated_home(|temp_dir| {
766            // Create a git repository
767            env::set_current_dir(temp_dir).unwrap();
768
769            // Initialize git repository
770            init_repo(temp_dir);
771
772            // Create config in git root
773            let config_path = temp_dir.join(".gitperfconfig");
774            fs::write(
775                &config_path,
776                "[measurement.\"test\"]\nepoch = \"12345678\"\n",
777            )
778            .unwrap();
779
780            // Test that find_config_path finds it
781            let found_path = find_config_path();
782            assert!(found_path.is_some());
783            // Canonicalize both paths to handle symlinks (e.g., /var -> /private/var on macOS)
784            assert_eq!(
785                found_path.unwrap().canonicalize().unwrap(),
786                config_path.canonicalize().unwrap()
787            );
788        });
789    }
790
791    #[test]
792    fn test_find_config_path_not_found() {
793        with_isolated_home(|temp_dir| {
794            // Create a git repository but no .gitperfconfig
795            env::set_current_dir(temp_dir).unwrap();
796
797            // Initialize git repository
798            init_repo(temp_dir);
799
800            // Test that find_config_path returns None when no .gitperfconfig exists
801            let found_path = find_config_path();
802            assert!(found_path.is_none());
803        });
804    }
805
806    #[test]
807    fn test_hierarchical_config_workspace_overrides_home() {
808        with_isolated_home(|temp_dir| {
809            // Create a git repository
810            env::set_current_dir(temp_dir).unwrap();
811
812            // Initialize git repository
813            init_repo(temp_dir);
814
815            // Create home config
816            let home_config_path = create_home_config_dir(temp_dir);
817            fs::write(
818                &home_config_path,
819                r#"
820[measurement."test"]
821backoff_max_elapsed_seconds = 30
822audit_min_relative_deviation = 1.0
823"#,
824            )
825            .unwrap();
826
827            // Create workspace config that overrides some values
828            let workspace_config_path = temp_dir.join(".gitperfconfig");
829            fs::write(
830                &workspace_config_path,
831                r#"
832[measurement."test"]
833backoff_max_elapsed_seconds = 60
834"#,
835            )
836            .unwrap();
837
838            // Set HOME to our temp directory
839            env::set_var("HOME", temp_dir);
840            env::remove_var("XDG_CONFIG_HOME");
841
842            // Read hierarchical config and verify workspace overrides home
843            let config = read_hierarchical_config().unwrap();
844
845            // backoff_max_elapsed_seconds should be overridden by workspace config
846            let backoff: i32 = config
847                .get("measurement.test.backoff_max_elapsed_seconds")
848                .unwrap();
849            assert_eq!(backoff, 60);
850
851            // audit_min_relative_deviation should come from home config
852            let deviation: f64 = config
853                .get("measurement.test.audit_min_relative_deviation")
854                .unwrap();
855            assert_eq!(deviation, 1.0);
856        });
857    }
858
859    #[test]
860    fn test_determine_epoch_from_config_with_missing_file() {
861        // Test that missing config file doesn't panic and returns None
862        let temp_dir = TempDir::new().unwrap();
863        fs::create_dir_all(temp_dir.path()).unwrap();
864        env::set_current_dir(temp_dir.path()).unwrap();
865
866        let epoch = determine_epoch_from_config("test_measurement");
867        assert!(epoch.is_none());
868    }
869
870    #[test]
871    fn test_determine_epoch_from_config_with_invalid_toml() {
872        let temp_dir = TempDir::new().unwrap();
873        let config_path = temp_dir.path().join(".gitperfconfig");
874        fs::write(&config_path, "invalid toml content").unwrap();
875
876        fs::create_dir_all(temp_dir.path()).unwrap();
877        env::set_current_dir(temp_dir.path()).unwrap();
878
879        let epoch = determine_epoch_from_config("test_measurement");
880        assert!(epoch.is_none());
881    }
882
883    #[test]
884    fn test_write_config_creates_file() {
885        with_isolated_home(|temp_dir| {
886            // Create git repository
887            env::set_current_dir(temp_dir).unwrap();
888            init_repo(temp_dir);
889
890            // Create a subdirectory to test that config is written to repo root
891            let subdir = temp_dir.join("a").join("b").join("c");
892            fs::create_dir_all(&subdir).unwrap();
893            env::set_current_dir(&subdir).unwrap();
894
895            let config_content = "[measurement.\"test\"]\nepoch = \"12345678\"\n";
896            write_config(config_content).unwrap();
897
898            // Config should be written to repo root, not subdirectory
899            let repo_config_path = temp_dir.join(".gitperfconfig");
900            let subdir_config_path = subdir.join(".gitperfconfig");
901
902            assert!(repo_config_path.is_file());
903            assert!(!subdir_config_path.is_file());
904
905            let content = fs::read_to_string(&repo_config_path).unwrap();
906            assert_eq!(content, config_content);
907        });
908    }
909
910    #[test]
911    fn test_hierarchical_config_system_override() {
912        with_isolated_home(|temp_dir| {
913            // Create system config (home directory config)
914            let system_config_path = create_home_config_dir(temp_dir);
915            let system_config = r#"
916[measurement]
917min_relative_deviation = 5.0
918dispersion_method = "mad"
919
920[backoff]
921max_elapsed_seconds = 120
922"#;
923            fs::write(&system_config_path, system_config).unwrap();
924
925            // Create git repository
926            env::set_current_dir(temp_dir).unwrap();
927            init_repo(temp_dir);
928
929            // Create workspace config that overrides system config
930            let workspace_config_path = temp_dir.join(".gitperfconfig");
931            let local_config = r#"
932[measurement]
933min_relative_deviation = 10.0
934
935[measurement."build_time"]
936min_relative_deviation = 15.0
937dispersion_method = "stddev"
938"#;
939            fs::write(&workspace_config_path, local_config).unwrap();
940
941            // Test hierarchical config reading
942            let config = read_hierarchical_config().unwrap();
943
944            // Test that local parent table overrides system config via helper
945            use super::ConfigParentFallbackExt;
946            assert_eq!(
947                config
948                    .get_with_parent_fallback(
949                        "measurement",
950                        "any_measurement",
951                        "min_relative_deviation"
952                    )
953                    .unwrap()
954                    .parse::<f64>()
955                    .unwrap(),
956                10.0
957            );
958            assert_eq!(
959                config
960                    .get_with_parent_fallback("measurement", "any_measurement", "dispersion_method")
961                    .unwrap(),
962                "mad"
963            ); // Not overridden in local for parent fallback
964
965            // Test measurement-specific override
966            assert_eq!(
967                config
968                    .get_float("measurement.build_time.min_relative_deviation")
969                    .unwrap(),
970                15.0
971            );
972            assert_eq!(
973                config
974                    .get_string("measurement.build_time.dispersion_method")
975                    .unwrap(),
976                "stddev"
977            );
978
979            // Test that system config is still available for non-overridden values
980            assert_eq!(config.get_int("backoff.max_elapsed_seconds").unwrap(), 120);
981
982            // Test the convenience functions
983            assert_eq!(audit_min_relative_deviation("build_time"), Some(15.0));
984            assert_eq!(
985                audit_min_relative_deviation("other_measurement"),
986                Some(10.0)
987            );
988            assert_eq!(
989                audit_dispersion_method("build_time"),
990                git_perf_cli_types::DispersionMethod::StandardDeviation
991            );
992            assert_eq!(
993                audit_dispersion_method("other_measurement"),
994                git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
995            );
996            assert_eq!(backoff_max_elapsed_seconds(), 120);
997        });
998    }
999
1000    #[test]
1001    fn test_read_config_from_file_missing_file() {
1002        let temp_dir = TempDir::new().unwrap();
1003        let nonexistent_file = temp_dir.path().join("does_not_exist.toml");
1004
1005        // Should return error, not Ok(String::new())
1006        let result = read_config_from_file(&nonexistent_file);
1007        assert!(result.is_err());
1008    }
1009
1010    #[test]
1011    fn test_read_config_from_file_valid_content() {
1012        let temp_dir = TempDir::new().unwrap();
1013        let config_file = temp_dir.path().join("test_config.toml");
1014        let expected_content = "[measurement]\nepoch = \"12345678\"\n";
1015
1016        fs::write(&config_file, expected_content).unwrap();
1017
1018        let result = read_config_from_file(&config_file);
1019        assert!(result.is_ok());
1020        let content = result.unwrap();
1021        assert_eq!(content, expected_content);
1022
1023        // This would catch the mutant that returns Ok(String::new())
1024        assert!(!content.is_empty());
1025    }
1026
1027    #[test]
1028    fn test_audit_min_measurements() {
1029        with_isolated_home(|temp_dir| {
1030            // Create git repository
1031            env::set_current_dir(temp_dir).unwrap();
1032            init_repo(temp_dir);
1033
1034            // Create workspace config with measurement-specific settings
1035            let workspace_config_path = temp_dir.join(".gitperfconfig");
1036            let local_config = r#"
1037[measurement]
1038min_measurements = 5
1039
1040[measurement."build_time"]
1041min_measurements = 10
1042
1043[measurement."memory_usage"]
1044min_measurements = 3
1045"#;
1046            fs::write(&workspace_config_path, local_config).unwrap();
1047
1048            // Test measurement-specific settings
1049            assert_eq!(super::audit_min_measurements("build_time"), Some(10));
1050            assert_eq!(super::audit_min_measurements("memory_usage"), Some(3));
1051            assert_eq!(super::audit_min_measurements("other_measurement"), Some(5));
1052
1053            // Test no config
1054            fs::remove_file(&workspace_config_path).unwrap();
1055            assert_eq!(super::audit_min_measurements("any_measurement"), None);
1056        });
1057    }
1058
1059    #[test]
1060    fn test_audit_aggregate_by() {
1061        with_isolated_home(|temp_dir| {
1062            // Create git repository
1063            env::set_current_dir(temp_dir).unwrap();
1064            init_repo(temp_dir);
1065
1066            // Create workspace config with measurement-specific settings
1067            let workspace_config_path = temp_dir.join(".gitperfconfig");
1068            let local_config = r#"
1069[measurement]
1070aggregate_by = "median"
1071
1072[measurement."build_time"]
1073aggregate_by = "max"
1074
1075[measurement."memory_usage"]
1076aggregate_by = "mean"
1077"#;
1078            fs::write(&workspace_config_path, local_config).unwrap();
1079
1080            // Test measurement-specific settings
1081            assert_eq!(
1082                super::audit_aggregate_by("build_time"),
1083                Some(git_perf_cli_types::ReductionFunc::Max)
1084            );
1085            assert_eq!(
1086                super::audit_aggregate_by("memory_usage"),
1087                Some(git_perf_cli_types::ReductionFunc::Mean)
1088            );
1089            assert_eq!(
1090                super::audit_aggregate_by("other_measurement"),
1091                Some(git_perf_cli_types::ReductionFunc::Median)
1092            );
1093
1094            // Test no config
1095            fs::remove_file(&workspace_config_path).unwrap();
1096            assert_eq!(super::audit_aggregate_by("any_measurement"), None);
1097        });
1098    }
1099
1100    #[test]
1101    fn test_audit_sigma() {
1102        with_isolated_home(|temp_dir| {
1103            // Create git repository
1104            env::set_current_dir(temp_dir).unwrap();
1105            init_repo(temp_dir);
1106
1107            // Create workspace config with measurement-specific settings
1108            let workspace_config_path = temp_dir.join(".gitperfconfig");
1109            let local_config = r#"
1110[measurement]
1111sigma = 3.0
1112
1113[measurement."build_time"]
1114sigma = 5.5
1115
1116[measurement."memory_usage"]
1117sigma = 2.0
1118"#;
1119            fs::write(&workspace_config_path, local_config).unwrap();
1120
1121            // Test measurement-specific settings
1122            assert_eq!(super::audit_sigma("build_time"), Some(5.5));
1123            assert_eq!(super::audit_sigma("memory_usage"), Some(2.0));
1124            assert_eq!(super::audit_sigma("other_measurement"), Some(3.0));
1125
1126            // Test no config
1127            fs::remove_file(&workspace_config_path).unwrap();
1128            assert_eq!(super::audit_sigma("any_measurement"), None);
1129        });
1130    }
1131
1132    #[test]
1133    fn test_measurement_unit() {
1134        with_isolated_home(|temp_dir| {
1135            // Create git repository
1136            env::set_current_dir(temp_dir).unwrap();
1137            init_repo(temp_dir);
1138
1139            // Create workspace config with measurement-specific units
1140            let workspace_config_path = temp_dir.join(".gitperfconfig");
1141            let local_config = r#"
1142[measurement]
1143unit = "ms"
1144
1145[measurement."build_time"]
1146unit = "ms"
1147
1148[measurement."memory_usage"]
1149unit = "bytes"
1150
1151[measurement."throughput"]
1152unit = "requests/sec"
1153"#;
1154            fs::write(&workspace_config_path, local_config).unwrap();
1155
1156            // Test measurement-specific settings
1157            assert_eq!(
1158                super::measurement_unit("build_time"),
1159                Some("ms".to_string())
1160            );
1161            assert_eq!(
1162                super::measurement_unit("memory_usage"),
1163                Some("bytes".to_string())
1164            );
1165            assert_eq!(
1166                super::measurement_unit("throughput"),
1167                Some("requests/sec".to_string())
1168            );
1169
1170            // Test fallback to parent table default
1171            assert_eq!(
1172                super::measurement_unit("other_measurement"),
1173                Some("ms".to_string())
1174            );
1175
1176            // Test no config
1177            fs::remove_file(&workspace_config_path).unwrap();
1178            assert_eq!(super::measurement_unit("any_measurement"), None);
1179        });
1180    }
1181
1182    #[test]
1183    fn test_measurement_unit_precedence() {
1184        with_isolated_home(|temp_dir| {
1185            // Create git repository
1186            env::set_current_dir(temp_dir).unwrap();
1187            init_repo(temp_dir);
1188
1189            // Create workspace config testing precedence
1190            let workspace_config_path = temp_dir.join(".gitperfconfig");
1191            let precedence_config = r#"
1192[measurement]
1193unit = "ms"
1194
1195[measurement."build_time"]
1196unit = "seconds"
1197"#;
1198            fs::write(&workspace_config_path, precedence_config).unwrap();
1199
1200            // Measurement-specific should override parent default
1201            assert_eq!(
1202                super::measurement_unit("build_time"),
1203                Some("seconds".to_string())
1204            );
1205
1206            // Other measurements should use parent default
1207            assert_eq!(
1208                super::measurement_unit("other_measurement"),
1209                Some("ms".to_string())
1210            );
1211        });
1212    }
1213
1214    #[test]
1215    fn test_measurement_unit_no_parent_default() {
1216        with_isolated_home(|temp_dir| {
1217            // Create git repository
1218            env::set_current_dir(temp_dir).unwrap();
1219            init_repo(temp_dir);
1220
1221            // Create workspace config with only measurement-specific units (no parent default)
1222            let workspace_config_path = temp_dir.join(".gitperfconfig");
1223            let local_config = r#"
1224[measurement."build_time"]
1225unit = "ms"
1226
1227[measurement."memory_usage"]
1228unit = "bytes"
1229"#;
1230            fs::write(&workspace_config_path, local_config).unwrap();
1231
1232            // Test measurement-specific settings
1233            assert_eq!(
1234                super::measurement_unit("build_time"),
1235                Some("ms".to_string())
1236            );
1237            assert_eq!(
1238                super::measurement_unit("memory_usage"),
1239                Some("bytes".to_string())
1240            );
1241
1242            // Test measurement without unit (no parent default either)
1243            assert_eq!(super::measurement_unit("other_measurement"), None);
1244        });
1245    }
1246}