Skip to main content

gitversion_rs/config/
effective.rs

1//! Resolution of the final (effective) configuration applied to a specific branch.
2//!
3//! Simplified port of the inheritance/merge rules from the original
4//! `GitVersion.Core/Configuration/EffectiveConfiguration.cs` and
5//! `EffectiveBranchConfigurationFinder.cs`.
6
7use super::model::*;
8use regex::Regex;
9
10/// Return the branch-config key and its configuration that match `branch_name`.
11/// Concrete (non-`unknown`) branches take priority; falls back to `unknown` when nothing else matches.
12pub fn find_branch_config<'a>(
13    config: &'a GitVersionConfiguration,
14    branch_name: &str,
15) -> Option<(String, &'a BranchConfiguration)> {
16    let short = branch_name.rsplit('/').next().unwrap_or(branch_name);
17    let mut unknown: Option<(String, &BranchConfiguration)> = None;
18    for (key, bc) in &config.branches {
19        let Some(re_src) = &bc.regex else { continue };
20        if re_src.is_empty() {
21            continue;
22        }
23        let Ok(re) = Regex::new(&format!("(?i){re_src}")) else {
24            continue;
25        };
26        if re.is_match(branch_name) || re.is_match(short) {
27            if key == "unknown" {
28                unknown = Some((key.clone(), bc));
29            } else {
30                return Some((key.clone(), bc));
31            }
32        }
33    }
34    unknown
35}
36
37/// Normalise next-version. The original `GitVersionConfiguration.NextVersion` setter
38/// coerces plain integers to `"{major}.0"` (e.g. "1" becomes "1.0", "2" becomes "2.0"); all other values are kept as-is.
39fn normalize_next_version(value: &str) -> String {
40    match value.trim().parse::<i64>() {
41        Ok(major) => format!("{major}.0"),
42        Err(_) => value.to_string(),
43    }
44}
45
46/// Substitute `{token}` placeholders in a label. Ports `GetBranchSpecificLabel` +
47/// `BuildLabelPlaceholders` + `StringFormatWith` from the original:
48/// - Placeholders come exclusively from **named captures** in the branch regex; each captured
49///   value is passed through SanitizeName (`[^a-zA-Z0-9-]` → `-`) (BuildLabelPlaceholders).
50/// - Tokens absent from the placeholder map are **left as-is** (FormatWith). Therefore
51///   `{BranchName}` stays literal for a regex without named captures (e.g. a custom `^custom/`) —
52///   matching the original. No fallback to the last branch-name segment is performed.
53/// - No additional sanitisation is applied to the final label (the original does not do it either).
54fn resolve_label(label: &str, regex_src: &Option<String>, branch_name: &str) -> String {
55    let sanitize = |s: &str| {
56        Regex::new(r"[^a-zA-Z0-9-]")
57            .unwrap()
58            .replace_all(s, "-")
59            .into_owned()
60    };
61
62    // BuildLabelPlaceholders: no placeholders when the regex or branch name is empty.
63    let mut captures: std::collections::HashMap<String, String> = std::collections::HashMap::new();
64    if let Some(src) = regex_src {
65        if !src.trim().is_empty() && !branch_name.is_empty() {
66            if let Ok(re) = Regex::new(&format!("(?i){src}")) {
67                if let Some(caps) = re.captures(branch_name) {
68                    for name in re.capture_names().flatten() {
69                        if let Some(m) = caps.name(name) {
70                            captures.insert(name.to_string(), sanitize(m.as_str()));
71                        }
72                    }
73                }
74            }
75        }
76    }
77
78    let token_re = Regex::new(r"\{([^}]+)\}").unwrap();
79    token_re
80        .replace_all(label, |c: &regex::Captures| {
81            let whole = c[0].to_string();
82            let inner = c[1].trim();
83            // Split `?? "fallback"`.
84            let (expr, fallback) = match inner.split_once("??") {
85                Some((l, r)) => (l.trim(), Some(r.trim().trim_matches('"').to_string())),
86                None => (inner, None),
87            };
88            // Strip `:format` specifier (only the name is used).
89            let name = expr.split(':').next().unwrap_or(expr).trim();
90            let resolved = if let Some(var) = expr.strip_prefix("env:") {
91                let var = var.split("??").next().unwrap_or(var).trim();
92                std::env::var(var).ok().filter(|v| !v.is_empty())
93            } else {
94                captures.get(name).cloned()
95            };
96            // Resolution failure with no explicit fallback — keep the original token as a literal.
97            resolved.or(fallback).unwrap_or(whole)
98        })
99        .into_owned()
100}
101
102/// Inherit label from source-branch parents when none is set locally (mirrors the original
103/// `BranchConfiguration.Inherit` rule: `Label = Label ?? parent.Label`). Uses the branch's
104/// own label when defined; otherwise walks source parents and returns the first defined label.
105/// Returns None when no label is found (the caller then uses the global fallback).
106fn inherit_label(
107    config: &GitVersionConfiguration,
108    bc: &BranchConfiguration,
109    depth: usize,
110) -> Option<String> {
111    if let Some(l) = &bc.label {
112        return Some(l.clone());
113    }
114    if depth > 8 {
115        return None;
116    }
117    for src in &bc.source_branches {
118        if let Some(src_bc) = config.branches.get(src) {
119            if let Some(l) = inherit_label(config, src_bc, depth + 1) {
120                return Some(l);
121            }
122        }
123    }
124    None
125}
126
127/// Resolve an `Inherit` increment by walking the source-branch chain.
128pub(crate) fn resolve_increment(
129    config: &GitVersionConfiguration,
130    bc: &BranchConfiguration,
131    depth: usize,
132) -> IncrementStrategy {
133    let own = bc
134        .increment
135        .or(config.increment)
136        .unwrap_or(IncrementStrategy::Inherit);
137    if own != IncrementStrategy::Inherit {
138        return own;
139    }
140    // Per the original EffectiveBranchConfigurationFinder: Inherit is resolved by walking
141    // source-branch parents. If still unresolved, it would remain Inherit and become None
142    // (no increment) in the ToVersionField step. We resolve to None rather than adding an
143    // arbitrary Patch fallback.
144    if depth > 8 {
145        return IncrementStrategy::None;
146    }
147    for src in &bc.source_branches {
148        if let Some(src_bc) = config.branches.get(src) {
149            let resolved = resolve_increment(config, src_bc, depth + 1);
150            if resolved != IncrementStrategy::Inherit {
151                return resolved;
152            }
153        }
154    }
155    IncrementStrategy::None
156}
157
158/// All configuration values flattened to those effective for a given branch.
159#[derive(Debug, Clone)]
160pub struct EffectiveConfiguration {
161    pub branch_key: String,
162    pub deployment_mode: DeploymentMode,
163    pub label: String,
164    pub increment: IncrementStrategy,
165    pub regex: Option<String>,
166    pub prevent_increment_of_merged_branch: bool,
167    pub prevent_increment_when_branch_merged: bool,
168    pub prevent_increment_when_current_commit_tagged: bool,
169    pub track_merge_target: bool,
170    pub track_merge_message: bool,
171    pub tracks_release_branches: bool,
172    pub is_release_branch: bool,
173    pub is_main_branch: bool,
174    pub pre_release_weight: i64,
175    pub tag_pre_release_weight: i64,
176    pub commit_message_incrementing: CommitMessageIncrementMode,
177    pub major_bump_message: String,
178    pub minor_bump_message: String,
179    pub patch_bump_message: String,
180    pub no_bump_message: String,
181    pub tag_prefix: String,
182    pub version_in_branch_pattern: String,
183    pub next_version: Option<String>,
184    pub semantic_version_format: SemanticVersionFormat,
185    pub commit_date_format: String,
186    pub assembly_versioning_scheme: VersioningScheme,
187    pub assembly_file_versioning_scheme: VersioningScheme,
188    pub assembly_informational_format: String,
189    pub assembly_versioning_format: Option<String>,
190    pub assembly_file_versioning_format: Option<String>,
191    pub merge_message_formats: std::collections::BTreeMap<String, String>,
192    pub source_branches: Vec<String>,
193    /// Regex for extracting a number from the pre-release label.
194    pub label_number_pattern: String,
195}
196
197impl EffectiveConfiguration {
198    /// Merge the global configuration with the matched branch configuration to produce the effective configuration.
199    pub fn resolve(config: &GitVersionConfiguration, branch_name: &str) -> Self {
200        let matched = find_branch_config(config, branch_name);
201        let (branch_key, bc): (String, BranchConfiguration) = match matched {
202            Some((k, b)) => (k, b.clone()),
203            None => ("unknown".into(), BranchConfiguration::default()),
204        };
205
206        // null-coalescing: branch value takes priority, falls back to global.
207        let pi_branch = bc.prevent_increment.clone().unwrap_or_default();
208        let pi_global = config.prevent_increment.clone().unwrap_or_default();
209        let coalesce_bool = |b: Option<bool>, g: Option<bool>| b.or(g).unwrap_or(false);
210
211        let raw_label = inherit_label(config, &bc, 0)
212            .or_else(|| config.label.clone())
213            .unwrap_or_default();
214        let label = resolve_label(&raw_label, &bc.regex, branch_name);
215
216        EffectiveConfiguration {
217            deployment_mode: bc
218                .mode
219                .or(config.mode)
220                .unwrap_or(DeploymentMode::ContinuousDelivery),
221            label,
222            increment: resolve_increment(config, &bc, 0),
223            regex: bc.regex.clone(),
224            prevent_increment_of_merged_branch: coalesce_bool(
225                pi_branch.of_merged_branch,
226                pi_global.of_merged_branch,
227            ),
228            prevent_increment_when_branch_merged: coalesce_bool(
229                pi_branch.when_branch_merged,
230                pi_global.when_branch_merged,
231            ),
232            prevent_increment_when_current_commit_tagged: pi_branch
233                .when_current_commit_tagged
234                .or(pi_global.when_current_commit_tagged)
235                .unwrap_or(true),
236            track_merge_target: coalesce_bool(bc.track_merge_target, config.track_merge_target),
237            track_merge_message: bc
238                .track_merge_message
239                .or(config.track_merge_message)
240                .unwrap_or(true),
241            tracks_release_branches: coalesce_bool(
242                bc.tracks_release_branches,
243                config.tracks_release_branches,
244            ),
245            is_release_branch: coalesce_bool(bc.is_release_branch, config.is_release_branch),
246            is_main_branch: coalesce_bool(bc.is_main_branch, config.is_main_branch),
247            pre_release_weight: bc
248                .pre_release_weight
249                .or(config.pre_release_weight)
250                .unwrap_or(0),
251            tag_pre_release_weight: config.tag_pre_release_weight.unwrap_or(60000),
252            commit_message_incrementing: bc
253                .commit_message_incrementing
254                .or(config.commit_message_incrementing)
255                .unwrap_or(CommitMessageIncrementMode::Enabled),
256            major_bump_message: config
257                .major_version_bump_message
258                .clone()
259                .unwrap_or_else(|| r"\+semver:\s?(breaking|major)".into()),
260            minor_bump_message: config
261                .minor_version_bump_message
262                .clone()
263                .unwrap_or_else(|| r"\+semver:\s?(feature|minor)".into()),
264            patch_bump_message: config
265                .patch_version_bump_message
266                .clone()
267                .unwrap_or_else(|| r"\+semver:\s?(fix|patch)".into()),
268            no_bump_message: config
269                .no_bump_message
270                .clone()
271                .unwrap_or_else(|| r"\+semver:\s?(none|skip)".into()),
272            tag_prefix: config.tag_prefix.clone().unwrap_or_else(|| "[vV]?".into()),
273            version_in_branch_pattern: config
274                .version_in_branch_pattern
275                .clone()
276                .unwrap_or_else(|| r"(?<version>[vV]?\d+(\.\d+)?(\.\d+)?).*".into()),
277            next_version: config.next_version.as_deref().map(normalize_next_version),
278            semantic_version_format: config
279                .semantic_version_format
280                .unwrap_or(SemanticVersionFormat::Strict),
281            commit_date_format: config
282                .commit_date_format
283                .clone()
284                .unwrap_or_else(|| "yyyy-MM-dd".into()),
285            assembly_versioning_scheme: config
286                .assembly_versioning_scheme
287                .unwrap_or(VersioningScheme::MajorMinorPatch),
288            assembly_file_versioning_scheme: config
289                .assembly_file_versioning_scheme
290                .unwrap_or(VersioningScheme::MajorMinorPatch),
291            assembly_informational_format: config
292                .assembly_informational_format
293                .clone()
294                .unwrap_or_else(|| "{InformationalVersion}".into()),
295            assembly_versioning_format: config.assembly_versioning_format.clone(),
296            assembly_file_versioning_format: config.assembly_file_versioning_format.clone(),
297            merge_message_formats: config.merge_message_formats.clone(),
298            source_branches: bc.source_branches.clone(),
299            label_number_pattern: bc
300                .label_number_pattern
301                .clone()
302                .or_else(|| config.label_number_pattern.clone())
303                .unwrap_or_else(|| r"(?<name>.*?)\.?(?<number>\d+)?$".into()),
304            branch_key,
305        }
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::config::defaults;
313
314    #[test]
315    fn find_branch_config_main_matches() {
316        let cfg = defaults::gitflow();
317        let (key, _) = find_branch_config(&cfg, "main").unwrap();
318        assert_eq!(key, "main");
319    }
320
321    #[test]
322    fn find_branch_config_feature_matches() {
323        let cfg = defaults::gitflow();
324        let (key, _) = find_branch_config(&cfg, "feature/foo").unwrap();
325        assert_eq!(key, "feature");
326    }
327
328    #[test]
329    fn find_branch_config_no_match_returns_unknown() {
330        let cfg = defaults::gitflow();
331        // "totally-unknown" should not match any pattern, so the "unknown" entry is returned.
332        let result = find_branch_config(&cfg, "totally-unknown-xyz-branch");
333        // If the "unknown" key exists, it is returned; otherwise None.
334        if let Some((key, _)) = result {
335            assert_eq!(key, "unknown");
336        }
337    }
338
339    #[test]
340    fn find_branch_config_short_name_matching() {
341        let cfg = defaults::gitflow();
342        // Long names such as "refs/heads/develop" should also match via the short form ("develop").
343        let result = find_branch_config(&cfg, "refs/heads/develop");
344        assert!(result.is_some());
345        let (key, _) = result.unwrap();
346        assert_eq!(key, "develop");
347    }
348
349    #[test]
350    fn normalize_next_version_pads_integer() {
351        // Original setter: integers are coerced to "{major}.0", everything else is kept as-is.
352        assert_eq!(normalize_next_version("1"), "1.0");
353        assert_eq!(normalize_next_version("2"), "2.0");
354        assert_eq!(normalize_next_version("1.0"), "1.0");
355        assert_eq!(normalize_next_version("1.2.3"), "1.2.3");
356        assert_eq!(normalize_next_version("1.0.0-beta"), "1.0.0-beta");
357    }
358
359    #[test]
360    fn resolve_label_branch_name_capture() {
361        let cfg = defaults::gitflow();
362        // feature/my-feat → the {BranchName} capture is substituted with "my-feat".
363        let eff = EffectiveConfiguration::resolve(&cfg, "feature/my-feat");
364        assert_eq!(eff.label, "my-feat");
365    }
366
367    #[test]
368    fn resolve_label_unmatched_token_stays_literal() {
369        // Regex without named captures: {BranchName} is not substituted and stays as a literal
370        // (original FormatWith: tokens with no matching placeholder are kept). No segment fallback.
371        let r = resolve_label("{BranchName}", &Some("^custom/".into()), "custom/x");
372        assert_eq!(r, "{BranchName}");
373        // With named captures, substitution + SanitizeName is applied.
374        let r = resolve_label(
375            "{BranchName}",
376            &Some(r"^features?[/-](?<BranchName>.+)".into()),
377            "feature/a_b",
378        );
379        assert_eq!(r, "a-b");
380    }
381
382    #[test]
383    fn resolve_label_slash_dot_sanitized() {
384        let cfg = defaults::gitflow();
385        // feature/my.feature → BranchName = "my.feature" → label = "my-feature" ("." → "-").
386        let eff = EffectiveConfiguration::resolve(&cfg, "feature/my.feature");
387        assert_eq!(eff.label, "my-feature");
388    }
389
390    #[test]
391    fn resolve_increment_inherit_falls_back_to_patch() {
392        let cfg = defaults::gitflow();
393        // develop is Inherit, so it inherits Patch from its source branch (main).
394        let eff = EffectiveConfiguration::resolve(&cfg, "develop");
395        assert_eq!(eff.increment, crate::config::IncrementStrategy::Minor);
396    }
397
398    #[test]
399    fn resolve_sets_is_main_branch_for_main() {
400        let cfg = defaults::gitflow();
401        let eff = EffectiveConfiguration::resolve(&cfg, "main");
402        assert!(eff.is_main_branch);
403    }
404
405    #[test]
406    fn resolve_sets_is_release_branch_for_release() {
407        let cfg = defaults::gitflow();
408        let eff = EffectiveConfiguration::resolve(&cfg, "release/1.0.0");
409        assert!(eff.is_release_branch);
410    }
411
412    #[test]
413    fn resolve_hotfix_inherits_patch() {
414        let cfg = defaults::gitflow();
415        let eff = EffectiveConfiguration::resolve(&cfg, "hotfix/1.0.1");
416        assert_eq!(eff.increment, crate::config::IncrementStrategy::Patch);
417    }
418}