Skip to main content

git_perf/
config.rs

1use anyhow::Result;
2use config::{Config, ConfigError, File, FileFormat};
3use std::{
4    collections::HashMap,
5    env,
6    fs::File as StdFile,
7    io::{Read, Write},
8    path::{Path, PathBuf},
9};
10use toml_edit::{value, DocumentMut, Item, Table};
11
12use crate::defaults;
13use crate::git::git_interop::{get_head_revision, get_repository_root};
14
15// Import the CLI types for dispersion method
16use git_perf_cli_types::DispersionMethod;
17
18/// Extension trait to get values with parent table fallback.
19///
20/// This provides a consistent way to retrieve a value for a given logical name
21/// and fall back to the parent table when the specific name is not present.
22pub trait ConfigParentFallbackExt {
23    /// Returns a string value for `{parent}.{name}.{key}` if available.
24    /// Otherwise falls back to `{parent}.{key}` (parent table defaults).
25    ///
26    /// The `parent` is the parent table name (e.g., "measurement").
27    /// The `name` is the specific identifier within that parent.
28    fn get_with_parent_fallback(&self, parent: &str, name: &str, key: &str) -> Option<String>;
29}
30
31impl ConfigParentFallbackExt for Config {
32    fn get_with_parent_fallback(&self, parent: &str, name: &str, key: &str) -> Option<String> {
33        // Use table-based navigation instead of building a dot-path key string.
34        // The config crate's path expression parser only accepts [a-zA-Z0-9_-] as identifier
35        // characters, so dot-path lookup silently fails for measurement names that contain
36        // '::' or '/' (e.g. Criterion benchmark names like "bench::group/bench/1::stat").
37        if let Ok(mut parent_table) = self.get_table(parent) {
38            // Try specific measurement first: parent[name][key]
39            if let Some(name_value) = parent_table.remove(name) {
40                if let Ok(mut name_table) = name_value.into_table() {
41                    if let Some(key_value) = name_table.remove(key) {
42                        if let Ok(s) = key_value.into_string() {
43                            return Some(s);
44                        }
45                    }
46                }
47            }
48
49            // Fallback to parent-level default: parent[key]
50            if let Some(key_value) = parent_table.remove(key) {
51                if let Ok(s) = key_value.into_string() {
52                    return Some(s);
53                }
54            }
55        }
56
57        None
58    }
59}
60
61/// Get the main repository config path (always in repo root)
62fn get_main_config_path() -> Result<PathBuf> {
63    // Use git to find the repository root
64    let repo_root = get_repository_root().map_err(|e| {
65        anyhow::anyhow!(
66            "Failed to determine repository root - must be run from within a git repository: {}",
67            e
68        )
69    })?;
70
71    if repo_root.is_empty() {
72        return Err(anyhow::anyhow!(
73            "Repository root is empty - must be run from within a git repository"
74        ));
75    }
76
77    Ok(PathBuf::from(repo_root).join(".gitperfconfig"))
78}
79
80/// Write config to the main repository directory (always in repo root)
81pub fn write_config(conf: &str) -> Result<()> {
82    let path = get_main_config_path()?;
83    let mut f = StdFile::create(path)?;
84    f.write_all(conf.as_bytes())?;
85    Ok(())
86}
87
88/// Read hierarchical configuration (system -> local override)
89pub fn read_hierarchical_config() -> Result<Config, ConfigError> {
90    let mut builder = Config::builder();
91
92    // 1. System-wide config (XDG_CONFIG_HOME or ~/.config/git-perf/config.toml)
93    if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") {
94        let system_config_path = Path::new(&xdg_config_home)
95            .join("git-perf")
96            .join("config.toml");
97        builder = builder.add_source(
98            File::from(system_config_path)
99                .format(FileFormat::Toml)
100                .required(false),
101        );
102    } else if let Some(home) = dirs_next::home_dir() {
103        let system_config_path = home.join(".config").join("git-perf").join("config.toml");
104        builder = builder.add_source(
105            File::from(system_config_path)
106                .format(FileFormat::Toml)
107                .required(false),
108        );
109    }
110
111    // 2. Local config (repository .gitperfconfig) - this overrides system config
112    if let Some(local_path) = find_config_path() {
113        builder = builder.add_source(
114            File::from(local_path)
115                .format(FileFormat::Toml)
116                .required(false),
117        );
118    }
119
120    builder.build()
121}
122
123fn find_config_path() -> Option<PathBuf> {
124    // Use get_main_config_path but handle errors gracefully
125    let path = get_main_config_path().ok()?;
126    if path.is_file() {
127        Some(path)
128    } else {
129        None
130    }
131}
132
133fn read_config_from_file<P: AsRef<Path>>(file: P) -> Result<String> {
134    let mut conf_str = String::new();
135    StdFile::open(file)?.read_to_string(&mut conf_str)?;
136    Ok(conf_str)
137}
138
139fn read_raw_gitperfconfig() -> Option<String> {
140    let path = find_config_path()?;
141    read_config_from_file(path).ok()
142}
143
144fn read_gitperfconfig_document() -> Option<DocumentMut> {
145    read_raw_gitperfconfig()?.parse::<DocumentMut>().ok()
146}
147
148fn parse_environment_from_doc(doc: &DocumentMut) -> HashMap<String, Vec<String>> {
149    let Some(table) = doc.get("environment").and_then(|item| item.as_table()) else {
150        return HashMap::new();
151    };
152    let mut result = HashMap::new();
153    for (key, item) in table.iter() {
154        if let Some(s) = item.as_str() {
155            result.insert(key.to_string(), vec![s.to_string()]);
156        } else if let Some(arr) = item.as_array() {
157            let vars: Vec<String> = arr
158                .iter()
159                .filter_map(|v| v.as_str())
160                .map(String::from)
161                .collect();
162            if !vars.is_empty() {
163                result.insert(key.to_string(), vars);
164            }
165        } else {
166            log::warn!(
167                "Ignoring unsupported value type for [environment] key '{}'",
168                key
169            );
170        }
171    }
172    result
173}
174
175fn parse_defaults_from_doc(doc: &DocumentMut) -> HashMap<String, String> {
176    let Some(table) = doc.get("defaults").and_then(|item| item.as_table()) else {
177        return HashMap::new();
178    };
179    let mut result = HashMap::new();
180    for (key, item) in table.iter() {
181        if let Some(s) = item.as_str() {
182            result.insert(key.to_string(), s.to_string());
183        } else {
184            log::warn!("Ignoring non-string value for [defaults] key '{}'", key);
185        }
186    }
187    result
188}
189
190/// Returns the `[environment]` mapping from `.gitperfconfig`.
191///
192/// Each key maps to one or more environment variable names to look up at
193/// measurement time (first non-empty value wins for multi-source lists).
194/// Returns an empty map when the section is absent or the file cannot be parsed.
195#[must_use]
196pub fn read_environment_config() -> HashMap<String, Vec<String>> {
197    read_gitperfconfig_document()
198        .map(|doc| parse_environment_from_doc(&doc))
199        .unwrap_or_default()
200}
201
202/// Returns the `[defaults]` mapping from `.gitperfconfig`.
203///
204/// Each key maps to a static string value used as a fallback when the
205/// corresponding `[environment]` variable is not set.
206/// Returns an empty map when the section is absent or the file cannot be parsed.
207#[must_use]
208pub fn read_defaults_config() -> HashMap<String, String> {
209    read_gitperfconfig_document()
210        .map(|doc| parse_defaults_from_doc(&doc))
211        .unwrap_or_default()
212}
213
214fn apply_env_source(result: &mut HashMap<String, String>, key: String, var_names: Vec<String>) {
215    for var_name in &var_names {
216        if let Ok(val) = std::env::var(var_name) {
217            if !val.is_empty() {
218                result.insert(key, val);
219                break;
220            }
221        }
222    }
223}
224
225/// Resolves merged key-value pairs for measurement commands by applying precedence:
226///   1. `cli_key_values` — highest priority (from --key-value / --metadata)
227///   2. `[environment]` section — env var lookup, first-found-wins for multi-source lists
228///   3. `[defaults]` section — static fallback values
229///
230/// When `skip_env` is true the `[environment]` section is ignored entirely.
231/// The config file is read and parsed only once regardless of which sections are present.
232#[must_use]
233pub fn resolve_key_values(
234    cli_key_values: &[(String, String)],
235    skip_env: bool,
236) -> Vec<(String, String)> {
237    let mut result: HashMap<String, String> = HashMap::new();
238
239    // Read and parse the config file exactly once for both sections
240    if let Some(doc) = read_gitperfconfig_document() {
241        // 3. Base layer: [defaults] static values
242        for (key, value) in parse_defaults_from_doc(&doc) {
243            result.insert(key, value);
244        }
245
246        // 2. [environment] env var lookups (skipped when --skip-env)
247        if !skip_env {
248            for (key, source) in parse_environment_from_doc(&doc) {
249                apply_env_source(&mut result, key, source);
250            }
251        }
252    }
253
254    // 1. CLI args always win — insert last to overwrite everything
255    for (key, value) in cli_key_values {
256        result.insert(key.clone(), value.clone());
257    }
258
259    result.into_iter().collect()
260}
261
262#[must_use]
263pub fn determine_epoch_from_config(measurement: &str) -> Option<u32> {
264    let config = read_hierarchical_config()
265        .map_err(|e| {
266            // Log the error but don't fail - this is expected when no config exists
267            log::debug!("Could not read hierarchical config: {}", e);
268        })
269        .ok()?;
270
271    // Use parent fallback for measurement epoch
272    config
273        .get_with_parent_fallback("measurement", measurement, "epoch")
274        .and_then(|s| u32::from_str_radix(&s, 16).ok())
275}
276
277pub fn bump_epoch_in_conf(measurement: &str, conf_str: &mut String) -> Result<()> {
278    let mut conf = conf_str
279        .parse::<DocumentMut>()
280        .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?;
281
282    let head_revision = get_head_revision()?;
283
284    // Ensure that non-inline tables are written in an empty config file
285    if !conf.contains_key("measurement") {
286        conf["measurement"] = Item::Table(Table::new());
287    }
288    if !conf["measurement"]
289        .as_table()
290        .unwrap()
291        .contains_key(measurement)
292    {
293        conf["measurement"][measurement] = Item::Table(Table::new());
294    }
295
296    conf["measurement"][measurement]["epoch"] = value(&head_revision[0..8]);
297    *conf_str = conf.to_string();
298
299    Ok(())
300}
301
302pub fn bump_epoch(measurement: &str) -> Result<()> {
303    // Read existing config from the main config path
304    let config_path = get_main_config_path()?;
305    let mut conf_str = read_config_from_file(&config_path).unwrap_or_default();
306
307    bump_epoch_in_conf(measurement, &mut conf_str)?;
308    write_config(&conf_str)?;
309    Ok(())
310}
311
312/// Returns the backoff max elapsed seconds from config, or the default if not set.
313#[must_use]
314pub fn backoff_max_elapsed_seconds() -> u64 {
315    match read_hierarchical_config() {
316        Ok(config) => {
317            if let Ok(seconds) = config.get_int("backoff.max_elapsed_seconds") {
318                seconds as u64
319            } else {
320                defaults::DEFAULT_BACKOFF_MAX_ELAPSED_SECONDS
321            }
322        }
323        Err(_) => defaults::DEFAULT_BACKOFF_MAX_ELAPSED_SECONDS,
324    }
325}
326
327/// Returns the minimum relative deviation threshold from config, or None if not set.
328#[must_use]
329pub fn audit_min_relative_deviation(measurement: &str) -> Option<f64> {
330    let config = read_hierarchical_config().ok()?;
331
332    if let Some(s) =
333        config.get_with_parent_fallback("measurement", measurement, "min_relative_deviation")
334    {
335        if let Ok(v) = s.parse::<f64>() {
336            return Some(v);
337        }
338    }
339
340    None
341}
342
343/// Returns the maximum CoV (Coefficient of Variation = σ/μ × 100%) threshold from
344/// config, or None if not set. When tail or head CoV exceeds this value, a warning
345/// is emitted in the audit output.
346#[must_use]
347pub fn audit_max_cov(measurement: &str) -> Option<f64> {
348    let config = read_hierarchical_config().ok()?;
349
350    if let Some(s) = config.get_with_parent_fallback("measurement", measurement, "max_cov") {
351        if let Ok(v) = s.parse::<f64>() {
352            return Some(v);
353        }
354    }
355
356    None
357}
358
359/// Returns the minimum absolute deviation threshold from config, or None if not set.
360#[must_use]
361pub fn audit_min_absolute_deviation(measurement: &str) -> Option<f64> {
362    let config = read_hierarchical_config().ok()?;
363
364    if let Some(s) =
365        config.get_with_parent_fallback("measurement", measurement, "min_absolute_deviation")
366    {
367        if let Ok(v) = s.parse::<f64>() {
368            return Some(v);
369        }
370    }
371
372    None
373}
374
375/// Returns the dispersion method from config, or StandardDeviation if not set.
376#[must_use]
377pub fn audit_dispersion_method(measurement: &str) -> DispersionMethod {
378    let Some(config) = read_hierarchical_config().ok() else {
379        return DispersionMethod::StandardDeviation;
380    };
381
382    if let Some(s) =
383        config.get_with_parent_fallback("measurement", measurement, "dispersion_method")
384    {
385        if let Ok(method) = s.parse::<DispersionMethod>() {
386            return method;
387        }
388    }
389
390    DispersionMethod::StandardDeviation
391}
392
393/// Returns the minimum measurements from config, or None if not set.
394#[must_use]
395pub fn audit_min_measurements(measurement: &str) -> Option<u16> {
396    let config = read_hierarchical_config().ok()?;
397
398    if let Some(s) = config.get_with_parent_fallback("measurement", measurement, "min_measurements")
399    {
400        if let Ok(v) = s.parse::<u16>() {
401            return Some(v);
402        }
403    }
404
405    None
406}
407
408/// Returns the aggregate-by reduction function from config, or None if not set.
409#[must_use]
410pub fn audit_aggregate_by(measurement: &str) -> Option<git_perf_cli_types::ReductionFunc> {
411    let config = read_hierarchical_config().ok()?;
412
413    let s = config.get_with_parent_fallback("measurement", measurement, "aggregate_by")?;
414
415    // Parse the string to ReductionFunc
416    match s.to_lowercase().as_str() {
417        "min" => Some(git_perf_cli_types::ReductionFunc::Min),
418        "max" => Some(git_perf_cli_types::ReductionFunc::Max),
419        "median" => Some(git_perf_cli_types::ReductionFunc::Median),
420        "mean" => Some(git_perf_cli_types::ReductionFunc::Mean),
421        _ => None,
422    }
423}
424
425/// Returns the sigma value from config, or None if not set.
426#[must_use]
427pub fn audit_sigma(measurement: &str) -> Option<f64> {
428    let config = read_hierarchical_config().ok()?;
429
430    if let Some(s) = config.get_with_parent_fallback("measurement", measurement, "sigma") {
431        if let Ok(v) = s.parse::<f64>() {
432            return Some(v);
433        }
434    }
435
436    None
437}
438
439/// Returns the configured unit for a measurement, or None if not set.
440#[must_use]
441pub fn measurement_unit(measurement: &str) -> Option<String> {
442    let config = read_hierarchical_config().ok()?;
443    config.get_with_parent_fallback("measurement", measurement, "unit")
444}
445
446/// Returns the report template path from config, or None if not set.
447#[must_use]
448pub fn report_template_path() -> Option<PathBuf> {
449    let config = read_hierarchical_config().ok()?;
450    let path_str = config.get_string("report.template_path").ok()?;
451    Some(PathBuf::from(path_str))
452}
453
454/// Returns the report custom CSS path from config, or None if not set.
455#[must_use]
456pub fn report_custom_css_path() -> Option<PathBuf> {
457    let config = read_hierarchical_config().ok()?;
458    let path_str = config.get_string("report.custom_css_path").ok()?;
459    Some(PathBuf::from(path_str))
460}
461
462/// Returns the report title from config, or None if not set.
463#[must_use]
464pub fn report_title() -> Option<String> {
465    let config = read_hierarchical_config().ok()?;
466    config.get_string("report.title").ok()
467}
468
469/// Returns the change point configuration for a measurement, applying fallback rules.
470///
471/// Configuration keys under `[change_point]` or `[change_point."measurement_name"]`:
472/// - `enabled`: Enable/disable change point detection (default: true)
473/// - `min_data_points`: Minimum data points required (default: 10)
474/// - `min_magnitude_pct`: Minimum percentage change to consider significant (default: 5.0)
475/// - `confidence_threshold`: Minimum confidence to report a change point (0.0-1.0, default: 0.75)
476/// - `penalty`: Penalty factor for PELT algorithm (default: 0.5, lower = more sensitive)
477#[must_use]
478pub fn change_point_config(measurement: &str) -> crate::change_point::ChangePointConfig {
479    let mut config = crate::change_point::ChangePointConfig::default();
480
481    let Ok(file_config) = read_hierarchical_config() else {
482        return config;
483    };
484
485    // Check if change point detection is disabled globally or per-measurement
486    if let Some(enabled_str) =
487        file_config.get_with_parent_fallback("change_point", measurement, "enabled")
488    {
489        if let Ok(enabled) = enabled_str.parse::<bool>() {
490            config.enabled = enabled;
491        }
492    }
493
494    // min_data_points
495    if let Some(s) =
496        file_config.get_with_parent_fallback("change_point", measurement, "min_data_points")
497    {
498        if let Ok(v) = s.parse::<usize>() {
499            config.min_data_points = v;
500        }
501    }
502
503    // min_magnitude_pct
504    if let Some(s) =
505        file_config.get_with_parent_fallback("change_point", measurement, "min_magnitude_pct")
506    {
507        if let Ok(v) = s.parse::<f64>() {
508            config.min_magnitude_pct = v;
509        }
510    }
511
512    // confidence_threshold
513    if let Some(s) =
514        file_config.get_with_parent_fallback("change_point", measurement, "confidence_threshold")
515    {
516        if let Ok(v) = s.parse::<f64>() {
517            config.confidence_threshold = v;
518        }
519    }
520
521    // penalty
522    if let Some(s) = file_config.get_with_parent_fallback("change_point", measurement, "penalty") {
523        if let Ok(v) = s.parse::<f64>() {
524            config.penalty = v;
525        }
526    }
527
528    config
529}
530
531#[cfg(test)]
532mod test {
533    use super::*;
534    use crate::test_helpers::{
535        hermetic_git_env, init_repo, init_repo_with_file, with_isolated_home,
536    };
537    use std::fs;
538    use tempfile::TempDir;
539
540    /// Create a HOME config directory structure and return the config path
541    fn create_home_config_dir(home_dir: &Path) -> PathBuf {
542        let config_dir = home_dir.join(".config").join("git-perf");
543        fs::create_dir_all(&config_dir).unwrap();
544        config_dir.join("config.toml")
545    }
546
547    #[test]
548    fn test_read_epochs() {
549        with_isolated_home(|temp_dir| {
550            // Create a git repository
551            env::set_current_dir(temp_dir).unwrap();
552            init_repo(temp_dir);
553
554            // Create workspace config with epochs
555            let workspace_config_path = temp_dir.join(".gitperfconfig");
556            let configfile = r#"[measurement]
557# General performance regression
558epoch="12344555"
559
560[measurement."something"]
561#My comment
562epoch="34567898"
563
564[measurement."somethingelse"]
565epoch="a3dead"
566"#;
567            fs::write(&workspace_config_path, configfile).unwrap();
568
569            let epoch = determine_epoch_from_config("something");
570            assert_eq!(epoch, Some(0x34567898));
571
572            let epoch = determine_epoch_from_config("somethingelse");
573            assert_eq!(epoch, Some(0xa3dead));
574
575            let epoch = determine_epoch_from_config("unspecified");
576            assert_eq!(epoch, Some(0x12344555));
577        });
578    }
579
580    #[test]
581    fn test_bump_epochs() {
582        with_isolated_home(|temp_dir| {
583            // Create a temporary git repository for this test
584            env::set_current_dir(temp_dir).unwrap();
585
586            // Set up hermetic git environment
587            hermetic_git_env();
588
589            // Initialize git repository with initial commit
590            init_repo_with_file(temp_dir);
591
592            let configfile = r#"[measurement."something"]
593#My comment
594epoch = "34567898"
595"#;
596
597            let mut actual = String::from(configfile);
598            bump_epoch_in_conf("something", &mut actual).expect("Failed to bump epoch");
599
600            let expected = format!(
601                r#"[measurement."something"]
602#My comment
603epoch = "{}"
604"#,
605                &get_head_revision().expect("get_head_revision failed")[0..8],
606            );
607
608            assert_eq!(actual, expected);
609        });
610    }
611
612    #[test]
613    fn test_bump_new_epoch_and_read_it() {
614        with_isolated_home(|temp_dir| {
615            // Create a temporary git repository for this test
616            env::set_current_dir(temp_dir).unwrap();
617
618            // Set up hermetic git environment
619            hermetic_git_env();
620
621            // Initialize git repository with initial commit
622            init_repo_with_file(temp_dir);
623
624            let mut conf = String::new();
625            bump_epoch_in_conf("mymeasurement", &mut conf).expect("Failed to bump epoch");
626
627            // Write the config to a file and test reading it
628            let config_path = temp_dir.join(".gitperfconfig");
629            fs::write(&config_path, &conf).unwrap();
630
631            let epoch = determine_epoch_from_config("mymeasurement");
632            assert!(epoch.is_some());
633        });
634    }
635
636    #[test]
637    fn test_backoff_max_elapsed_seconds() {
638        with_isolated_home(|temp_dir| {
639            // Create git repository
640            env::set_current_dir(temp_dir).unwrap();
641            init_repo(temp_dir);
642
643            // Create workspace config with explicit value
644            let workspace_config_path = temp_dir.join(".gitperfconfig");
645            let local_config = "[backoff]\nmax_elapsed_seconds = 42\n";
646            fs::write(&workspace_config_path, local_config).unwrap();
647
648            // Test with explicit value
649            assert_eq!(super::backoff_max_elapsed_seconds(), 42);
650
651            // Remove config file and test default
652            fs::remove_file(&workspace_config_path).unwrap();
653            assert_eq!(super::backoff_max_elapsed_seconds(), 60);
654        });
655    }
656
657    #[test]
658    fn test_audit_min_relative_deviation() {
659        with_isolated_home(|temp_dir| {
660            // Create git repository
661            env::set_current_dir(temp_dir).unwrap();
662            init_repo(temp_dir);
663
664            // Create workspace config with measurement-specific settings
665            let workspace_config_path = temp_dir.join(".gitperfconfig");
666            let local_config = r#"
667[measurement]
668min_relative_deviation = 5.0
669
670[measurement."build_time"]
671min_relative_deviation = 10.0
672
673[measurement."memory_usage"]
674min_relative_deviation = 2.5
675"#;
676            fs::write(&workspace_config_path, local_config).unwrap();
677
678            // Test measurement-specific settings
679            assert_eq!(
680                super::audit_min_relative_deviation("build_time"),
681                Some(10.0)
682            );
683            assert_eq!(
684                super::audit_min_relative_deviation("memory_usage"),
685                Some(2.5)
686            );
687            assert_eq!(
688                super::audit_min_relative_deviation("other_measurement"),
689                Some(5.0) // Now falls back to parent table
690            );
691
692            // Test global (now parent table) setting
693            let global_config = r#"
694[measurement]
695min_relative_deviation = 5.0
696"#;
697            fs::write(&workspace_config_path, global_config).unwrap();
698            assert_eq!(
699                super::audit_min_relative_deviation("any_measurement"),
700                Some(5.0)
701            );
702
703            // Test precedence - measurement-specific overrides global
704            let precedence_config = r#"
705[measurement]
706min_relative_deviation = 5.0
707
708[measurement."build_time"]
709min_relative_deviation = 10.0
710"#;
711            fs::write(&workspace_config_path, precedence_config).unwrap();
712            assert_eq!(
713                super::audit_min_relative_deviation("build_time"),
714                Some(10.0)
715            );
716            assert_eq!(
717                super::audit_min_relative_deviation("other_measurement"),
718                Some(5.0)
719            );
720
721            // Test no config
722            fs::remove_file(&workspace_config_path).unwrap();
723            assert_eq!(super::audit_min_relative_deviation("any_measurement"), None);
724        });
725    }
726
727    #[test]
728    fn test_audit_max_cov() {
729        with_isolated_home(|temp_dir| {
730            env::set_current_dir(temp_dir).unwrap();
731            init_repo(temp_dir);
732
733            let workspace_config_path = temp_dir.join(".gitperfconfig");
734            let local_config = r#"
735[measurement]
736max_cov = 30.0
737
738[measurement."build_time"]
739max_cov = 50.0
740
741[measurement."memory_usage"]
742max_cov = 20.0
743"#;
744            fs::write(&workspace_config_path, local_config).unwrap();
745
746            assert_eq!(super::audit_max_cov("build_time"), Some(50.0));
747            assert_eq!(super::audit_max_cov("memory_usage"), Some(20.0));
748            assert_eq!(super::audit_max_cov("other_measurement"), Some(30.0));
749
750            let global_config = r#"
751[measurement]
752max_cov = 30.0
753"#;
754            fs::write(&workspace_config_path, global_config).unwrap();
755            assert_eq!(super::audit_max_cov("any_measurement"), Some(30.0));
756
757            fs::remove_file(&workspace_config_path).unwrap();
758            assert_eq!(super::audit_max_cov("any_measurement"), None);
759        });
760    }
761
762    #[test]
763    fn test_audit_min_absolute_deviation() {
764        with_isolated_home(|temp_dir| {
765            // Create git repository
766            env::set_current_dir(temp_dir).unwrap();
767            init_repo(temp_dir);
768
769            // Create workspace config with measurement-specific settings
770            let workspace_config_path = temp_dir.join(".gitperfconfig");
771            let local_config = r#"
772[measurement]
773min_absolute_deviation = 5.0
774
775[measurement."build_time"]
776min_absolute_deviation = 10.0
777
778[measurement."memory_usage"]
779min_absolute_deviation = 2.5
780"#;
781            fs::write(&workspace_config_path, local_config).unwrap();
782
783            // Test measurement-specific settings
784            assert_eq!(
785                super::audit_min_absolute_deviation("build_time"),
786                Some(10.0)
787            );
788            assert_eq!(
789                super::audit_min_absolute_deviation("memory_usage"),
790                Some(2.5)
791            );
792            assert_eq!(
793                super::audit_min_absolute_deviation("other_measurement"),
794                Some(5.0) // falls back to parent table
795            );
796
797            // Test global (parent table) setting
798            let global_config = r#"
799[measurement]
800min_absolute_deviation = 5.0
801"#;
802            fs::write(&workspace_config_path, global_config).unwrap();
803            assert_eq!(
804                super::audit_min_absolute_deviation("any_measurement"),
805                Some(5.0)
806            );
807
808            // Test precedence - measurement-specific overrides global
809            let precedence_config = r#"
810[measurement]
811min_absolute_deviation = 5.0
812
813[measurement."build_time"]
814min_absolute_deviation = 10.0
815"#;
816            fs::write(&workspace_config_path, precedence_config).unwrap();
817            assert_eq!(
818                super::audit_min_absolute_deviation("build_time"),
819                Some(10.0)
820            );
821            assert_eq!(
822                super::audit_min_absolute_deviation("other_measurement"),
823                Some(5.0)
824            );
825
826            // Test no config
827            fs::remove_file(&workspace_config_path).unwrap();
828            assert_eq!(super::audit_min_absolute_deviation("any_measurement"), None);
829        });
830    }
831
832    #[test]
833    fn test_audit_dispersion_method() {
834        with_isolated_home(|temp_dir| {
835            // Create git repository
836            env::set_current_dir(temp_dir).unwrap();
837            init_repo(temp_dir);
838
839            // Create workspace config with measurement-specific settings
840            let workspace_config_path = temp_dir.join(".gitperfconfig");
841            let local_config = r#"
842[measurement]
843dispersion_method = "stddev"
844
845[measurement."build_time"]
846dispersion_method = "mad"
847
848[measurement."memory_usage"]
849dispersion_method = "stddev"
850"#;
851            fs::write(&workspace_config_path, local_config).unwrap();
852
853            // Test measurement-specific settings
854            assert_eq!(
855                super::audit_dispersion_method("build_time"),
856                git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
857            );
858            assert_eq!(
859                super::audit_dispersion_method("memory_usage"),
860                git_perf_cli_types::DispersionMethod::StandardDeviation
861            );
862            assert_eq!(
863                super::audit_dispersion_method("other_measurement"),
864                git_perf_cli_types::DispersionMethod::StandardDeviation
865            );
866
867            // Test global (now parent table) setting
868            let global_config = r#"
869[measurement]
870dispersion_method = "mad"
871"#;
872            fs::write(&workspace_config_path, global_config).unwrap();
873            assert_eq!(
874                super::audit_dispersion_method("any_measurement"),
875                git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
876            );
877
878            // Test precedence - measurement-specific overrides global
879            let precedence_config = r#"
880[measurement]
881dispersion_method = "mad"
882
883[measurement."build_time"]
884dispersion_method = "stddev"
885"#;
886            fs::write(&workspace_config_path, precedence_config).unwrap();
887            assert_eq!(
888                super::audit_dispersion_method("build_time"),
889                git_perf_cli_types::DispersionMethod::StandardDeviation
890            );
891            assert_eq!(
892                super::audit_dispersion_method("other_measurement"),
893                git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
894            );
895
896            // Test no config (should return StandardDeviation)
897            fs::remove_file(&workspace_config_path).unwrap();
898            assert_eq!(
899                super::audit_dispersion_method("any_measurement"),
900                git_perf_cli_types::DispersionMethod::StandardDeviation
901            );
902        });
903    }
904
905    #[test]
906    fn test_bump_epoch_in_conf_creates_proper_tables() {
907        // We need to test the production bump_epoch_in_conf function, but it calls get_head_revision()
908        // which requires a git repo. Let's temporarily modify the environment to make it work.
909        with_isolated_home(|temp_dir| {
910            env::set_current_dir(temp_dir).unwrap();
911
912            // Set up minimal git environment
913            hermetic_git_env();
914
915            init_repo_with_file(temp_dir);
916
917            // Test case 1: Empty config string should create proper table structure
918            let mut empty_config = String::new();
919
920            // This calls the actual production function!
921            bump_epoch_in_conf("mymeasurement", &mut empty_config).unwrap();
922
923            // Verify that proper table structure is created (not inline tables)
924            assert!(empty_config.contains("[measurement]"));
925            assert!(empty_config.contains("[measurement.mymeasurement]"));
926            assert!(empty_config.contains("epoch ="));
927            // Ensure it's NOT using inline table syntax
928            assert!(!empty_config.contains("measurement = {"));
929            assert!(!empty_config.contains("mymeasurement = {"));
930
931            // Test case 2: Existing config should preserve structure and add new measurement
932            let mut existing_config = r#"[measurement]
933existing_setting = "value"
934
935[measurement."other"]
936epoch = "oldvalue"
937"#
938            .to_string();
939
940            bump_epoch_in_conf("newmeasurement", &mut existing_config).unwrap();
941
942            // Verify it maintains existing structure and adds new measurement with proper table format
943            assert!(existing_config.contains("[measurement.newmeasurement]"));
944            assert!(existing_config.contains("existing_setting = \"value\""));
945            assert!(existing_config.contains("[measurement.\"other\"]"));
946            assert!(!existing_config.contains("newmeasurement = {"));
947        });
948    }
949
950    #[test]
951    fn test_find_config_path_in_git_root() {
952        with_isolated_home(|temp_dir| {
953            // Create a git repository
954            env::set_current_dir(temp_dir).unwrap();
955
956            // Initialize git repository
957            init_repo(temp_dir);
958
959            // Create config in git root
960            let config_path = temp_dir.join(".gitperfconfig");
961            fs::write(
962                &config_path,
963                "[measurement.\"test\"]\nepoch = \"12345678\"\n",
964            )
965            .unwrap();
966
967            // Test that find_config_path finds it
968            let found_path = find_config_path();
969            assert!(found_path.is_some());
970            // Canonicalize both paths to handle symlinks (e.g., /var -> /private/var on macOS)
971            assert_eq!(
972                found_path.unwrap().canonicalize().unwrap(),
973                config_path.canonicalize().unwrap()
974            );
975        });
976    }
977
978    #[test]
979    fn test_find_config_path_not_found() {
980        with_isolated_home(|temp_dir| {
981            // Create a git repository but no .gitperfconfig
982            env::set_current_dir(temp_dir).unwrap();
983
984            // Initialize git repository
985            init_repo(temp_dir);
986
987            // Test that find_config_path returns None when no .gitperfconfig exists
988            let found_path = find_config_path();
989            assert!(found_path.is_none());
990        });
991    }
992
993    #[test]
994    fn test_hierarchical_config_workspace_overrides_home() {
995        with_isolated_home(|temp_dir| {
996            // Create a git repository
997            env::set_current_dir(temp_dir).unwrap();
998
999            // Initialize git repository
1000            init_repo(temp_dir);
1001
1002            // Create home config
1003            let home_config_path = create_home_config_dir(temp_dir);
1004            fs::write(
1005                &home_config_path,
1006                r#"
1007[measurement."test"]
1008backoff_max_elapsed_seconds = 30
1009audit_min_relative_deviation = 1.0
1010"#,
1011            )
1012            .unwrap();
1013
1014            // Create workspace config that overrides some values
1015            let workspace_config_path = temp_dir.join(".gitperfconfig");
1016            fs::write(
1017                &workspace_config_path,
1018                r#"
1019[measurement."test"]
1020backoff_max_elapsed_seconds = 60
1021"#,
1022            )
1023            .unwrap();
1024
1025            // Set HOME to our temp directory
1026            env::set_var("HOME", temp_dir);
1027            env::remove_var("XDG_CONFIG_HOME");
1028
1029            // Read hierarchical config and verify workspace overrides home
1030            let config = read_hierarchical_config().unwrap();
1031
1032            // backoff_max_elapsed_seconds should be overridden by workspace config
1033            let backoff: i32 = config
1034                .get("measurement.test.backoff_max_elapsed_seconds")
1035                .unwrap();
1036            assert_eq!(backoff, 60);
1037
1038            // audit_min_relative_deviation should come from home config
1039            let deviation: f64 = config
1040                .get("measurement.test.audit_min_relative_deviation")
1041                .unwrap();
1042            assert_eq!(deviation, 1.0);
1043        });
1044    }
1045
1046    #[test]
1047    fn test_determine_epoch_from_config_with_missing_file() {
1048        // Test that missing config file doesn't panic and returns None
1049        let temp_dir = TempDir::new().unwrap();
1050        fs::create_dir_all(temp_dir.path()).unwrap();
1051        env::set_current_dir(temp_dir.path()).unwrap();
1052
1053        let epoch = determine_epoch_from_config("test_measurement");
1054        assert!(epoch.is_none());
1055    }
1056
1057    #[test]
1058    fn test_determine_epoch_from_config_with_invalid_toml() {
1059        let temp_dir = TempDir::new().unwrap();
1060        let config_path = temp_dir.path().join(".gitperfconfig");
1061        fs::write(&config_path, "invalid toml content").unwrap();
1062
1063        fs::create_dir_all(temp_dir.path()).unwrap();
1064        env::set_current_dir(temp_dir.path()).unwrap();
1065
1066        let epoch = determine_epoch_from_config("test_measurement");
1067        assert!(epoch.is_none());
1068    }
1069
1070    #[test]
1071    fn test_write_config_creates_file() {
1072        with_isolated_home(|temp_dir| {
1073            // Create git repository
1074            env::set_current_dir(temp_dir).unwrap();
1075            init_repo(temp_dir);
1076
1077            // Create a subdirectory to test that config is written to repo root
1078            let subdir = temp_dir.join("a").join("b").join("c");
1079            fs::create_dir_all(&subdir).unwrap();
1080            env::set_current_dir(&subdir).unwrap();
1081
1082            let config_content = "[measurement.\"test\"]\nepoch = \"12345678\"\n";
1083            write_config(config_content).unwrap();
1084
1085            // Config should be written to repo root, not subdirectory
1086            let repo_config_path = temp_dir.join(".gitperfconfig");
1087            let subdir_config_path = subdir.join(".gitperfconfig");
1088
1089            assert!(repo_config_path.is_file());
1090            assert!(!subdir_config_path.is_file());
1091
1092            let content = fs::read_to_string(&repo_config_path).unwrap();
1093            assert_eq!(content, config_content);
1094        });
1095    }
1096
1097    #[test]
1098    fn test_hierarchical_config_system_override() {
1099        with_isolated_home(|temp_dir| {
1100            // Create system config (home directory config)
1101            let system_config_path = create_home_config_dir(temp_dir);
1102            let system_config = r#"
1103[measurement]
1104min_relative_deviation = 5.0
1105dispersion_method = "mad"
1106
1107[backoff]
1108max_elapsed_seconds = 120
1109"#;
1110            fs::write(&system_config_path, system_config).unwrap();
1111
1112            // Create git repository
1113            env::set_current_dir(temp_dir).unwrap();
1114            init_repo(temp_dir);
1115
1116            // Create workspace config that overrides system config
1117            let workspace_config_path = temp_dir.join(".gitperfconfig");
1118            let local_config = r#"
1119[measurement]
1120min_relative_deviation = 10.0
1121
1122[measurement."build_time"]
1123min_relative_deviation = 15.0
1124dispersion_method = "stddev"
1125"#;
1126            fs::write(&workspace_config_path, local_config).unwrap();
1127
1128            // Test hierarchical config reading
1129            let config = read_hierarchical_config().unwrap();
1130
1131            // Test that local parent table overrides system config via helper
1132            use super::ConfigParentFallbackExt;
1133            assert_eq!(
1134                config
1135                    .get_with_parent_fallback(
1136                        "measurement",
1137                        "any_measurement",
1138                        "min_relative_deviation"
1139                    )
1140                    .unwrap()
1141                    .parse::<f64>()
1142                    .unwrap(),
1143                10.0
1144            );
1145            assert_eq!(
1146                config
1147                    .get_with_parent_fallback("measurement", "any_measurement", "dispersion_method")
1148                    .unwrap(),
1149                "mad"
1150            ); // Not overridden in local for parent fallback
1151
1152            // Test measurement-specific override
1153            assert_eq!(
1154                config
1155                    .get_float("measurement.build_time.min_relative_deviation")
1156                    .unwrap(),
1157                15.0
1158            );
1159            assert_eq!(
1160                config
1161                    .get_string("measurement.build_time.dispersion_method")
1162                    .unwrap(),
1163                "stddev"
1164            );
1165
1166            // Test that system config is still available for non-overridden values
1167            assert_eq!(config.get_int("backoff.max_elapsed_seconds").unwrap(), 120);
1168
1169            // Test the convenience functions
1170            assert_eq!(audit_min_relative_deviation("build_time"), Some(15.0));
1171            assert_eq!(
1172                audit_min_relative_deviation("other_measurement"),
1173                Some(10.0)
1174            );
1175            assert_eq!(
1176                audit_dispersion_method("build_time"),
1177                git_perf_cli_types::DispersionMethod::StandardDeviation
1178            );
1179            assert_eq!(
1180                audit_dispersion_method("other_measurement"),
1181                git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation
1182            );
1183            assert_eq!(backoff_max_elapsed_seconds(), 120);
1184        });
1185    }
1186
1187    #[test]
1188    fn test_read_config_from_file_missing_file() {
1189        let temp_dir = TempDir::new().unwrap();
1190        let nonexistent_file = temp_dir.path().join("does_not_exist.toml");
1191
1192        // Should return error, not Ok(String::new())
1193        let result = read_config_from_file(&nonexistent_file);
1194        assert!(result.is_err());
1195    }
1196
1197    #[test]
1198    fn test_read_config_from_file_valid_content() {
1199        let temp_dir = TempDir::new().unwrap();
1200        let config_file = temp_dir.path().join("test_config.toml");
1201        let expected_content = "[measurement]\nepoch = \"12345678\"\n";
1202
1203        fs::write(&config_file, expected_content).unwrap();
1204
1205        let result = read_config_from_file(&config_file);
1206        assert!(result.is_ok());
1207        let content = result.unwrap();
1208        assert_eq!(content, expected_content);
1209
1210        // This would catch the mutant that returns Ok(String::new())
1211        assert!(!content.is_empty());
1212    }
1213
1214    #[test]
1215    fn test_audit_min_measurements() {
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 measurement-specific settings
1222            let workspace_config_path = temp_dir.join(".gitperfconfig");
1223            let local_config = r#"
1224[measurement]
1225min_measurements = 5
1226
1227[measurement."build_time"]
1228min_measurements = 10
1229
1230[measurement."memory_usage"]
1231min_measurements = 3
1232"#;
1233            fs::write(&workspace_config_path, local_config).unwrap();
1234
1235            // Test measurement-specific settings
1236            assert_eq!(super::audit_min_measurements("build_time"), Some(10));
1237            assert_eq!(super::audit_min_measurements("memory_usage"), Some(3));
1238            assert_eq!(super::audit_min_measurements("other_measurement"), Some(5));
1239
1240            // Test no config
1241            fs::remove_file(&workspace_config_path).unwrap();
1242            assert_eq!(super::audit_min_measurements("any_measurement"), None);
1243        });
1244    }
1245
1246    #[test]
1247    fn test_audit_aggregate_by() {
1248        with_isolated_home(|temp_dir| {
1249            // Create git repository
1250            env::set_current_dir(temp_dir).unwrap();
1251            init_repo(temp_dir);
1252
1253            // Create workspace config with measurement-specific settings
1254            let workspace_config_path = temp_dir.join(".gitperfconfig");
1255            let local_config = r#"
1256[measurement]
1257aggregate_by = "median"
1258
1259[measurement."build_time"]
1260aggregate_by = "max"
1261
1262[measurement."memory_usage"]
1263aggregate_by = "mean"
1264"#;
1265            fs::write(&workspace_config_path, local_config).unwrap();
1266
1267            // Test measurement-specific settings
1268            assert_eq!(
1269                super::audit_aggregate_by("build_time"),
1270                Some(git_perf_cli_types::ReductionFunc::Max)
1271            );
1272            assert_eq!(
1273                super::audit_aggregate_by("memory_usage"),
1274                Some(git_perf_cli_types::ReductionFunc::Mean)
1275            );
1276            assert_eq!(
1277                super::audit_aggregate_by("other_measurement"),
1278                Some(git_perf_cli_types::ReductionFunc::Median)
1279            );
1280
1281            // Test no config
1282            fs::remove_file(&workspace_config_path).unwrap();
1283            assert_eq!(super::audit_aggregate_by("any_measurement"), None);
1284        });
1285    }
1286
1287    #[test]
1288    fn test_audit_sigma() {
1289        with_isolated_home(|temp_dir| {
1290            // Create git repository
1291            env::set_current_dir(temp_dir).unwrap();
1292            init_repo(temp_dir);
1293
1294            // Create workspace config with measurement-specific settings
1295            let workspace_config_path = temp_dir.join(".gitperfconfig");
1296            let local_config = r#"
1297[measurement]
1298sigma = 3.0
1299
1300[measurement."build_time"]
1301sigma = 5.5
1302
1303[measurement."memory_usage"]
1304sigma = 2.0
1305"#;
1306            fs::write(&workspace_config_path, local_config).unwrap();
1307
1308            // Test measurement-specific settings
1309            assert_eq!(super::audit_sigma("build_time"), Some(5.5));
1310            assert_eq!(super::audit_sigma("memory_usage"), Some(2.0));
1311            assert_eq!(super::audit_sigma("other_measurement"), Some(3.0));
1312
1313            // Test no config
1314            fs::remove_file(&workspace_config_path).unwrap();
1315            assert_eq!(super::audit_sigma("any_measurement"), None);
1316        });
1317    }
1318
1319    #[test]
1320    fn test_measurement_unit() {
1321        with_isolated_home(|temp_dir| {
1322            // Create git repository
1323            env::set_current_dir(temp_dir).unwrap();
1324            init_repo(temp_dir);
1325
1326            // Create workspace config with measurement-specific units
1327            let workspace_config_path = temp_dir.join(".gitperfconfig");
1328            let local_config = r#"
1329[measurement]
1330unit = "ms"
1331
1332[measurement."build_time"]
1333unit = "ms"
1334
1335[measurement."memory_usage"]
1336unit = "bytes"
1337
1338[measurement."throughput"]
1339unit = "requests/sec"
1340"#;
1341            fs::write(&workspace_config_path, local_config).unwrap();
1342
1343            // Test measurement-specific settings
1344            assert_eq!(
1345                super::measurement_unit("build_time"),
1346                Some("ms".to_string())
1347            );
1348            assert_eq!(
1349                super::measurement_unit("memory_usage"),
1350                Some("bytes".to_string())
1351            );
1352            assert_eq!(
1353                super::measurement_unit("throughput"),
1354                Some("requests/sec".to_string())
1355            );
1356
1357            // Test fallback to parent table default
1358            assert_eq!(
1359                super::measurement_unit("other_measurement"),
1360                Some("ms".to_string())
1361            );
1362
1363            // Test no config
1364            fs::remove_file(&workspace_config_path).unwrap();
1365            assert_eq!(super::measurement_unit("any_measurement"), None);
1366        });
1367    }
1368
1369    #[test]
1370    fn test_measurement_unit_precedence() {
1371        with_isolated_home(|temp_dir| {
1372            // Create git repository
1373            env::set_current_dir(temp_dir).unwrap();
1374            init_repo(temp_dir);
1375
1376            // Create workspace config testing precedence
1377            let workspace_config_path = temp_dir.join(".gitperfconfig");
1378            let precedence_config = r#"
1379[measurement]
1380unit = "ms"
1381
1382[measurement."build_time"]
1383unit = "seconds"
1384"#;
1385            fs::write(&workspace_config_path, precedence_config).unwrap();
1386
1387            // Measurement-specific should override parent default
1388            assert_eq!(
1389                super::measurement_unit("build_time"),
1390                Some("seconds".to_string())
1391            );
1392
1393            // Other measurements should use parent default
1394            assert_eq!(
1395                super::measurement_unit("other_measurement"),
1396                Some("ms".to_string())
1397            );
1398        });
1399    }
1400
1401    #[test]
1402    fn test_read_environment_config_single_var() {
1403        with_isolated_home(|temp_dir| {
1404            env::set_current_dir(temp_dir).unwrap();
1405            init_repo(temp_dir);
1406            let config_path = temp_dir.join(".gitperfconfig");
1407            fs::write(
1408                &config_path,
1409                "[environment]\ncommit = \"TEST_GITPERF_SHA\"\n",
1410            )
1411            .unwrap();
1412            let cfg = read_environment_config();
1413            assert_eq!(
1414                cfg.get("commit"),
1415                Some(&vec!["TEST_GITPERF_SHA".to_string()])
1416            );
1417        });
1418    }
1419
1420    #[test]
1421    fn test_read_environment_config_multi_var() {
1422        with_isolated_home(|temp_dir| {
1423            env::set_current_dir(temp_dir).unwrap();
1424            init_repo(temp_dir);
1425            let config_path = temp_dir.join(".gitperfconfig");
1426            fs::write(
1427                &config_path,
1428                "[environment]\nrunner_id = [\"GITPERF_R1\", \"GITPERF_R2\"]\n",
1429            )
1430            .unwrap();
1431            let cfg = read_environment_config();
1432            assert_eq!(
1433                cfg.get("runner_id"),
1434                Some(&vec!["GITPERF_R1".to_string(), "GITPERF_R2".to_string()])
1435            );
1436        });
1437    }
1438
1439    #[test]
1440    fn test_read_defaults_config() {
1441        with_isolated_home(|temp_dir| {
1442            env::set_current_dir(temp_dir).unwrap();
1443            init_repo(temp_dir);
1444            let config_path = temp_dir.join(".gitperfconfig");
1445            fs::write(&config_path, "[defaults]\nenvironment = \"local\"\n").unwrap();
1446            let cfg = read_defaults_config();
1447            assert_eq!(cfg.get("environment"), Some(&"local".to_string()));
1448        });
1449    }
1450
1451    #[test]
1452    fn test_read_environment_config_missing_section() {
1453        with_isolated_home(|temp_dir| {
1454            env::set_current_dir(temp_dir).unwrap();
1455            init_repo(temp_dir);
1456            let config_path = temp_dir.join(".gitperfconfig");
1457            fs::write(&config_path, "[measurement]\n").unwrap();
1458            assert!(read_environment_config().is_empty());
1459        });
1460    }
1461
1462    #[test]
1463    fn test_read_defaults_config_missing_section() {
1464        with_isolated_home(|temp_dir| {
1465            env::set_current_dir(temp_dir).unwrap();
1466            init_repo(temp_dir);
1467            let config_path = temp_dir.join(".gitperfconfig");
1468            fs::write(&config_path, "[measurement]\n").unwrap();
1469            assert!(read_defaults_config().is_empty());
1470        });
1471    }
1472
1473    #[test]
1474    fn test_resolve_key_values_cli_wins_over_env() {
1475        with_isolated_home(|temp_dir| {
1476            env::set_current_dir(temp_dir).unwrap();
1477            init_repo(temp_dir);
1478            let config_path = temp_dir.join(".gitperfconfig");
1479            fs::write(
1480                &config_path,
1481                "[environment]\nfoo = \"GITPERF_TEST_CLI_WINS\"\n",
1482            )
1483            .unwrap();
1484            env::set_var("GITPERF_TEST_CLI_WINS", "from_env");
1485            let result = resolve_key_values(&[("foo".to_string(), "from_cli".to_string())], false);
1486            env::remove_var("GITPERF_TEST_CLI_WINS");
1487            assert!(result.contains(&("foo".to_string(), "from_cli".to_string())));
1488        });
1489    }
1490
1491    #[test]
1492    fn test_resolve_key_values_env_wins_over_defaults() {
1493        with_isolated_home(|temp_dir| {
1494            env::set_current_dir(temp_dir).unwrap();
1495            init_repo(temp_dir);
1496            let config_path = temp_dir.join(".gitperfconfig");
1497            fs::write(
1498                &config_path,
1499                "[environment]\nfoo = \"GITPERF_TEST_ENV_WINS\"\n[defaults]\nfoo = \"from_defaults\"\n",
1500            )
1501            .unwrap();
1502            env::set_var("GITPERF_TEST_ENV_WINS", "from_env");
1503            let result = resolve_key_values(&[], false);
1504            env::remove_var("GITPERF_TEST_ENV_WINS");
1505            assert!(result.contains(&("foo".to_string(), "from_env".to_string())));
1506        });
1507    }
1508
1509    #[test]
1510    fn test_resolve_key_values_defaults_when_env_unset() {
1511        with_isolated_home(|temp_dir| {
1512            env::set_current_dir(temp_dir).unwrap();
1513            init_repo(temp_dir);
1514            let config_path = temp_dir.join(".gitperfconfig");
1515            fs::write(
1516                &config_path,
1517                "[environment]\nfoo = \"GITPERF_TEST_DEFINITELY_NOT_SET_XYZ\"\n[defaults]\nfoo = \"fallback\"\n",
1518            )
1519            .unwrap();
1520            env::remove_var("GITPERF_TEST_DEFINITELY_NOT_SET_XYZ");
1521            let result = resolve_key_values(&[], false);
1522            assert!(result.contains(&("foo".to_string(), "fallback".to_string())));
1523        });
1524    }
1525
1526    #[test]
1527    fn test_resolve_key_values_multi_source_first_wins() {
1528        with_isolated_home(|temp_dir| {
1529            env::set_current_dir(temp_dir).unwrap();
1530            init_repo(temp_dir);
1531            let config_path = temp_dir.join(".gitperfconfig");
1532            fs::write(
1533                &config_path,
1534                "[environment]\nrunner_id = [\"GITPERF_MULTI_R1\", \"GITPERF_MULTI_R2\"]\n",
1535            )
1536            .unwrap();
1537            env::set_var("GITPERF_MULTI_R1", "runner1");
1538            env::set_var("GITPERF_MULTI_R2", "runner2");
1539            let result = resolve_key_values(&[], false);
1540            env::remove_var("GITPERF_MULTI_R1");
1541            env::remove_var("GITPERF_MULTI_R2");
1542            assert!(result.contains(&("runner_id".to_string(), "runner1".to_string())));
1543            assert!(!result.contains(&("runner_id".to_string(), "runner2".to_string())));
1544        });
1545    }
1546
1547    #[test]
1548    fn test_resolve_key_values_multi_source_fallback() {
1549        with_isolated_home(|temp_dir| {
1550            env::set_current_dir(temp_dir).unwrap();
1551            init_repo(temp_dir);
1552            let config_path = temp_dir.join(".gitperfconfig");
1553            fs::write(
1554                &config_path,
1555                "[environment]\nrunner_id = [\"GITPERF_FALLBACK_R1\", \"GITPERF_FALLBACK_R2\"]\n",
1556            )
1557            .unwrap();
1558            env::remove_var("GITPERF_FALLBACK_R1");
1559            env::set_var("GITPERF_FALLBACK_R2", "runner2");
1560            let result = resolve_key_values(&[], false);
1561            env::remove_var("GITPERF_FALLBACK_R2");
1562            assert!(result.contains(&("runner_id".to_string(), "runner2".to_string())));
1563        });
1564    }
1565
1566    #[test]
1567    fn test_resolve_key_values_skip_env_uses_defaults() {
1568        with_isolated_home(|temp_dir| {
1569            env::set_current_dir(temp_dir).unwrap();
1570            init_repo(temp_dir);
1571            let config_path = temp_dir.join(".gitperfconfig");
1572            fs::write(
1573                &config_path,
1574                "[environment]\nfoo = \"GITPERF_TEST_SKIP_ENV\"\n[defaults]\nfoo = \"from_defaults\"\n",
1575            )
1576            .unwrap();
1577            env::set_var("GITPERF_TEST_SKIP_ENV", "from_env");
1578            let result = resolve_key_values(&[], true);
1579            env::remove_var("GITPERF_TEST_SKIP_ENV");
1580            assert!(result.contains(&("foo".to_string(), "from_defaults".to_string())));
1581            assert!(!result.contains(&("foo".to_string(), "from_env".to_string())));
1582        });
1583    }
1584
1585    #[test]
1586    fn test_resolve_key_values_no_config_empty() {
1587        with_isolated_home(|temp_dir| {
1588            env::set_current_dir(temp_dir).unwrap();
1589            init_repo(temp_dir);
1590            let result = resolve_key_values(&[], false);
1591            assert!(result.is_empty());
1592        });
1593    }
1594
1595    #[test]
1596    fn test_resolve_key_values_allows_normal_var() {
1597        with_isolated_home(|temp_dir| {
1598            env::set_current_dir(temp_dir).unwrap();
1599            init_repo(temp_dir);
1600            let config_path = temp_dir.join(".gitperfconfig");
1601            fs::write(
1602                &config_path,
1603                "[environment]\ncommit = \"GITPERF_TEST_SHA_NORMAL\"\n",
1604            )
1605            .unwrap();
1606            env::set_var("GITPERF_TEST_SHA_NORMAL", "abc123");
1607            let result = resolve_key_values(&[], false);
1608            env::remove_var("GITPERF_TEST_SHA_NORMAL");
1609            assert!(result.contains(&("commit".to_string(), "abc123".to_string())));
1610        });
1611    }
1612
1613    #[test]
1614    fn test_resolve_key_values_empty_env_var_not_used() {
1615        with_isolated_home(|temp_dir| {
1616            env::set_current_dir(temp_dir).unwrap();
1617            init_repo(temp_dir);
1618            let config_path = temp_dir.join(".gitperfconfig");
1619            fs::write(
1620                &config_path,
1621                "[environment]\nfoo = \"GITPERF_TEST_EMPTY_VAR\"\n[defaults]\nfoo = \"fallback\"\n",
1622            )
1623            .unwrap();
1624            env::set_var("GITPERF_TEST_EMPTY_VAR", "");
1625            let result = resolve_key_values(&[], false);
1626            env::remove_var("GITPERF_TEST_EMPTY_VAR");
1627            assert!(result.contains(&("foo".to_string(), "fallback".to_string())));
1628        });
1629    }
1630
1631    #[test]
1632    fn test_measurement_unit_no_parent_default() {
1633        with_isolated_home(|temp_dir| {
1634            // Create git repository
1635            env::set_current_dir(temp_dir).unwrap();
1636            init_repo(temp_dir);
1637
1638            // Create workspace config with only measurement-specific units (no parent default)
1639            let workspace_config_path = temp_dir.join(".gitperfconfig");
1640            let local_config = r#"
1641[measurement."build_time"]
1642unit = "ms"
1643
1644[measurement."memory_usage"]
1645unit = "bytes"
1646"#;
1647            fs::write(&workspace_config_path, local_config).unwrap();
1648
1649            // Test measurement-specific settings
1650            assert_eq!(
1651                super::measurement_unit("build_time"),
1652                Some("ms".to_string())
1653            );
1654            assert_eq!(
1655                super::measurement_unit("memory_usage"),
1656                Some("bytes".to_string())
1657            );
1658
1659            // Test measurement without unit (no parent default either)
1660            assert_eq!(super::measurement_unit("other_measurement"), None);
1661        });
1662    }
1663
1664    #[test]
1665    fn test_measurement_unit_special_chars_in_name() {
1666        with_isolated_home(|temp_dir| {
1667            env::set_current_dir(temp_dir).unwrap();
1668            init_repo(temp_dir);
1669
1670            let workspace_config_path = temp_dir.join(".gitperfconfig");
1671            let local_config = r#"
1672[measurement."with_colon::name"]
1673unit = "ns"
1674
1675[measurement."with/slash"]
1676unit = "ms"
1677
1678[measurement."bench::add_measurements/add_measurement/1::median"]
1679unit = "ns"
1680min_measurements = 3
1681"#;
1682            fs::write(&workspace_config_path, local_config).unwrap();
1683
1684            assert_eq!(
1685                super::measurement_unit("with_colon::name"),
1686                Some("ns".to_string()),
1687                "double-colon in name"
1688            );
1689            assert_eq!(
1690                super::measurement_unit("with/slash"),
1691                Some("ms".to_string()),
1692                "slash in name"
1693            );
1694            assert_eq!(
1695                super::measurement_unit("bench::add_measurements/add_measurement/1::median"),
1696                Some("ns".to_string()),
1697                "full benchmark name"
1698            );
1699        });
1700    }
1701}