Skip to main content

gitversion_rs/config/
loader.rs

1//! Configuration file discovery, YAML loading, and default merging.
2//!
3//! Corresponds to the original `GitVersion.Configuration/ConfigurationFileLocator.cs`
4//! and `ConfigurationProvider.cs`.
5
6use super::{defaults, model::*};
7use anyhow::{Context, Result};
8use rust_i18n::t;
9use std::path::{Path, PathBuf};
10
11/// Configuration file names searched in priority order.
12///
13/// Matching is case-insensitive (see `locate`), mirroring upstream's
14/// `StringComparison.OrdinalIgnoreCase`, so e.g. `.gitversion.yml` is also recognised
15/// (GitVersion's own repository uses that lowercase spelling).
16const CANDIDATES: [&str; 4] = [
17    "GitVersion.yml",
18    "GitVersion.yaml",
19    ".GitVersion.yml",
20    ".GitVersion.yaml",
21];
22
23/// Search for a configuration file in `dir` and `repo_root`.
24///
25/// Each directory's entries are matched against `CANDIDATES` case-insensitively, so the
26/// canonical names are found regardless of spelling and regardless of whether the
27/// filesystem is case-sensitive (e.g. `.gitversion.yml` on Linux).
28pub fn locate(dir: &Path, repo_root: Option<&Path>) -> Option<PathBuf> {
29    let mut search_dirs = vec![dir.to_path_buf()];
30    if let Some(root) = repo_root {
31        if root != dir {
32            search_dirs.push(root.to_path_buf());
33        }
34    }
35    for d in search_dirs {
36        // Snapshot the directory's files once, then resolve candidates by priority.
37        let Ok(entries) = std::fs::read_dir(&d) else {
38            continue;
39        };
40        let files: Vec<(String, PathBuf)> = entries
41            .flatten()
42            .filter(|e| e.path().is_file())
43            .filter_map(|e| e.file_name().to_str().map(|s| (s.to_string(), e.path())))
44            .collect();
45        for cand in CANDIDATES {
46            if let Some((_, path)) = files
47                .iter()
48                .find(|(name, _)| name.eq_ignore_ascii_case(cand))
49            {
50                return Some(path.clone());
51            }
52        }
53    }
54    None
55}
56
57/// Returns true when the workflow value looks like a file path.
58///
59/// Treated as a file path when it starts with `./`, `../`, or `/`, or ends with `.yml` / `.yaml`.
60fn is_workflow_file_path(s: &str) -> bool {
61    s.starts_with("./")
62        || s.starts_with("../")
63        || s.starts_with('/')
64        || s.ends_with(".yml")
65        || s.ends_with(".yaml")
66}
67
68/// Load an external workflow file and return it as the base configuration.
69///
70/// Relative paths are resolved against `config_dir` (the directory containing the config file).
71fn load_workflow_file(wf_path: &str, config_dir: &Path) -> Result<GitVersionConfiguration> {
72    let abs = if Path::new(wf_path).is_absolute() {
73        Path::new(wf_path).to_path_buf()
74    } else {
75        config_dir.join(wf_path)
76    };
77    let text = std::fs::read_to_string(&abs)
78        .with_context(|| t!("config.read_failed", path = abs.display()))?;
79    serde_yaml::from_str(&text)
80        .with_context(|| t!("config.yaml_parse_failed", path = abs.display()))
81}
82
83/// Load configuration from an explicit path or by searching, then merge with workflow defaults.
84pub fn load(
85    explicit_path: Option<&Path>,
86    work_dir: &Path,
87    repo_root: Option<&Path>,
88) -> Result<GitVersionConfiguration> {
89    let path = match explicit_path {
90        Some(p) => Some(p.to_path_buf()),
91        None => locate(work_dir, repo_root),
92    };
93
94    let Some(path) = path else {
95        // No config file found — fall back to GitFlow defaults.
96        return Ok(defaults::gitflow());
97    };
98
99    let text = std::fs::read_to_string(&path)
100        .with_context(|| t!("config.read_failed", path = path.display()))?;
101    let overrides: GitVersionConfiguration = serde_yaml::from_str(&text)
102        .with_context(|| t!("config.yaml_parse_failed", path = path.display()))?;
103
104    // When the workflow value is a file path, load that file as the base configuration.
105    let config_dir = path.parent().unwrap_or(work_dir);
106    let mut base = match overrides.workflow.as_deref() {
107        Some(wf) if is_workflow_file_path(wf) => load_workflow_file(wf, config_dir)?,
108        wf => defaults::for_workflow(wf),
109    };
110    merge(&mut base, overrides);
111    apply_source_branch_mappings(&mut base);
112    validate(&base).with_context(|| t!("config.validate_failed", path = path.display()))?;
113    Ok(base)
114}
115
116/// Validate configuration (mirrors the original `ConfigurationBuilderBase.ValidateConfiguration`).
117///
118/// Every branch must have a `regex`, and `source-branches` may only reference configured branches.
119/// Violations return an error (the original throws `ConfigurationException`).
120pub fn validate(config: &GitVersionConfiguration) -> Result<()> {
121    const HELP: &str = "\nSee https://gitversion.net/docs/reference/configuration for more info";
122    for (name, bc) in &config.branches {
123        if bc.regex.is_none() {
124            anyhow::bail!(
125                "Branch configuration '{name}' is missing required configuration 'regex'{HELP}"
126            );
127        }
128        let missing: Vec<&str> = bc
129            .source_branches
130            .iter()
131            .filter(|sb| !config.branches.contains_key(*sb))
132            .map(|s| s.as_str())
133            .collect();
134        if !missing.is_empty() {
135            anyhow::bail!(
136                "Branch configuration '{name}' defines these 'source-branches' that are not configured: '[{}]'{HELP}",
137                missing.join(",")
138            );
139        }
140    }
141    Ok(())
142}
143
144/// Reverse-map `is-source-branch-for`: if branch A declares `is-source-branch-for: [X]`,
145/// A is added to X's `source-branches` (mirrors the original `ApplySourceBranchesSourceBranch`).
146pub fn apply_source_branch_mappings(config: &mut GitVersionConfiguration) {
147    let mappings: Vec<(String, Vec<String>)> = config
148        .branches
149        .iter()
150        .filter(|(_, b)| !b.is_source_branch_for.is_empty())
151        .map(|(k, b)| (k.clone(), b.is_source_branch_for.clone()))
152        .collect();
153    for (source, targets) in mappings {
154        for target in targets {
155            if let Some(tb) = config.branches.get_mut(&target) {
156                if !tb.source_branches.contains(&source) {
157                    tb.source_branches.push(source.clone());
158                }
159            }
160        }
161    }
162}
163
164/// Overlay `over` onto `base` (only Some / non-empty values are applied).
165pub fn merge(base: &mut GitVersionConfiguration, over: GitVersionConfiguration) {
166    macro_rules! ov {
167        ($field:ident) => {
168            if over.$field.is_some() {
169                base.$field = over.$field;
170            }
171        };
172    }
173    ov!(workflow);
174    ov!(assembly_versioning_scheme);
175    ov!(assembly_file_versioning_scheme);
176    ov!(assembly_informational_format);
177    ov!(assembly_versioning_format);
178    ov!(assembly_file_versioning_format);
179    ov!(tag_prefix);
180    ov!(version_in_branch_pattern);
181    ov!(next_version);
182    ov!(major_version_bump_message);
183    ov!(minor_version_bump_message);
184    ov!(patch_version_bump_message);
185    ov!(no_bump_message);
186    ov!(tag_pre_release_weight);
187    ov!(commit_date_format);
188    ov!(semantic_version_format);
189    ov!(update_build_number);
190    ov!(increment);
191    ov!(mode);
192    ov!(label);
193    ov!(regex);
194    ov!(commit_message_incrementing);
195    ov!(prevent_increment);
196    ov!(track_merge_target);
197    ov!(track_merge_message);
198    ov!(tracks_release_branches);
199    ov!(is_release_branch);
200    ov!(is_main_branch);
201    ov!(pre_release_weight);
202    ov!(label_number_pattern);
203
204    if !over.strategies.is_empty() {
205        base.strategies = over.strategies;
206    }
207    if !over.source_branches.is_empty() {
208        base.source_branches = over.source_branches;
209    }
210    if !over.is_source_branch_for.is_empty() {
211        base.is_source_branch_for = over.is_source_branch_for;
212    }
213    if over.ignore.commits_before.is_some()
214        || !over.ignore.sha.is_empty()
215        || !over.ignore.paths.is_empty()
216    {
217        base.ignore = over.ignore;
218    }
219    if !over.merge_message_formats.is_empty() {
220        base.merge_message_formats
221            .extend(over.merge_message_formats);
222    }
223    if !over.exec.is_empty() {
224        base.exec.extend(over.exec);
225    }
226
227    // Per-branch merge.
228    for (key, ob) in over.branches {
229        let entry = base.branches.entry(key).or_default();
230        merge_branch(entry, ob);
231    }
232}
233
234fn merge_branch(base: &mut BranchConfiguration, over: BranchConfiguration) {
235    macro_rules! ov {
236        ($field:ident) => {
237            if over.$field.is_some() {
238                base.$field = over.$field;
239            }
240        };
241    }
242    ov!(regex);
243    ov!(label);
244    ov!(increment);
245    ov!(mode);
246    ov!(commit_message_incrementing);
247    ov!(prevent_increment);
248    ov!(track_merge_target);
249    ov!(track_merge_message);
250    ov!(tracks_release_branches);
251    ov!(is_release_branch);
252    ov!(is_main_branch);
253    ov!(pre_release_weight);
254    ov!(label_number_pattern);
255    if !over.source_branches.is_empty() {
256        base.source_branches = over.source_branches;
257    }
258    if !over.is_source_branch_for.is_empty() {
259        base.is_source_branch_for = over.is_source_branch_for;
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    fn config_from(yaml: &str) -> GitVersionConfiguration {
268        let over: GitVersionConfiguration = serde_yaml::from_str(yaml).unwrap();
269        let mut base = defaults::for_workflow(over.workflow.as_deref());
270        merge(&mut base, over);
271        apply_source_branch_mappings(&mut base);
272        base
273    }
274
275    #[test]
276    fn validate_rejects_missing_regex() {
277        let c = config_from("branches:\n  custom:\n    label: x\n");
278        let err = validate(&c).unwrap_err().to_string();
279        assert!(err.contains("'custom'") && err.contains("'regex'"), "{err}");
280    }
281
282    #[test]
283    fn validate_rejects_unknown_source_branch() {
284        let c =
285            config_from("branches:\n  custom:\n    regex: '^c$'\n    source-branches: [nope]\n");
286        let err = validate(&c).unwrap_err().to_string();
287        assert!(
288            err.contains("not configured") && err.contains("nope"),
289            "{err}"
290        );
291    }
292
293    #[test]
294    fn validate_accepts_defaults_and_valid_custom() {
295        assert!(validate(&defaults::gitflow()).is_ok());
296        assert!(validate(&defaults::githubflow()).is_ok());
297        let c =
298            config_from("branches:\n  custom:\n    regex: '^c$'\n    source-branches: [main]\n");
299        assert!(validate(&c).is_ok());
300    }
301
302    fn unique_temp_dir(tag: &str) -> PathBuf {
303        let nanos = std::time::SystemTime::now()
304            .duration_since(std::time::UNIX_EPOCH)
305            .unwrap()
306            .as_nanos();
307        let dir =
308            std::env::temp_dir().join(format!("gv-loader-{tag}-{}-{nanos}", std::process::id()));
309        std::fs::create_dir_all(&dir).unwrap();
310        dir
311    }
312
313    #[test]
314    fn locate_matches_lowercase_dotted_name() {
315        // GitVersion's own repo uses the all-lowercase `.gitversion.yml`; it must be found.
316        let dir = unique_temp_dir("lower");
317        std::fs::write(dir.join(".gitversion.yml"), "workflow: GitHubFlow/v1\n").unwrap();
318        let found = locate(&dir, None).expect("config should be located");
319        assert_eq!(found.file_name().unwrap(), ".gitversion.yml");
320        std::fs::remove_dir_all(&dir).ok();
321    }
322
323    #[test]
324    fn locate_matches_yaml_extension() {
325        // The `.yaml` extension (here lowercase, non-dotted) is recognised too.
326        let dir = unique_temp_dir("yaml");
327        std::fs::write(dir.join("gitversion.yaml"), "workflow: GitHubFlow/v1\n").unwrap();
328        let found = locate(&dir, None).expect("yaml config should be located");
329        assert_eq!(found.file_name().unwrap(), "gitversion.yaml");
330        std::fs::remove_dir_all(&dir).ok();
331    }
332
333    #[test]
334    fn locate_prefers_non_dotted_by_priority() {
335        // Both present (case-insensitive): the non-dotted candidate wins regardless of casing.
336        let dir = unique_temp_dir("priority");
337        std::fs::write(dir.join("gitversion.yml"), "workflow: GitHubFlow/v1\n").unwrap();
338        std::fs::write(dir.join(".gitversion.yaml"), "workflow: GitHubFlow/v1\n").unwrap();
339        let found = locate(&dir, None).unwrap();
340        assert_eq!(found.file_name().unwrap(), "gitversion.yml");
341        std::fs::remove_dir_all(&dir).ok();
342    }
343
344    #[test]
345    fn source_branch_reverse_mapping() {
346        let c = config_from(
347            "branches:\n  myfeat:\n    regex: '^myfeat$'\n    is-source-branch-for: [main]\n",
348        );
349        assert!(c.branches["main"]
350            .source_branches
351            .contains(&"myfeat".to_string()));
352    }
353
354    #[test]
355    fn label_number_pattern_yaml_roundtrip() {
356        // label-number-pattern is parsed from YAML and applied to the branch.
357        let c = config_from(
358            "branches:\n  main:\n    regex: '^main$'\n    label-number-pattern: '[0-9]+'\n",
359        );
360        assert_eq!(
361            c.branches["main"].label_number_pattern.as_deref(),
362            Some("[0-9]+")
363        );
364    }
365
366    #[test]
367    fn workflow_file_path_detection() {
368        assert!(is_workflow_file_path("./my-workflow.yml"));
369        assert!(is_workflow_file_path("../shared/gitversion.yaml"));
370        assert!(is_workflow_file_path("/absolute/path.yml"));
371        assert!(is_workflow_file_path("some-file.yml"));
372        assert!(is_workflow_file_path("some-file.yaml"));
373        assert!(!is_workflow_file_path("GitFlow/v1"));
374        assert!(!is_workflow_file_path("GitHubFlow/v1"));
375        assert!(!is_workflow_file_path("TrunkBased/preview1"));
376    }
377}