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 order.
12const CANDIDATES: [&str; 4] = [
13    "GitVersion.yml",
14    "GitVersion.yaml",
15    ".GitVersion.yml",
16    ".GitVersion.yaml",
17];
18
19/// Search for a configuration file in `dir` and `repo_root`.
20pub fn locate(dir: &Path, repo_root: Option<&Path>) -> Option<PathBuf> {
21    let mut search_dirs = vec![dir.to_path_buf()];
22    if let Some(root) = repo_root {
23        if root != dir {
24            search_dirs.push(root.to_path_buf());
25        }
26    }
27    for d in search_dirs {
28        for name in CANDIDATES {
29            let p = d.join(name);
30            if p.is_file() {
31                return Some(p);
32            }
33        }
34    }
35    None
36}
37
38/// Returns true when the workflow value looks like a file path.
39///
40/// Treated as a file path when it starts with `./`, `../`, or `/`, or ends with `.yml` / `.yaml`.
41fn is_workflow_file_path(s: &str) -> bool {
42    s.starts_with("./")
43        || s.starts_with("../")
44        || s.starts_with('/')
45        || s.ends_with(".yml")
46        || s.ends_with(".yaml")
47}
48
49/// Load an external workflow file and return it as the base configuration.
50///
51/// Relative paths are resolved against `config_dir` (the directory containing the config file).
52fn load_workflow_file(wf_path: &str, config_dir: &Path) -> Result<GitVersionConfiguration> {
53    let abs = if Path::new(wf_path).is_absolute() {
54        Path::new(wf_path).to_path_buf()
55    } else {
56        config_dir.join(wf_path)
57    };
58    let text = std::fs::read_to_string(&abs)
59        .with_context(|| t!("config.read_failed", path = abs.display()))?;
60    serde_yaml::from_str(&text)
61        .with_context(|| t!("config.yaml_parse_failed", path = abs.display()))
62}
63
64/// Load configuration from an explicit path or by searching, then merge with workflow defaults.
65pub fn load(
66    explicit_path: Option<&Path>,
67    work_dir: &Path,
68    repo_root: Option<&Path>,
69) -> Result<GitVersionConfiguration> {
70    let path = match explicit_path {
71        Some(p) => Some(p.to_path_buf()),
72        None => locate(work_dir, repo_root),
73    };
74
75    let Some(path) = path else {
76        // No config file found — fall back to GitFlow defaults.
77        return Ok(defaults::gitflow());
78    };
79
80    let text = std::fs::read_to_string(&path)
81        .with_context(|| t!("config.read_failed", path = path.display()))?;
82    let overrides: GitVersionConfiguration = serde_yaml::from_str(&text)
83        .with_context(|| t!("config.yaml_parse_failed", path = path.display()))?;
84
85    // When the workflow value is a file path, load that file as the base configuration.
86    let config_dir = path.parent().unwrap_or(work_dir);
87    let mut base = match overrides.workflow.as_deref() {
88        Some(wf) if is_workflow_file_path(wf) => load_workflow_file(wf, config_dir)?,
89        wf => defaults::for_workflow(wf),
90    };
91    merge(&mut base, overrides);
92    apply_source_branch_mappings(&mut base);
93    validate(&base).with_context(|| t!("config.validate_failed", path = path.display()))?;
94    Ok(base)
95}
96
97/// Validate configuration (mirrors the original `ConfigurationBuilderBase.ValidateConfiguration`).
98///
99/// Every branch must have a `regex`, and `source-branches` may only reference configured branches.
100/// Violations return an error (the original throws `ConfigurationException`).
101pub fn validate(config: &GitVersionConfiguration) -> Result<()> {
102    const HELP: &str = "\nSee https://gitversion.net/docs/reference/configuration for more info";
103    for (name, bc) in &config.branches {
104        if bc.regex.is_none() {
105            anyhow::bail!(
106                "Branch configuration '{name}' is missing required configuration 'regex'{HELP}"
107            );
108        }
109        let missing: Vec<&str> = bc
110            .source_branches
111            .iter()
112            .filter(|sb| !config.branches.contains_key(*sb))
113            .map(|s| s.as_str())
114            .collect();
115        if !missing.is_empty() {
116            anyhow::bail!(
117                "Branch configuration '{name}' defines these 'source-branches' that are not configured: '[{}]'{HELP}",
118                missing.join(",")
119            );
120        }
121    }
122    Ok(())
123}
124
125/// Reverse-map `is-source-branch-for`: if branch A declares `is-source-branch-for: [X]`,
126/// A is added to X's `source-branches` (mirrors the original `ApplySourceBranchesSourceBranch`).
127pub fn apply_source_branch_mappings(config: &mut GitVersionConfiguration) {
128    let mappings: Vec<(String, Vec<String>)> = config
129        .branches
130        .iter()
131        .filter(|(_, b)| !b.is_source_branch_for.is_empty())
132        .map(|(k, b)| (k.clone(), b.is_source_branch_for.clone()))
133        .collect();
134    for (source, targets) in mappings {
135        for target in targets {
136            if let Some(tb) = config.branches.get_mut(&target) {
137                if !tb.source_branches.contains(&source) {
138                    tb.source_branches.push(source.clone());
139                }
140            }
141        }
142    }
143}
144
145/// Overlay `over` onto `base` (only Some / non-empty values are applied).
146pub fn merge(base: &mut GitVersionConfiguration, over: GitVersionConfiguration) {
147    macro_rules! ov {
148        ($field:ident) => {
149            if over.$field.is_some() {
150                base.$field = over.$field;
151            }
152        };
153    }
154    ov!(workflow);
155    ov!(assembly_versioning_scheme);
156    ov!(assembly_file_versioning_scheme);
157    ov!(assembly_informational_format);
158    ov!(assembly_versioning_format);
159    ov!(assembly_file_versioning_format);
160    ov!(tag_prefix);
161    ov!(version_in_branch_pattern);
162    ov!(next_version);
163    ov!(major_version_bump_message);
164    ov!(minor_version_bump_message);
165    ov!(patch_version_bump_message);
166    ov!(no_bump_message);
167    ov!(tag_pre_release_weight);
168    ov!(commit_date_format);
169    ov!(semantic_version_format);
170    ov!(update_build_number);
171    ov!(increment);
172    ov!(mode);
173    ov!(label);
174    ov!(regex);
175    ov!(commit_message_incrementing);
176    ov!(prevent_increment);
177    ov!(track_merge_target);
178    ov!(track_merge_message);
179    ov!(tracks_release_branches);
180    ov!(is_release_branch);
181    ov!(is_main_branch);
182    ov!(pre_release_weight);
183    ov!(label_number_pattern);
184
185    if !over.strategies.is_empty() {
186        base.strategies = over.strategies;
187    }
188    if !over.source_branches.is_empty() {
189        base.source_branches = over.source_branches;
190    }
191    if !over.is_source_branch_for.is_empty() {
192        base.is_source_branch_for = over.is_source_branch_for;
193    }
194    if over.ignore.commits_before.is_some()
195        || !over.ignore.sha.is_empty()
196        || !over.ignore.paths.is_empty()
197    {
198        base.ignore = over.ignore;
199    }
200    if !over.merge_message_formats.is_empty() {
201        base.merge_message_formats
202            .extend(over.merge_message_formats);
203    }
204    if !over.exec.is_empty() {
205        base.exec.extend(over.exec);
206    }
207
208    // Per-branch merge.
209    for (key, ob) in over.branches {
210        let entry = base.branches.entry(key).or_default();
211        merge_branch(entry, ob);
212    }
213}
214
215fn merge_branch(base: &mut BranchConfiguration, over: BranchConfiguration) {
216    macro_rules! ov {
217        ($field:ident) => {
218            if over.$field.is_some() {
219                base.$field = over.$field;
220            }
221        };
222    }
223    ov!(regex);
224    ov!(label);
225    ov!(increment);
226    ov!(mode);
227    ov!(commit_message_incrementing);
228    ov!(prevent_increment);
229    ov!(track_merge_target);
230    ov!(track_merge_message);
231    ov!(tracks_release_branches);
232    ov!(is_release_branch);
233    ov!(is_main_branch);
234    ov!(pre_release_weight);
235    ov!(label_number_pattern);
236    if !over.source_branches.is_empty() {
237        base.source_branches = over.source_branches;
238    }
239    if !over.is_source_branch_for.is_empty() {
240        base.is_source_branch_for = over.is_source_branch_for;
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn config_from(yaml: &str) -> GitVersionConfiguration {
249        let over: GitVersionConfiguration = serde_yaml::from_str(yaml).unwrap();
250        let mut base = defaults::for_workflow(over.workflow.as_deref());
251        merge(&mut base, over);
252        apply_source_branch_mappings(&mut base);
253        base
254    }
255
256    #[test]
257    fn validate_rejects_missing_regex() {
258        let c = config_from("branches:\n  custom:\n    label: x\n");
259        let err = validate(&c).unwrap_err().to_string();
260        assert!(err.contains("'custom'") && err.contains("'regex'"), "{err}");
261    }
262
263    #[test]
264    fn validate_rejects_unknown_source_branch() {
265        let c =
266            config_from("branches:\n  custom:\n    regex: '^c$'\n    source-branches: [nope]\n");
267        let err = validate(&c).unwrap_err().to_string();
268        assert!(
269            err.contains("not configured") && err.contains("nope"),
270            "{err}"
271        );
272    }
273
274    #[test]
275    fn validate_accepts_defaults_and_valid_custom() {
276        assert!(validate(&defaults::gitflow()).is_ok());
277        assert!(validate(&defaults::githubflow()).is_ok());
278        let c =
279            config_from("branches:\n  custom:\n    regex: '^c$'\n    source-branches: [main]\n");
280        assert!(validate(&c).is_ok());
281    }
282
283    #[test]
284    fn source_branch_reverse_mapping() {
285        let c = config_from(
286            "branches:\n  myfeat:\n    regex: '^myfeat$'\n    is-source-branch-for: [main]\n",
287        );
288        assert!(c.branches["main"]
289            .source_branches
290            .contains(&"myfeat".to_string()));
291    }
292
293    #[test]
294    fn label_number_pattern_yaml_roundtrip() {
295        // label-number-pattern is parsed from YAML and applied to the branch.
296        let c = config_from(
297            "branches:\n  main:\n    regex: '^main$'\n    label-number-pattern: '[0-9]+'\n",
298        );
299        assert_eq!(
300            c.branches["main"].label_number_pattern.as_deref(),
301            Some("[0-9]+")
302        );
303    }
304
305    #[test]
306    fn workflow_file_path_detection() {
307        assert!(is_workflow_file_path("./my-workflow.yml"));
308        assert!(is_workflow_file_path("../shared/gitversion.yaml"));
309        assert!(is_workflow_file_path("/absolute/path.yml"));
310        assert!(is_workflow_file_path("some-file.yml"));
311        assert!(is_workflow_file_path("some-file.yaml"));
312        assert!(!is_workflow_file_path("GitFlow/v1"));
313        assert!(!is_workflow_file_path("GitHubFlow/v1"));
314        assert!(!is_workflow_file_path("TrunkBased/preview1"));
315    }
316}