Skip to main content

gitversion_rs/version/
calculation.rs

1//! Version calculation engine.
2//!
3//! Ports the strategy → increment → selection → deployment-mode pipeline from
4//! the original `GitVersion.Core/VersionCalculation`. Handles common GitFlow/GitHubFlow
5//! scenarios faithfully; Mainline is implemented in a simplified form.
6
7use crate::config::{
8    effective::EffectiveConfiguration, CommitMessageIncrementMode, DeploymentMode,
9    GitVersionConfiguration, IncrementStrategy, SemanticVersionFormat, VersionStrategy,
10    VersioningScheme,
11};
12use crate::git::{CommitInfo, GitRepo};
13use crate::output::variables::VersionVariables;
14use crate::version::{BuildMetaData, PreReleaseTag, SemanticVersion, VersionField};
15use anyhow::Result;
16use chrono::{DateTime, FixedOffset, NaiveDateTime, TimeZone};
17use regex::Regex;
18use std::collections::HashSet;
19
20/// Set of commits excluded from version calculation. Corresponds to the original `ignore` config.
21#[derive(Debug, Clone, Default)]
22struct IgnoreSet {
23    shas: HashSet<String>,
24    before: Option<DateTime<FixedOffset>>,
25    /// Exclude commits that only changed files under these path prefixes (ignore.paths).
26    paths: Vec<String>,
27}
28
29impl IgnoreSet {
30    fn from_config(config: &GitVersionConfiguration) -> Self {
31        let shas: HashSet<String> = config.ignore.sha.iter().map(|s| s.to_lowercase()).collect();
32        let before = config
33            .ignore
34            .commits_before
35            .as_deref()
36            .and_then(parse_ignore_date);
37        let paths = config.ignore.paths.clone();
38        IgnoreSet {
39            shas,
40            before,
41            paths,
42        }
43    }
44
45    fn is_ignored(&self, sha: &str, when: &DateTime<FixedOffset>) -> bool {
46        if self.shas.contains(&sha.to_lowercase()) {
47            return true;
48        }
49        // The entry may be a prefix rather than the full SHA, so check prefix matches too.
50        if self
51            .shas
52            .iter()
53            .any(|s| sha.to_lowercase().starts_with(s.as_str()) && s.len() >= 7)
54        {
55            return true;
56        }
57        matches!(&self.before, Some(b) if when < b)
58    }
59
60    /// Returns true when all files changed by the commit fall under ignored path prefixes.
61    fn is_path_ignored(&self, repo: &crate::git::GitRepo, sha: &str) -> bool {
62        if self.paths.is_empty() {
63            return false;
64        }
65        let changed = repo.changed_paths_for_commit(sha);
66        // Commits with no changed files (e.g. --allow-empty) satisfy vacuous truth:
67        // all (zero) files are under ignored paths, so the commit is ignored (matches .NET GitVersion).
68        if changed.is_empty() {
69            return true;
70        }
71        changed.iter().all(|file| {
72            self.paths.iter().any(|prefix| {
73                let prefix = prefix.trim_end_matches('/');
74                file == prefix || file.starts_with(&format!("{prefix}/"))
75            })
76        })
77    }
78
79    fn filter(&self, repo: &crate::git::GitRepo, commits: Vec<CommitInfo>) -> Vec<CommitInfo> {
80        if self.shas.is_empty() && self.before.is_none() && self.paths.is_empty() {
81            return commits;
82        }
83        commits
84            .into_iter()
85            .filter(|c| !self.is_ignored(&c.sha, &c.when) && !self.is_path_ignored(repo, &c.sha))
86            .collect()
87    }
88}
89
90/// Parse an ignore date in `yyyy-MM-ddTHH:mm:ss` (or date-only) format, assuming UTC.
91fn parse_ignore_date(s: &str) -> Option<DateTime<FixedOffset>> {
92    let s = s.trim();
93    for fmt in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"] {
94        if let Ok(ndt) = NaiveDateTime::parse_from_str(s, fmt) {
95            return Some(chrono::Utc.from_utc_datetime(&ndt).fixed_offset());
96        }
97        if let Ok(d) = chrono::NaiveDate::parse_from_str(s, fmt) {
98            if let Some(ndt) = d.and_hms_opt(0, 0, 0) {
99                return Some(chrono::Utc.from_utc_datetime(&ndt).fixed_offset());
100            }
101        }
102    }
103    None
104}
105
106/// Convert a .NET date format string to a chrono strftime format (common tokens only).
107fn dotnet_date_format_to_strftime(fmt: &str) -> String {
108    // Longer tokens must be replaced first to avoid partial matches.
109    let mut out = fmt.to_string();
110    for (from, to) in [
111        ("yyyy", "%Y"),
112        ("yy", "%y"),
113        ("MMMM", "%B"),
114        ("MMM", "%b"),
115        ("MM", "%m"),
116        ("dddd", "%A"),
117        ("ddd", "%a"),
118        ("dd", "%d"),
119        ("HH", "%H"),
120        ("mm", "%M"),
121        ("ss", "%S"),
122    ] {
123        out = out.replace(from, to);
124    }
125    out
126}
127
128/// A base version candidate produced by one strategy.
129#[derive(Debug, Clone)]
130struct BaseVersion {
131    source: String,
132    semantic_version: SemanticVersion,
133    base_version_source: Option<String>,
134    /// Timestamp of the base-source commit (used to pick the most recent source).
135    source_when: Option<DateTime<FixedOffset>>,
136    increment: VersionField,
137    label: Option<String>,
138    force_increment: bool,
139    /// Use the current commit's tag as-is (no increment / label / deployment mode applied).
140    exact: bool,
141}
142
143impl BaseVersion {
144    fn new(
145        source: impl Into<String>,
146        semantic_version: SemanticVersion,
147        base_version_source: Option<String>,
148        increment: VersionField,
149        label: Option<String>,
150    ) -> Self {
151        Self {
152            source: source.into(),
153            semantic_version,
154            base_version_source,
155            source_when: None,
156            increment,
157            label,
158            force_increment: false,
159            exact: false,
160        }
161    }
162}
163
164/// Result of applying an increment to a candidate.
165#[derive(Debug, Clone)]
166struct NextVersion {
167    incremented: SemanticVersion,
168    base: BaseVersion,
169}
170
171/// Convert an `IncrementStrategy` to a `VersionField`.
172fn strategy_to_field(s: IncrementStrategy) -> VersionField {
173    match s {
174        IncrementStrategy::Major => VersionField::Major,
175        IncrementStrategy::Minor => VersionField::Minor,
176        IncrementStrategy::Patch => VersionField::Patch,
177        IncrementStrategy::None | IncrementStrategy::Inherit => VersionField::None,
178    }
179}
180
181/// Extract the bump field from a single commit message. Returns `None` when no pattern matches.
182fn increment_from_message(msg: &str, eff: &EffectiveConfiguration) -> Option<VersionField> {
183    let test = |pat: &str| {
184        Regex::new(&format!("(?im){pat}"))
185            .map(|r| r.is_match(msg))
186            .unwrap_or(false)
187    };
188    if test(&eff.major_bump_message) {
189        Some(VersionField::Major)
190    } else if test(&eff.minor_bump_message) {
191        Some(VersionField::Minor)
192    } else if test(&eff.patch_bump_message) {
193        Some(VersionField::Patch)
194    } else if test(&eff.no_bump_message) {
195        Some(VersionField::None)
196    } else {
197        None
198    }
199}
200
201/// Determine the increment field from commits between `base_source` (exclusive) and `head`.
202/// Mirrors the original `IncrementStrategyFinder.DetermineIncrementedField`.
203fn determine_increment(
204    repo: &GitRepo,
205    base_source: Option<&str>,
206    head_sha: &str,
207    should_increment: bool,
208    eff: &EffectiveConfiguration,
209    ignore: &IgnoreSet,
210) -> Result<VersionField> {
211    let default_increment = strategy_to_field(eff.increment);
212
213    let message_increment =
214        if eff.commit_message_incrementing == CommitMessageIncrementMode::Disabled {
215            None
216        } else {
217            let commits = ignore.filter(repo, repo.commits_between(base_source, head_sha)?);
218            let merge_only =
219                eff.commit_message_incrementing == CommitMessageIncrementMode::MergeMessageOnly;
220            let mut best: Option<VersionField> = None;
221            for c in &commits {
222                if merge_only && c.parent_count < 2 {
223                    continue;
224                }
225                if let Some(f) = increment_from_message(&c.message, eff) {
226                    best = Some(best.map_or(f, |b| b.max(f)));
227                }
228            }
229            best
230        };
231
232    Ok(match message_increment {
233        None => {
234            if should_increment {
235                default_increment
236            } else {
237                VersionField::None
238            }
239        }
240        Some(mi) => {
241            if should_increment && mi < default_increment {
242                default_increment
243            } else {
244                mi
245            }
246        }
247    })
248}
249
250/// Parse a version string according to the configured `semantic-version-format`.
251fn parse_version(input: &str, eff: &EffectiveConfiguration) -> Option<SemanticVersion> {
252    let strict = eff.semantic_version_format == SemanticVersionFormat::Strict;
253    SemanticVersion::parse_with(input, &eff.tag_prefix, strict)
254}
255
256/// Pre-validate all regex values in the config. The original GitVersion fails the calculation when
257/// it encounters an invalid `tag-prefix` or `*-version-bump-message` regex, so we return an error
258/// rather than silently ignoring them. (`version-in-branch-pattern` is excluded because it is only
259/// used on release branches and would not be validated on main etc.)
260fn validate_config_regexes(eff: &EffectiveConfiguration) -> Result<()> {
261    let check = |label: &str, pat: &str| -> Result<()> {
262        Regex::new(pat)
263            .map(|_| ())
264            .map_err(|e| anyhow::anyhow!("Invalid {label} regex '{pat}': {e}"))
265    };
266    check("tag-prefix", &eff.tag_prefix)?;
267    if eff.commit_message_incrementing != CommitMessageIncrementMode::Disabled {
268        check("major-version-bump-message", &eff.major_bump_message)?;
269        check("minor-version-bump-message", &eff.minor_bump_message)?;
270        check("patch-version-bump-message", &eff.patch_bump_message)?;
271        check("no-bump-message", &eff.no_bump_message)?;
272    }
273    Ok(())
274}
275
276/// Extract a version token from a message or branch name (mirrors the original `ReferenceNameExtensions`).
277///
278/// The original splits the branch name by a separator and matches `^{pattern}` against each part.
279/// The separator is `/` when the text contains `/` or no `-`; otherwise `-`.
280/// The extracted token is parsed according to the configured `semantic-version-format` (Strict/Loose).
281fn extract_version(text: &str, eff: &EffectiveConfiguration) -> Option<SemanticVersion> {
282    let pattern = format!(
283        "(?i)^{}",
284        eff.version_in_branch_pattern.trim_start_matches('^')
285    );
286    let re = Regex::new(&pattern).ok()?;
287    let sep = if text.contains('/') || !text.contains('-') {
288        '/'
289    } else {
290        '-'
291    };
292    for part in text.split(sep) {
293        if part.is_empty() {
294            continue;
295        }
296        if let Some(caps) = re.captures(part) {
297            let raw = caps
298                .name("version")
299                .map(|m| m.as_str())
300                .unwrap_or_else(|| caps.get(0).unwrap().as_str());
301            if let Some(v) = parse_version(raw, eff) {
302                return Some(v);
303            }
304        }
305    }
306    None
307}
308
309/// Resolve an `Inherit` increment via git ancestry. Finds the source branch that the current
310/// branch diverged from most recently (latest merge-base) and returns its increment.
311/// Returns `None` when inheritance is not applicable or no candidate is found (caller keeps existing resolution).
312fn resolve_inherit_via_git(
313    repo: &GitRepo,
314    config: &GitVersionConfiguration,
315    branch_name: &str,
316) -> Result<Option<IncrementStrategy>> {
317    let Some((_, bc)) = crate::config::effective::find_branch_config(config, branch_name) else {
318        return Ok(None);
319    };
320    let own = bc
321        .increment
322        .or(config.increment)
323        .unwrap_or(IncrementStrategy::Inherit);
324    if own != IncrementStrategy::Inherit {
325        return Ok(None);
326    }
327
328    let repo_branches = repo.branch_names().unwrap_or_default();
329    let mut best: Option<(i64, IncrementStrategy)> = None;
330
331    for src_key in &bc.source_branches {
332        let Some(src_bc) = config.branches.get(src_key) else {
333            continue;
334        };
335        let Some(re_src) = &src_bc.regex else {
336            continue;
337        };
338        let Ok(re) = Regex::new(&format!("(?i){re_src}")) else {
339            continue;
340        };
341
342        // Actual repository branches that match this source config entry.
343        for rb in &repo_branches {
344            if rb == branch_name {
345                continue;
346            }
347            let short = rb.rsplit('/').next().unwrap_or(rb);
348            if !(re.is_match(rb) || re.is_match(short)) {
349                continue;
350            }
351            let Some(mb) = repo.merge_base(branch_name, rb)? else {
352                continue;
353            };
354            // A deeper merge-base (farther from the root) means a more recent divergence point.
355            let depth = repo.commits_between(None, &mb)?.len() as i64;
356            // Resolve the source branch's effective increment recursively (mirrors the original:
357            // Inherit walks further up to its parents). Unresolvable cases yield None rather than
358            // an arbitrary Patch fallback.
359            let inc = crate::config::effective::resolve_increment(config, src_bc, 0);
360            if best.map(|(d, _)| depth > d).unwrap_or(true) {
361                best = Some((depth, inc));
362            }
363        }
364    }
365    Ok(best.map(|(_, inc)| inc))
366}
367
368/// Built-in merge message formats (mirrors the original `MergeMessage.cs`). Each format extracts
369/// `SourceBranch`, from which the version is obtained via the `version-in-branch` pattern.
370const BUILTIN_MERGE_FORMATS: &[&str] = &[
371    // Default
372    r"^Merge (branch|tag) '(?<SourceBranch>[^']*)'(?: into (?<TargetBranch>[^\s]*))*",
373    // SmartGit
374    r"^Finish (?<SourceBranch>[^\s]*)(?: into (?<TargetBranch>[^\s]*))*",
375    // BitBucketPull
376    r"^Merge pull request #(?<PullRequestNumber>\d+) (from|in) (?<Source>.*) from (?<SourceBranch>[^\s]*) to (?<TargetBranch>[^\s]*)",
377    // BitBucketPullv7 (multiline: "Pull request #N\n\nMerge in X from Y to Z").
378    // (?s) applies globally, so the first line/Source is restricted to [^\r\n] to match .NET behaviour.
379    r"^Pull request #(?<PullRequestNumber>\d+)[^\r\n]*\r?\n\r?\nMerge in (?<Source>[^\r\n]*) from (?<SourceBranch>[^\s]*) to (?<TargetBranch>[^\s]*)",
380    // BitBucketCloudPull
381    r"^Merged in (?<SourceBranch>[^\s]*) \(pull request #(?<PullRequestNumber>\d+)\)",
382    // GitHubPull
383    r"^Merge pull request #(?<PullRequestNumber>\d+) (from|in) (?:[^\s/]+/)?(?<SourceBranch>[^\s]*)(?: into (?<TargetBranch>[^\s]*))*",
384    // RemoteTracking
385    r"^Merge remote-tracking branch '(?<SourceBranch>[^\s]*)'(?: into (?<TargetBranch>[^\s]*))*",
386    // AzureDevOpsPull
387    r"^Merge pull request (?<PullRequestNumber>\d+) from (?<SourceBranch>[^\s]*) into (?<TargetBranch>[^\s]*)",
388];
389
390/// Parse a merge message and return `(merged branch name, extracted version)`.
391/// Tries the user-defined `merge-message-formats` first, then the 8 built-in formats.
392fn parse_merge_message(
393    message: &str,
394    eff: &EffectiveConfiguration,
395) -> Option<(String, SemanticVersion)> {
396    let from_branch = |sb: &str| -> Option<SemanticVersion> {
397        parse_version(sb, eff).or_else(|| extract_version(sb, eff))
398    };
399
400    // User-defined formats first, then the 8 built-in formats.
401    let custom = eff.merge_message_formats.values().map(|s| s.as_str());
402    for pattern in custom.chain(BUILTIN_MERGE_FORMATS.iter().copied()) {
403        let Ok(re) = Regex::new(&format!("(?s){pattern}")) else {
404            continue;
405        };
406        let Some(caps) = re.captures(message) else {
407            continue;
408        };
409        let Some(sb) = caps.name("SourceBranch") else {
410            continue;
411        };
412        let branch = sb.as_str().to_string();
413        if let Some(v) = caps
414            .name("Version")
415            .and_then(|m| parse_version(m.as_str(), eff))
416        {
417            return Some((branch, v));
418        }
419        if let Some(v) = from_branch(&branch) {
420            return Some((branch, v));
421        }
422        return None; // Format matched but no version found.
423    }
424    None
425}
426
427/// Extract the merged branch name from a merge commit message and return its configured increment.
428///
429/// Used during Mainline trunk walk to determine the increment floor for a merge commit.
430/// When the merged branch is configured as Minor (e.g. a TrunkBased feature), Minor is used
431/// instead of the default Patch. `Inherit`/`None` have no effect and return `None`.
432/// Returns `None` when `prevent_increment.when_branch_merged = true` on the merged branch.
433fn merge_branch_increment(config: &GitVersionConfiguration, message: &str) -> Option<VersionField> {
434    for pattern in BUILTIN_MERGE_FORMATS {
435        let Ok(re) = Regex::new(&format!("(?s){pattern}")) else {
436            continue;
437        };
438        let Some(caps) = re.captures(message) else {
439            continue;
440        };
441        let Some(sb) = caps.name("SourceBranch") else {
442            continue;
443        };
444        let branch = sb.as_str();
445        let (_, bc) = crate::config::effective::find_branch_config(config, branch)?;
446        // when_branch_merged=true → this merge commit must not increment at all.
447        // Some(VersionField::None) is a forced no-op signal that also blocks trunk_default.
448        if bc
449            .prevent_increment
450            .as_ref()
451            .and_then(|pi| pi.when_branch_merged)
452            .unwrap_or(false)
453        {
454            return Some(VersionField::None);
455        }
456        let increment = bc.increment.unwrap_or(IncrementStrategy::Inherit);
457        if matches!(
458            increment,
459            IncrementStrategy::Inherit | IncrementStrategy::None
460        ) {
461            return None;
462        }
463        return Some(strategy_to_field(increment));
464    }
465    None
466}
467
468/// Returns true if the branch name matches a branch config with `is-release-branch = true`.
469fn is_release_branch(config: &GitVersionConfiguration, branch_name: &str) -> bool {
470    let short = branch_name.rsplit('/').next().unwrap_or(branch_name);
471    config.branches.values().any(|bc| {
472        bc.is_release_branch == Some(true)
473            && bc
474                .regex
475                .as_ref()
476                .and_then(|r| Regex::new(&format!("(?i){r}")).ok())
477                .map(|re| re.is_match(branch_name) || re.is_match(short))
478                .unwrap_or(false)
479    })
480}
481
482/// Main calculation entry point. Produces the final output variables.
483pub fn calculate(
484    repo: &GitRepo,
485    config: &GitVersionConfiguration,
486    branch_override: Option<String>,
487) -> Result<VersionVariables> {
488    // If branch_override is an actual ref, use its tip as HEAD (recalculate for that branch);
489    // otherwise use the current HEAD.
490    let (head, branch_name) = match &branch_override {
491        Some(b) => {
492            let head = repo
493                .commit_info_of(b)
494                .map(Ok)
495                .unwrap_or_else(|| repo.head_commit())?;
496            (head, b.clone())
497        }
498        None => (repo.head_commit()?, repo.current_branch_name()?),
499    };
500    let mut eff = EffectiveConfiguration::resolve(config, &branch_name);
501    // Invalid regex config causes a calculation error just like the original (not silently ignored).
502    validate_config_regexes(&eff)?;
503    let ignore = IgnoreSet::from_config(config);
504
505    // When the Mainline strategy is active, use the per-commit accumulation approach.
506    if config.strategies.contains(&VersionStrategy::Mainline) {
507        return mainline_calculate(repo, config, &eff, &branch_name, &head, &ignore);
508    }
509
510    // Inherit increment: walk actual git ancestors to find the source branch the current branch
511    // diverged from, then inherit its increment (not necessarily the first configured source).
512    if let Some(inc) = resolve_inherit_via_git(repo, config, &branch_name)? {
513        eff.increment = inc;
514    }
515
516    let mut candidates: Vec<BaseVersion> = Vec::new();
517    let mut tag_alternatives: Vec<SemanticVersion> = Vec::new();
518    let strategies = if config.strategies.is_empty() {
519        vec![
520            VersionStrategy::Fallback,
521            VersionStrategy::ConfiguredNextVersion,
522            VersionStrategy::MergeMessage,
523            VersionStrategy::TaggedCommit,
524            VersionStrategy::VersionInBranchName,
525        ]
526    } else {
527        config.strategies.clone()
528    };
529
530    for strat in &strategies {
531        match strat {
532            VersionStrategy::ConfiguredNextVersion => {
533                // Mirrors the original ConfiguredNextVersionVersionStrategy: skip when
534                // next-version is empty; otherwise call SemanticVersion.Parse (throws on failure).
535                // A next-version that cannot be parsed with the current format fails the whole calculation.
536                if let Some(nv) = &eff.next_version {
537                    if !nv.is_empty() {
538                        let v = parse_version(nv, &eff).ok_or_else(|| {
539                            anyhow::anyhow!("Failed to parse {nv} into a Semantic Version")
540                        })?;
541                        // When a pre-release label is present, it must match the current branch label.
542                        // (Mirrors .NET IsMatchForBranchSpecificLabel behaviour.)
543                        let label_ok =
544                            !v.pre_release_tag.has_tag() || v.pre_release_tag.name == eff.label;
545                        if label_ok {
546                            candidates.push(BaseVersion::new(
547                                "ConfiguredNextVersion",
548                                v,
549                                None,
550                                VersionField::None,
551                                Some(eff.label.clone()),
552                            ));
553                        }
554                    }
555                }
556            }
557            VersionStrategy::TaggedCommit | VersionStrategy::Mainline => {
558                gather_tagged(
559                    repo,
560                    &eff,
561                    &head,
562                    &ignore,
563                    &mut candidates,
564                    &mut tag_alternatives,
565                )?;
566            }
567            VersionStrategy::VersionInBranchName => {
568                if eff.is_release_branch {
569                    if let Some(v) = extract_version(&branch_name, &eff) {
570                        candidates.push(BaseVersion::new(
571                            "VersionInBranchName",
572                            v,
573                            None,
574                            VersionField::None,
575                            Some(eff.label.clone()),
576                        ));
577                    }
578                }
579            }
580            VersionStrategy::MergeMessage => {
581                // When track-merge-message is false, merge messages are not used as version sources.
582                if eff.track_merge_message {
583                    gather_merge_messages(repo, config, &eff, &head, &ignore, &mut candidates)?;
584                }
585            }
586            VersionStrategy::TrackReleaseBranches => {
587                gather_track_release(repo, config, &eff, &head, &branch_name, &mut candidates)?;
588            }
589            VersionStrategy::Fallback => {
590                let field = determine_increment(repo, None, &head.sha, true, &eff, &ignore)?;
591                candidates.push(BaseVersion::new(
592                    "Fallback (0.0.0)",
593                    SemanticVersion::new(0, 0, 0),
594                    None,
595                    field,
596                    Some(eff.label.clone()),
597                ));
598            }
599            VersionStrategy::None => {}
600        }
601    }
602
603    if candidates.is_empty() {
604        // Mirrors the original NextVersionCalculator.CalculateNextVersion: when no candidates
605        // are found, fail the calculation rather than inserting an arbitrary fallback (0.0.0).
606        // The default strategy list includes Fallback, so this path only occurs when Fallback
607        // is explicitly excluded from `strategies`.
608        return Err(anyhow::anyhow!(
609            "No base versions determined on the current branch."
610        ));
611    }
612
613    // Apply increments to each candidate.
614    let next: Vec<NextVersion> = candidates
615        .into_iter()
616        .map(|b| {
617            let incremented = if b.exact {
618                b.semantic_version.clone()
619            } else {
620                b.semantic_version
621                    .increment(b.increment, b.label.as_deref(), b.force_increment)
622            };
623            NextVersion {
624                incremented,
625                base: b,
626            }
627        })
628        .collect();
629
630    // Select the candidate with the highest IncrementedVersion.
631    // Ties are broken in favour of the earlier candidate (mirroring .NET: TaggedCommit > VersionInBranchName).
632    let max_idx = next.iter().enumerate().fold(0usize, |acc, (i, n)| {
633        if n.incremented.cmp(&next[acc].incremented) == std::cmp::Ordering::Greater {
634            i
635        } else {
636            acc
637        }
638    });
639
640    // The base version source comes from the most recent candidate that has a source
641    // (mirrors the original NextVersionCalculator LatestBaseVersionSource rule).
642    // VSSV is taken from the base semantic_version of the selected (max) candidate.
643    let latest_source = next
644        .iter()
645        .filter(|n| n.base.base_version_source.is_some())
646        .max_by(|a, b| a.base.source_when.cmp(&b.base.source_when));
647    let base_source = latest_source
648        .and_then(|n| n.base.base_version_source.clone())
649        .or_else(|| next[max_idx].base.base_version_source.clone());
650
651    let chosen = next.into_iter().nth(max_idx).unwrap();
652    // VSSV = semantic_version of the chosen base version (before increment).
653    let source_semver = chosen.base.semantic_version.clone();
654
655    let mut final_semver = apply_deployment_mode(
656        repo,
657        &eff,
658        &branch_name,
659        &head,
660        &chosen,
661        base_source.as_deref(),
662        &ignore,
663    )?;
664    // AlternativeSemanticVersion adjustment: when a tag with a mismatched label exists on the branch
665    // and its core is higher, replace the final version's major.minor.patch with that tag's core.
666    // (Mirrors the .NET NextVersionCalculator.Calculate() alternativeSemanticVersion logic.)
667    if let Some(alt) = tag_alternatives.iter().max_by(|a, b| a.cmp_core(b)) {
668        if alt.cmp_core(&final_semver) == std::cmp::Ordering::Greater {
669            final_semver.major = alt.major;
670            final_semver.minor = alt.minor;
671            final_semver.patch = alt.patch;
672        }
673    }
674    let variables = build_variables(&eff, &branch_name, &head, &final_semver, &source_semver)?;
675    Ok(variables)
676}
677
678/// Mainline strategy: accumulate increments for each commit starting from the highest tag (or 0.0.0).
679///
680/// Each commit uses the message-based increment (major/minor/patch) when it is higher than the
681/// default; otherwise the default increment is applied (`+semver:none` is treated as the default).
682/// Ports the core behaviour of the original `MainlineVersionStrategy`, producing a monotonically
683/// increasing version without pre-releases, similar to ContinuousDeployment.
684fn mainline_calculate(
685    repo: &GitRepo,
686    config: &GitVersionConfiguration,
687    eff: &EffectiveConfiguration,
688    branch_name: &str,
689    head: &CommitInfo,
690    ignore: &IgnoreSet,
691) -> Result<VersionVariables> {
692    // Build a sha → core version map of all reachable tags (highest wins when a commit has multiple tags).
693    let mut tags_by_sha: std::collections::HashMap<String, SemanticVersion> =
694        std::collections::HashMap::new();
695    for tag in repo.tags()? {
696        if ignore.is_ignored(&tag.target_sha, &tag.when) {
697            continue;
698        }
699        if let Some(v) = parse_version(&tag.name, eff) {
700            let core = SemanticVersion::new(v.major, v.minor, v.patch);
701            let e = tags_by_sha
702                .entry(tag.target_sha.clone())
703                .or_insert_with(|| core.clone());
704            if core.cmp_core(e) == std::cmp::Ordering::Greater {
705                *e = core;
706            }
707        }
708    }
709    let core_gt =
710        |a: &SemanticVersion, b: &SemanticVersion| a.cmp_core(b) == std::cmp::Ordering::Greater;
711
712    let default = strategy_to_field(eff.increment);
713
714    // Non-trunk branches (feature, hotfix, etc.) apply trunk increments only up to the merge-base
715    // with the source branch, then apply the feature increment once. Falls back to the full trunk
716    // walk when the source branch ref cannot be resolved.
717    let merge_base_sha: Option<String> = if !eff.is_main_branch && !eff.source_branches.is_empty() {
718        let src = &eff.source_branches[0];
719        if let Some(src_info) = repo.commit_info_of(src) {
720            repo.merge_base(&head.sha, &src_info.sha)?
721        } else {
722            None
723        }
724    } else {
725        None
726    };
727
728    // Trunk walk: up to the merge-base (branch) or HEAD (trunk).
729    // For branches, walk using the source branch config (e.g. Patch increment).
730    let trunk_target = merge_base_sha.as_deref().unwrap_or(&head.sha);
731    let trunk_eff_buf;
732    let trunk_eff: &EffectiveConfiguration = if merge_base_sha.is_some() {
733        trunk_eff_buf = EffectiveConfiguration::resolve(config, &eff.source_branches[0]);
734        &trunk_eff_buf
735    } else {
736        eff
737    };
738    let trunk_default = strategy_to_field(trunk_eff.increment);
739
740    let mut trunk = ignore.filter(repo, repo.first_parent_between(None, trunk_target)?);
741    trunk.reverse();
742
743    let mut version = SemanticVersion::new(0, 0, 0);
744    let mut highest_tag = SemanticVersion::new(0, 0, 0);
745    // For VSSV: track the trunk version before each commit is processed.
746    let mut prev_trunk_version = SemanticVersion::new(0, 0, 0);
747    for c in &trunk {
748        prev_trunk_version = version.clone();
749        // Commits introduced by this step: for a merge, those from the second-parent side; otherwise the commit itself.
750        let introduced: Vec<CommitInfo> = if c.parents.len() >= 2 {
751            ignore.filter(
752                repo,
753                repo.commits_between(Some(&c.parents[0]), &c.parents[1])?,
754            )
755        } else {
756            vec![c.clone()]
757        };
758
759        // Highest tag core among the introduced commits (and the merge commit itself).
760        let mut step_tag: Option<SemanticVersion> = None;
761        for sha in introduced
762            .iter()
763            .map(|x| &x.sha)
764            .chain(std::iter::once(&c.sha))
765        {
766            if let Some(tv) = tags_by_sha.get(sha) {
767                if step_tag.as_ref().map(|s| core_gt(tv, s)).unwrap_or(true) {
768                    step_tag = Some(tv.clone());
769                }
770            }
771        }
772
773        if let Some(tv) = step_tag {
774            if core_gt(&tv, &highest_tag) {
775                highest_tag = tv.clone();
776            }
777            // A tag that is at least as high as the current version fixes that version (no increment).
778            if !core_gt(&version, &tv) {
779                version = tv;
780                continue;
781            }
782        }
783
784        // No tag (or tag lower than current) → increment. Consolidate messages; default is the floor.
785        let mut field = trunk_default;
786        for ic in &introduced {
787            if let Some(f) = increment_from_message(&ic.message, trunk_eff) {
788                if f > field {
789                    field = f;
790                }
791            }
792        }
793        // For merge commits, also apply the merged branch's configured increment as a floor.
794        // (e.g. TrunkBased feature = Minor → if Minor > Patch, use Minor.)
795        // Some(VersionField::None) signals when_branch_merged=true: blocks everything including trunk_default.
796        if c.parents.len() >= 2 {
797            match merge_branch_increment(config, &c.message) {
798                Some(VersionField::None) => {
799                    field = VersionField::None;
800                }
801                Some(branch_field) if branch_field > field => {
802                    field = branch_field;
803                }
804                _ => {}
805            }
806        }
807        version = version.increment(field, None, true);
808    }
809    // For VSSV: trunk version after the walk and before any feature increment.
810    let trunk_version_end = version.clone();
811
812    // Compute distance and source_sha.
813    let (mut version, source_sha, distance) = if let Some(ref mb_sha) = merge_base_sha {
814        // Branch: count only the commits in the branch portion (after the merge-base) as distance.
815        let feature_commits = ignore.filter(repo, repo.commits_between(Some(mb_sha), &head.sha)?);
816        let head_is_tagged = tags_by_sha.contains_key(&head.sha);
817
818        // Check whether the branch portion (including HEAD) has a tag.
819        let feature_tag = feature_commits
820            .iter()
821            .filter_map(|c| {
822                tags_by_sha
823                    .get(&c.sha)
824                    .map(|tv| (c.sha.clone(), tv.clone()))
825            })
826            .reduce(|(sa, a), (sb, b)| if core_gt(&b, &a) { (sb, b) } else { (sa, a) });
827
828        if let Some((ft_sha, ft)) = feature_tag {
829            // Tag exists on the branch: use it as the version source.
830            if head_is_tagged && !eff.prevent_increment_when_current_commit_tagged {
831                // HEAD is tagged and prevent-increment=false → one additional increment, distance=0.
832                let v = ft.increment(default, None, true);
833                (v, Some(head.sha.clone()), 0i64)
834            } else {
835                let d = repo.commits_between(Some(&ft_sha), &head.sha)?.len() as i64;
836                (ft, Some(ft_sha), d)
837            }
838        } else {
839            // No tag on the branch: trunk version + one branch increment, distance = number of branch commits.
840            let v = version.increment(default, None, true);
841            let d = feature_commits.len() as i64;
842            (v, Some(mb_sha.clone()), d)
843        }
844    } else {
845        // Trunk: handle HEAD tag (when-current-commit-tagged: false).
846        let head_is_tagged = tags_by_sha.contains_key(&head.sha);
847        if head_is_tagged && !eff.prevent_increment_when_current_commit_tagged {
848            let v = version.increment(default, None, true);
849            (v, Some(head.sha.clone()), 0i64)
850        } else {
851            // The Mainline version source is HEAD's first parent (the previous trunk state).
852            let s = head.parents.first().cloned();
853            let d = repo.commits_between(s.as_deref(), &head.sha)?.len() as i64;
854            (version, s, d)
855        }
856    };
857
858    // Set pre-release / build metadata according to the deployment mode.
859    let label = eff.label.as_str();
860    let mut commits_since_tag = None;
861    version.pre_release_tag = match eff.deployment_mode {
862        // Core version only (no pre-release).
863        DeploymentMode::ContinuousDeployment => PreReleaseTag::default(),
864        // Pre-release number = distance.
865        DeploymentMode::ContinuousDelivery => {
866            PreReleaseTag::new(label, Some(distance), label.is_empty())
867        }
868        // Pre-release number = 1, build metadata = distance.
869        DeploymentMode::ManualDeployment => {
870            commits_since_tag = Some(distance);
871            PreReleaseTag::new(label, Some(1), label.is_empty())
872        }
873    };
874    version.build_metadata = BuildMetaData {
875        commits_since_tag,
876        branch: Some(branch_name.to_string()),
877        sha: Some(head.sha.clone()),
878        short_sha: Some(head.short_sha.clone()),
879        commit_date: Some(head.when),
880        version_source_sha: source_sha,
881        version_source_distance: distance,
882        uncommitted_changes: repo.uncommitted_changes().unwrap_or(0),
883        version_source_increment: VersionField::None,
884        other_metadata: None,
885    };
886
887    // Compute VersionSourceSemVer:
888    // When source_sha has a tag, use that tag's core version; otherwise use the trunk version
889    // at that point suffixed with "-1".
890    // Branch: trunk_version_end = trunk version after the walk, before the feature increment.
891    // Trunk: prev_trunk_version = trunk version immediately before HEAD was processed.
892    let version_at_source = if merge_base_sha.is_some() {
893        trunk_version_end
894    } else {
895        prev_trunk_version.clone()
896    };
897    let source_semver = match version.build_metadata.version_source_sha.as_deref() {
898        None => SemanticVersion::new(0, 0, 0),
899        Some(sha) => {
900            if let Some(tv) = tags_by_sha.get(sha) {
901                tv.clone()
902            } else {
903                let mut sv = version_at_source;
904                sv.pre_release_tag = PreReleaseTag::new("", Some(1), true);
905                sv
906            }
907        }
908    };
909
910    build_variables(eff, branch_name, head, &version, &source_semver)
911}
912
913/// Check whether a version matches the branch label.
914///
915/// Mirrors .NET `SemanticVersion.IsMatchForBranchSpecificLabel`:
916/// `(Name.Length == 0 && Number is null) || IsLabeledWith(value)`
917fn is_match_for_branch_label(version: &SemanticVersion, label: &str) -> bool {
918    let pre = &version.pre_release_tag;
919    // Release version (name="" and number=None): always matches.
920    if pre.name.is_empty() && pre.number.is_none() {
921        return true;
922    }
923    // Has a pre-release: name must match the label (has_tag() && name == label).
924    pre.has_tag() && pre.name == label
925}
926
927/// Collect version tags reachable from HEAD into candidates.
928///
929/// `alternatives`: all parsed tag versions, used for AlternativeSemanticVersion adjustment.
930/// Tags whose label does not match the branch label are added only to `alternatives`, not to `out`.
931fn gather_tagged(
932    repo: &GitRepo,
933    eff: &EffectiveConfiguration,
934    head: &CommitInfo,
935    ignore: &IgnoreSet,
936    out: &mut Vec<BaseVersion>,
937    alternatives: &mut Vec<SemanticVersion>,
938) -> Result<()> {
939    for tag in repo.tags()? {
940        if ignore.is_ignored(&tag.target_sha, &tag.when) {
941            continue;
942        }
943        if ignore.is_path_ignored(repo, &tag.target_sha) {
944            continue;
945        }
946        if !repo
947            .is_ancestor_of(&tag.target_sha, &head.sha)
948            .unwrap_or(false)
949        {
950            continue;
951        }
952        let Some(version) = parse_version(&tag.name, eff) else {
953            continue;
954        };
955        // Collect all tag versions for AlternativeSemanticVersion adjustment (regardless of label).
956        alternatives.push(version.clone());
957        // Tags that don't match the branch label are excluded from candidates.
958        if !is_match_for_branch_label(&version, &eff.label) {
959            continue;
960        }
961        let is_current = tag.target_sha == head.sha;
962        let exact = is_current && eff.prevent_increment_when_current_commit_tagged;
963        // Named pre-release tags (e.g. 1.0.0-beta.1) are not yet "releases" and are not used
964        // as a version source. The core is not bumped; commit count starts from before the tag
965        // commit (inclusive), matching the original TaggedCommitVersionStrategy behaviour.
966        // Exception: numeric-only pre-releases (e.g. 1.0.0-1) are CD-style checkpoints and
967        // are used as a version source.
968        let has_pre = version.pre_release_tag.has_tag();
969        let is_numeric_only_pre = has_pre && version.pre_release_tag.name.is_empty();
970        let use_as_source = exact || !has_pre || is_numeric_only_pre;
971        let base_src = if use_as_source {
972            Some(tag.target_sha.clone())
973        } else {
974            None
975        };
976        let field = if exact {
977            VersionField::None
978        } else {
979            let from = if use_as_source {
980                Some(tag.target_sha.as_str())
981            } else {
982                None
983            };
984            determine_increment(repo, from, &head.sha, true, eff, ignore)?
985        };
986        let mut bv = BaseVersion::new(
987            format!("Tag {}", tag.name),
988            version,
989            base_src,
990            field,
991            Some(eff.label.clone()),
992        );
993        bv.exact = exact;
994        bv.source_when = if use_as_source { Some(tag.when) } else { None };
995        out.push(bv);
996    }
997    Ok(())
998}
999
1000/// Extract a version from merge commit messages.
1001///
1002/// Mirrors the original `MergeMessageVersionStrategy`: only uses the version when the merged
1003/// branch is a release branch.
1004fn gather_merge_messages(
1005    repo: &GitRepo,
1006    config: &GitVersionConfiguration,
1007    eff: &EffectiveConfiguration,
1008    head: &CommitInfo,
1009    ignore: &IgnoreSet,
1010    out: &mut Vec<BaseVersion>,
1011) -> Result<()> {
1012    // The original MergeMessageVersionStrategy.GetBaseVersions returns at most 5 candidates.
1013    let mut count = 0usize;
1014    for c in ignore.filter(repo, repo.commits_between(None, &head.sha)?) {
1015        if count >= 5 {
1016            break;
1017        }
1018        let Some((merged_branch, v)) = parse_merge_message(&c.message, eff) else {
1019            continue;
1020        };
1021        // Do not use the version when the merged branch is not a release branch.
1022        if !is_release_branch(config, &merged_branch) {
1023            continue;
1024        }
1025        // The base source for a merge commit is the merge-base of its two parents,
1026        // so that commits introduced by the merge are accurately counted after the version source.
1027        let base_src = if c.parents.len() >= 2 {
1028            repo.merge_base(&c.parents[0], &c.parents[1])?
1029                .unwrap_or_else(|| c.sha.clone())
1030        } else {
1031            c.sha.clone()
1032        };
1033        let field = if eff.prevent_increment_of_merged_branch {
1034            VersionField::None
1035        } else {
1036            determine_increment(repo, Some(&base_src), &head.sha, true, eff, ignore)?
1037        };
1038        let mut bv = BaseVersion::new(
1039            "MergeMessage",
1040            v,
1041            Some(base_src),
1042            field,
1043            Some(eff.label.clone()),
1044        );
1045        bv.source_when = Some(c.when);
1046        out.push(bv);
1047        count += 1;
1048    }
1049    Ok(())
1050}
1051
1052/// Track release branches (e.g. from develop). Generates candidates based on the merge-base.
1053fn gather_track_release(
1054    repo: &GitRepo,
1055    config: &GitVersionConfiguration,
1056    eff: &EffectiveConfiguration,
1057    head: &CommitInfo,
1058    branch_name: &str,
1059    out: &mut Vec<BaseVersion>,
1060) -> Result<()> {
1061    if !eff.tracks_release_branches {
1062        return Ok(());
1063    }
1064    let Some((_, release_bc)) = config
1065        .branches
1066        .iter()
1067        .find(|(k, _)| k.as_str() == "release")
1068    else {
1069        return Ok(());
1070    };
1071    let Some(re_src) = &release_bc.regex else {
1072        return Ok(());
1073    };
1074    let Ok(re) = Regex::new(&format!("(?i){re_src}")) else {
1075        return Ok(());
1076    };
1077
1078    for rb in repo.branch_names()? {
1079        let short = rb.rsplit('/').next().unwrap_or(&rb);
1080        if !(re.is_match(&rb) || re.is_match(short)) {
1081            continue;
1082        }
1083        if let Some(v) = extract_version(&rb, eff) {
1084            let base_src = repo.merge_base(branch_name, &rb)?;
1085            out.push(BaseVersion::new(
1086                format!("TrackReleaseBranches: {rb}"),
1087                v,
1088                base_src.or(Some(head.sha.clone())),
1089                strategy_to_field(eff.increment),
1090                Some(eff.label.clone()),
1091            ));
1092        }
1093    }
1094    Ok(())
1095}
1096
1097/// Produce the final version (including build metadata) according to the deployment mode.
1098fn apply_deployment_mode(
1099    repo: &GitRepo,
1100    eff: &EffectiveConfiguration,
1101    branch_name: &str,
1102    head: &CommitInfo,
1103    chosen: &NextVersion,
1104    base_source: Option<&str>,
1105    ignore: &IgnoreSet,
1106) -> Result<SemanticVersion> {
1107    let base_src = if chosen.base.exact {
1108        chosen.base.base_version_source.as_deref()
1109    } else {
1110        base_source
1111    };
1112    let commits = ignore
1113        .filter(repo, repo.commits_between(base_src, &head.sha)?)
1114        .len() as i64;
1115    let uncommitted = repo.uncommitted_changes().unwrap_or(0);
1116
1117    let mut sv = chosen.incremented.clone();
1118    let mut meta = BuildMetaData {
1119        commits_since_tag: Some(commits),
1120        branch: Some(branch_name.to_string()),
1121        sha: Some(head.sha.clone()),
1122        short_sha: Some(head.short_sha.clone()),
1123        commit_date: Some(head.when),
1124        version_source_sha: base_src.map(|s| s.to_string()),
1125        version_source_distance: commits,
1126        uncommitted_changes: uncommitted,
1127        // In the original, the final BaseVersion.Increment is recorded as None after the increment is consumed
1128        // (VersionSourceIncrement == None in all observed scenarios).
1129        version_source_increment: VersionField::None,
1130        other_metadata: None,
1131    };
1132
1133    if chosen.base.exact {
1134        // Current commit is tagged → use as-is. No build metadata accumulation.
1135        meta.commits_since_tag = None;
1136        sv.build_metadata = meta;
1137        return Ok(sv);
1138    }
1139
1140    match eff.deployment_mode {
1141        DeploymentMode::ManualDeployment => {
1142            // Keep core/tag; expose build metadata (short form) in FullSemVer.
1143        }
1144        DeploymentMode::ContinuousDelivery => {
1145            if sv.pre_release_tag.has_tag() {
1146                let n = sv.pre_release_tag.number.unwrap_or(1);
1147                sv.pre_release_tag.number = Some(n + commits - 1);
1148            }
1149            meta.commits_since_tag = None;
1150        }
1151        DeploymentMode::ContinuousDeployment => {
1152            sv.pre_release_tag = PreReleaseTag::default();
1153            meta.commits_since_tag = None;
1154        }
1155    }
1156
1157    sv.build_metadata = meta;
1158    Ok(sv)
1159}
1160
1161/// Build the final output variables.
1162fn build_variables(
1163    eff: &EffectiveConfiguration,
1164    branch_name: &str,
1165    head: &CommitInfo,
1166    sv: &SemanticVersion,
1167    source_semver: &SemanticVersion,
1168) -> Result<VersionVariables> {
1169    let pre = &sv.pre_release_tag;
1170    let pre_label = pre.name.clone();
1171    let pre_number = pre.number;
1172    let pre_tag_str = if pre.has_tag() {
1173        pre.format(false)
1174    } else {
1175        String::new()
1176    };
1177
1178    let with_dash = |s: &str| {
1179        if s.is_empty() {
1180            String::new()
1181        } else {
1182            format!("-{s}")
1183        }
1184    };
1185
1186    let major_minor_patch = sv.major_minor_patch();
1187    let sem_ver = sv.to_string();
1188    let commits = sv.build_metadata.version_source_distance;
1189    let full_build_meta = sv.build_metadata.format_full();
1190
1191    // FullSemVer uses only the short build metadata (commit count), e.g. 1.0.1-1+2.
1192    let full_sem_ver = match sv.build_metadata.commits_since_tag {
1193        Some(n) => format!("{sem_ver}+{n}"),
1194        None => sem_ver.clone(),
1195    };
1196
1197    // WeightedPreReleaseNumber: number + pre-release-weight when a number exists;
1198    // tag-pre-release-weight for stable releases. Mirrors the original SemanticVersionFormatValues.
1199    let weighted = Some(match pre_number {
1200        Some(n) => n + eff.pre_release_weight,
1201        None => eff.tag_pre_release_weight,
1202    });
1203
1204    let assembly_sem_ver = assembly_version(sv, eff.assembly_versioning_scheme);
1205    let assembly_sem_file_ver = assembly_version(sv, eff.assembly_file_versioning_scheme);
1206    // InformationalVersion uses the full build metadata (includes branch/sha).
1207    let informational = if full_build_meta.is_empty() {
1208        sem_ver.clone()
1209    } else {
1210        format!("{sem_ver}+{full_build_meta}")
1211    };
1212
1213    let escaped_branch = Regex::new(r"[^a-zA-Z0-9-]")
1214        .unwrap()
1215        .replace_all(branch_name, "-")
1216        .into_owned();
1217
1218    let date_fmt = dotnet_date_format_to_strftime(&eff.commit_date_format);
1219    let commit_date = head.when.naive_utc().format(&date_fmt).to_string();
1220
1221    let mut vars = VersionVariables {
1222        major: sv.major as u32,
1223        minor: sv.minor as u32,
1224        patch: sv.patch as u32,
1225        pre_release_tag: pre_tag_str.clone(),
1226        pre_release_tag_with_dash: with_dash(&pre_tag_str),
1227        pre_release_label: pre_label.clone(),
1228        pre_release_label_with_dash: with_dash(&pre_label),
1229        pre_release_number: pre_number,
1230        weighted_pre_release_number: weighted,
1231        build_meta_data: sv.build_metadata.commits_since_tag,
1232        full_build_meta_data: full_build_meta,
1233        major_minor_patch,
1234        sem_ver,
1235        full_sem_ver,
1236        assembly_sem_ver,
1237        assembly_sem_file_ver,
1238        informational_version: informational,
1239        branch_name: branch_name.to_string(),
1240        escaped_branch_name: escaped_branch,
1241        sha: head.sha.clone(),
1242        short_sha: head.short_sha.clone(),
1243        version_source_distance: Some(commits),
1244        version_source_increment: sv
1245            .build_metadata
1246            .version_source_increment
1247            .as_str()
1248            .to_string(),
1249        version_source_sem_ver: source_semver.to_string(),
1250        version_source_sha: sv
1251            .build_metadata
1252            .version_source_sha
1253            .clone()
1254            .unwrap_or_default(),
1255        commits_since_version_source: Some(commits),
1256        commit_date,
1257        uncommitted_changes: sv.build_metadata.uncommitted_changes,
1258    };
1259
1260    // Apply custom assembly-*-format / assembly-informational-format.
1261    // These reference the variables computed above, so they are post-processed here.
1262    let ctx = vars.to_map();
1263    if let Some(fmt) = &eff.assembly_versioning_format {
1264        vars.assembly_sem_ver = render_template(fmt, &ctx)?;
1265    }
1266    if let Some(fmt) = &eff.assembly_file_versioning_format {
1267        vars.assembly_sem_file_ver = render_template(fmt, &ctx)?;
1268    }
1269    // The default informational-format `{InformationalVersion}` reproduces the original value,
1270    // so it is always safe to apply.
1271    vars.informational_version = render_template(&eff.assembly_informational_format, &ctx)?;
1272
1273    Ok(vars)
1274}
1275
1276/// Substitute `{Variable}` and `{env:VAR}` tokens using the variable map.
1277/// The original GitVersion fails format expansion on unknown tokens, so this function also
1278/// returns `Err` for any token that is neither in `ctx` nor an `env:` reference.
1279fn render_template(fmt: &str, ctx: &std::collections::BTreeMap<String, String>) -> Result<String> {
1280    let re = Regex::new(r"\{(?<t>[A-Za-z0-9_:]+)\}").unwrap();
1281    let mut unknown: Option<String> = None;
1282    let out = re
1283        .replace_all(fmt, |c: &regex::Captures| {
1284            let t = &c["t"];
1285            if let Some(env_var) = t.strip_prefix("env:") {
1286                std::env::var(env_var).unwrap_or_default()
1287            } else if let Some(v) = ctx.get(t) {
1288                v.clone()
1289            } else {
1290                if unknown.is_none() {
1291                    unknown = Some(t.to_string());
1292                }
1293                String::new()
1294            }
1295        })
1296        .into_owned();
1297    match unknown {
1298        Some(t) => Err(anyhow::anyhow!(
1299            "Unknown template token '{{{t}}}' in format string"
1300        )),
1301        None => Ok(out),
1302    }
1303}
1304
1305/// Apply the assembly versioning scheme.
1306fn assembly_version(sv: &SemanticVersion, scheme: VersioningScheme) -> String {
1307    let pre = sv.pre_release_tag.number.unwrap_or(0);
1308    match scheme {
1309        VersioningScheme::Major => format!("{}.0.0.0", sv.major),
1310        VersioningScheme::MajorMinor => format!("{}.{}.0.0", sv.major, sv.minor),
1311        VersioningScheme::MajorMinorPatch => {
1312            format!("{}.{}.{}.0", sv.major, sv.minor, sv.patch)
1313        }
1314        VersioningScheme::MajorMinorPatchTag => {
1315            format!("{}.{}.{}.{}", sv.major, sv.minor, sv.patch, pre)
1316        }
1317        VersioningScheme::None => String::new(),
1318    }
1319}
1320
1321#[cfg(test)]
1322mod tests {
1323    use super::*;
1324    use crate::config::defaults;
1325
1326    fn default_eff() -> EffectiveConfiguration {
1327        let cfg = defaults::gitflow();
1328        EffectiveConfiguration::resolve(&cfg, "main")
1329    }
1330
1331    #[test]
1332    fn validate_config_regexes_rejects_bad_patterns() {
1333        // Default config passes.
1334        let eff = default_eff();
1335        assert!(validate_config_regexes(&eff).is_ok());
1336        // Invalid tag-prefix regex is an error (matches original behaviour).
1337        let mut bad_prefix = default_eff();
1338        bad_prefix.tag_prefix = "(unclosed".to_string();
1339        assert!(validate_config_regexes(&bad_prefix).is_err());
1340        // Invalid bump-message regex is also an error.
1341        let mut bad_bump = default_eff();
1342        bad_bump.major_bump_message = "[invalid".to_string();
1343        assert!(validate_config_regexes(&bad_bump).is_err());
1344        // When commit-message-incrementing=Disabled, bump-messages are not validated.
1345        let mut disabled = default_eff();
1346        disabled.major_bump_message = "[invalid".to_string();
1347        disabled.commit_message_incrementing = CommitMessageIncrementMode::Disabled;
1348        assert!(validate_config_regexes(&disabled).is_ok());
1349    }
1350
1351    #[test]
1352    fn render_template_errors_on_unknown_token() {
1353        let mut ctx = std::collections::BTreeMap::new();
1354        ctx.insert("Major".to_string(), "1".to_string());
1355        // Known tokens are substituted.
1356        assert_eq!(render_template("v{Major}", &ctx).unwrap(), "v1");
1357        // env: tokens resolve from environment variables (empty string when absent).
1358        assert!(render_template("{env:GV_NO_SUCH_VAR}", &ctx).is_ok());
1359        // Unknown tokens return an error, matching the original GitVersion behaviour.
1360        assert!(render_template("{Bogus}", &ctx).is_err());
1361    }
1362
1363    #[test]
1364    fn parse_ignore_date_formats() {
1365        // datetime format
1366        let dt = parse_ignore_date("2021-06-15T12:00:00").unwrap();
1367        assert!(dt.to_rfc3339().starts_with("2021-06-15"));
1368        // date only
1369        let dt2 = parse_ignore_date("2021-06-15").unwrap();
1370        assert!(dt2.to_rfc3339().starts_with("2021-06-15"));
1371        // space separator
1372        let dt3 = parse_ignore_date("2021-06-15 12:00:00").unwrap();
1373        assert!(dt3.to_rfc3339().starts_with("2021-06-15"));
1374        // invalid format
1375        assert!(parse_ignore_date("not-a-date").is_none());
1376    }
1377
1378    #[test]
1379    fn ignore_set_sha_prefix_match() {
1380        // Prefix of 7+ characters matches.
1381        let full_sha = "abcdef1234567890abcdef1234567890abcdef12";
1382        let prefix = "abcdef1"; // 7 chars
1383        let mut set = IgnoreSet::default();
1384        set.shas.insert(prefix.to_lowercase());
1385        let when = chrono::Utc::now().fixed_offset();
1386        assert!(set.is_ignored(full_sha, &when));
1387        // A 6-character prefix does not match.
1388        let mut set2 = IgnoreSet::default();
1389        set2.shas.insert("abcdef".to_lowercase()); // 6 chars → no match
1390        assert!(!set2.is_ignored(full_sha, &when));
1391    }
1392
1393    #[test]
1394    fn ignore_set_before_date() {
1395        let past = parse_ignore_date("2020-01-01").unwrap();
1396        let set = IgnoreSet {
1397            before: Some(parse_ignore_date("2021-01-01").unwrap()),
1398            ..Default::default()
1399        };
1400        // past(2020) < before(2021) → ignored
1401        assert!(set.is_ignored("anysha", &past));
1402        // future(2022) >= before → not ignored
1403        let future = parse_ignore_date("2022-01-01").unwrap();
1404        assert!(!set.is_ignored("anysha", &future));
1405    }
1406
1407    #[test]
1408    fn strategy_to_field_all_variants() {
1409        assert_eq!(
1410            strategy_to_field(IncrementStrategy::Major),
1411            VersionField::Major
1412        );
1413        assert_eq!(
1414            strategy_to_field(IncrementStrategy::Minor),
1415            VersionField::Minor
1416        );
1417        assert_eq!(
1418            strategy_to_field(IncrementStrategy::Patch),
1419            VersionField::Patch
1420        );
1421        assert_eq!(
1422            strategy_to_field(IncrementStrategy::None),
1423            VersionField::None
1424        );
1425        assert_eq!(
1426            strategy_to_field(IncrementStrategy::Inherit),
1427            VersionField::None
1428        );
1429    }
1430
1431    #[test]
1432    fn increment_from_message_all_levels() {
1433        let eff = default_eff();
1434        // major
1435        assert_eq!(
1436            increment_from_message("big change\n+semver: major", &eff),
1437            Some(VersionField::Major)
1438        );
1439        // minor
1440        assert_eq!(
1441            increment_from_message("new feature\n+semver: minor", &eff),
1442            Some(VersionField::Minor)
1443        );
1444        // patch
1445        assert_eq!(
1446            increment_from_message("small fix\n+semver: patch", &eff),
1447            Some(VersionField::Patch)
1448        );
1449        // none/skip
1450        assert_eq!(
1451            increment_from_message("chore\n+semver: none", &eff),
1452            Some(VersionField::None)
1453        );
1454        assert_eq!(
1455            increment_from_message("+semver: skip", &eff),
1456            Some(VersionField::None)
1457        );
1458        // No match
1459        assert_eq!(increment_from_message("ordinary commit", &eff), None);
1460    }
1461
1462    #[test]
1463    fn increment_from_message_breaking_alias() {
1464        let eff = default_eff();
1465        assert_eq!(
1466            increment_from_message("+semver: breaking", &eff),
1467            Some(VersionField::Major)
1468        );
1469        assert_eq!(
1470            increment_from_message("+semver: feature", &eff),
1471            Some(VersionField::Minor)
1472        );
1473        assert_eq!(
1474            increment_from_message("+semver: fix", &eff),
1475            Some(VersionField::Patch)
1476        );
1477    }
1478
1479    #[test]
1480    fn ignore_set_filter_empty_shortcircuit() {
1481        // shas/before/paths 모두 비어 있으면 filter 는 입력을 그대로 반환.
1482        use crate::git::CommitInfo;
1483        let set = IgnoreSet::default();
1484        let commits = vec![CommitInfo {
1485            sha: "abc".into(),
1486            short_sha: "abc".into(),
1487            message: "msg".into(),
1488            when: chrono::Utc::now().fixed_offset(),
1489            parent_count: 0,
1490            parents: vec![],
1491        }];
1492        // When shas/before/paths are all empty, filter returns input unchanged without calling GitRepo.
1493        // Since we cannot construct a real repo object here, we verify this indirectly: an empty
1494        // filter short-circuits without touching the commit list.
1495        assert!(set.shas.is_empty() && set.before.is_none() && set.paths.is_empty());
1496        let _ = commits; // compile-check only
1497    }
1498}