gitversion-rs 0.2.1

Rust port of GitVersion — calculates semantic versions from Git history. Full feature port with a Ratatui TUI.
Documentation
//! Configuration file discovery, YAML loading, and default merging.
//!
//! Corresponds to the original `GitVersion.Configuration/ConfigurationFileLocator.cs`
//! and `ConfigurationProvider.cs`.

use super::{defaults, model::*};
use anyhow::{Context, Result};
use rust_i18n::t;
use std::path::{Path, PathBuf};

/// Configuration file names searched in priority order.
///
/// Matching is case-insensitive (see `locate`), mirroring upstream's
/// `StringComparison.OrdinalIgnoreCase`, so e.g. `.gitversion.yml` is also recognised
/// (GitVersion's own repository uses that lowercase spelling).
const CANDIDATES: [&str; 4] = [
    "GitVersion.yml",
    "GitVersion.yaml",
    ".GitVersion.yml",
    ".GitVersion.yaml",
];

/// Search for a configuration file in `dir` and `repo_root`.
///
/// Each directory's entries are matched against `CANDIDATES` case-insensitively, so the
/// canonical names are found regardless of spelling and regardless of whether the
/// filesystem is case-sensitive (e.g. `.gitversion.yml` on Linux).
pub fn locate(dir: &Path, repo_root: Option<&Path>) -> Option<PathBuf> {
    let mut search_dirs = vec![dir.to_path_buf()];
    if let Some(root) = repo_root {
        if root != dir {
            search_dirs.push(root.to_path_buf());
        }
    }
    for d in search_dirs {
        // Snapshot the directory's files once, then resolve candidates by priority.
        let Ok(entries) = std::fs::read_dir(&d) else {
            continue;
        };
        let files: Vec<(String, PathBuf)> = entries
            .flatten()
            .filter(|e| e.path().is_file())
            .filter_map(|e| e.file_name().to_str().map(|s| (s.to_string(), e.path())))
            .collect();
        for cand in CANDIDATES {
            if let Some((_, path)) = files
                .iter()
                .find(|(name, _)| name.eq_ignore_ascii_case(cand))
            {
                return Some(path.clone());
            }
        }
    }
    None
}

/// Returns true when the workflow value looks like a file path.
///
/// Treated as a file path when it starts with `./`, `../`, or `/`, or ends with `.yml` / `.yaml`.
fn is_workflow_file_path(s: &str) -> bool {
    s.starts_with("./")
        || s.starts_with("../")
        || s.starts_with('/')
        || s.ends_with(".yml")
        || s.ends_with(".yaml")
}

/// Load an external workflow file and return it as the base configuration.
///
/// Relative paths are resolved against `config_dir` (the directory containing the config file).
fn load_workflow_file(wf_path: &str, config_dir: &Path) -> Result<GitVersionConfiguration> {
    let abs = if Path::new(wf_path).is_absolute() {
        Path::new(wf_path).to_path_buf()
    } else {
        config_dir.join(wf_path)
    };
    let text = std::fs::read_to_string(&abs)
        .with_context(|| t!("config.read_failed", path = abs.display()))?;
    serde_yaml::from_str(&text)
        .with_context(|| t!("config.yaml_parse_failed", path = abs.display()))
}

/// Load configuration from an explicit path or by searching, then merge with workflow defaults.
pub fn load(
    explicit_path: Option<&Path>,
    work_dir: &Path,
    repo_root: Option<&Path>,
) -> Result<GitVersionConfiguration> {
    let path = match explicit_path {
        Some(p) => Some(p.to_path_buf()),
        None => locate(work_dir, repo_root),
    };

    let Some(path) = path else {
        // No config file found — fall back to GitFlow defaults.
        return Ok(defaults::gitflow());
    };

    let text = std::fs::read_to_string(&path)
        .with_context(|| t!("config.read_failed", path = path.display()))?;
    let overrides: GitVersionConfiguration = serde_yaml::from_str(&text)
        .with_context(|| t!("config.yaml_parse_failed", path = path.display()))?;

    // When the workflow value is a file path, load that file as the base configuration.
    let config_dir = path.parent().unwrap_or(work_dir);
    let mut base = match overrides.workflow.as_deref() {
        Some(wf) if is_workflow_file_path(wf) => load_workflow_file(wf, config_dir)?,
        wf => defaults::for_workflow(wf),
    };
    merge(&mut base, overrides);
    apply_source_branch_mappings(&mut base);
    validate(&base).with_context(|| t!("config.validate_failed", path = path.display()))?;
    Ok(base)
}

/// Validate configuration (mirrors the original `ConfigurationBuilderBase.ValidateConfiguration`).
///
/// Every branch must have a `regex`, and `source-branches` may only reference configured branches.
/// Violations return an error (the original throws `ConfigurationException`).
pub fn validate(config: &GitVersionConfiguration) -> Result<()> {
    const HELP: &str = "\nSee https://gitversion.net/docs/reference/configuration for more info";
    for (name, bc) in &config.branches {
        if bc.regex.is_none() {
            anyhow::bail!(
                "Branch configuration '{name}' is missing required configuration 'regex'{HELP}"
            );
        }
        let missing: Vec<&str> = bc
            .source_branches
            .iter()
            .filter(|sb| !config.branches.contains_key(*sb))
            .map(|s| s.as_str())
            .collect();
        if !missing.is_empty() {
            anyhow::bail!(
                "Branch configuration '{name}' defines these 'source-branches' that are not configured: '[{}]'{HELP}",
                missing.join(",")
            );
        }
    }
    Ok(())
}

/// Reverse-map `is-source-branch-for`: if branch A declares `is-source-branch-for: [X]`,
/// A is added to X's `source-branches` (mirrors the original `ApplySourceBranchesSourceBranch`).
pub fn apply_source_branch_mappings(config: &mut GitVersionConfiguration) {
    let mappings: Vec<(String, Vec<String>)> = config
        .branches
        .iter()
        .filter(|(_, b)| !b.is_source_branch_for.is_empty())
        .map(|(k, b)| (k.clone(), b.is_source_branch_for.clone()))
        .collect();
    for (source, targets) in mappings {
        for target in targets {
            if let Some(tb) = config.branches.get_mut(&target) {
                if !tb.source_branches.contains(&source) {
                    tb.source_branches.push(source.clone());
                }
            }
        }
    }
}

/// Overlay `over` onto `base` (only Some / non-empty values are applied).
pub fn merge(base: &mut GitVersionConfiguration, over: GitVersionConfiguration) {
    macro_rules! ov {
        ($field:ident) => {
            if over.$field.is_some() {
                base.$field = over.$field;
            }
        };
    }
    ov!(workflow);
    ov!(assembly_versioning_scheme);
    ov!(assembly_file_versioning_scheme);
    ov!(assembly_informational_format);
    ov!(assembly_versioning_format);
    ov!(assembly_file_versioning_format);
    ov!(tag_prefix);
    ov!(version_in_branch_pattern);
    ov!(next_version);
    ov!(major_version_bump_message);
    ov!(minor_version_bump_message);
    ov!(patch_version_bump_message);
    ov!(no_bump_message);
    ov!(tag_pre_release_weight);
    ov!(commit_date_format);
    ov!(semantic_version_format);
    ov!(update_build_number);
    ov!(increment);
    ov!(mode);
    ov!(label);
    ov!(regex);
    ov!(commit_message_incrementing);
    ov!(prevent_increment);
    ov!(track_merge_target);
    ov!(track_merge_message);
    ov!(tracks_release_branches);
    ov!(is_release_branch);
    ov!(is_main_branch);
    ov!(pre_release_weight);
    ov!(label_number_pattern);

    if !over.strategies.is_empty() {
        base.strategies = over.strategies;
    }
    if !over.source_branches.is_empty() {
        base.source_branches = over.source_branches;
    }
    if !over.is_source_branch_for.is_empty() {
        base.is_source_branch_for = over.is_source_branch_for;
    }
    if over.ignore.commits_before.is_some()
        || !over.ignore.sha.is_empty()
        || !over.ignore.paths.is_empty()
    {
        base.ignore = over.ignore;
    }
    if !over.merge_message_formats.is_empty() {
        base.merge_message_formats
            .extend(over.merge_message_formats);
    }
    if !over.exec.is_empty() {
        base.exec.extend(over.exec);
    }

    // Per-branch merge.
    for (key, ob) in over.branches {
        let entry = base.branches.entry(key).or_default();
        merge_branch(entry, ob);
    }
}

fn merge_branch(base: &mut BranchConfiguration, over: BranchConfiguration) {
    macro_rules! ov {
        ($field:ident) => {
            if over.$field.is_some() {
                base.$field = over.$field;
            }
        };
    }
    ov!(regex);
    ov!(label);
    ov!(increment);
    ov!(mode);
    ov!(commit_message_incrementing);
    ov!(prevent_increment);
    ov!(track_merge_target);
    ov!(track_merge_message);
    ov!(tracks_release_branches);
    ov!(is_release_branch);
    ov!(is_main_branch);
    ov!(pre_release_weight);
    ov!(label_number_pattern);
    if !over.source_branches.is_empty() {
        base.source_branches = over.source_branches;
    }
    if !over.is_source_branch_for.is_empty() {
        base.is_source_branch_for = over.is_source_branch_for;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn config_from(yaml: &str) -> GitVersionConfiguration {
        let over: GitVersionConfiguration = serde_yaml::from_str(yaml).unwrap();
        let mut base = defaults::for_workflow(over.workflow.as_deref());
        merge(&mut base, over);
        apply_source_branch_mappings(&mut base);
        base
    }

    #[test]
    fn validate_rejects_missing_regex() {
        let c = config_from("branches:\n  custom:\n    label: x\n");
        let err = validate(&c).unwrap_err().to_string();
        assert!(err.contains("'custom'") && err.contains("'regex'"), "{err}");
    }

    #[test]
    fn validate_rejects_unknown_source_branch() {
        let c =
            config_from("branches:\n  custom:\n    regex: '^c$'\n    source-branches: [nope]\n");
        let err = validate(&c).unwrap_err().to_string();
        assert!(
            err.contains("not configured") && err.contains("nope"),
            "{err}"
        );
    }

    #[test]
    fn validate_accepts_defaults_and_valid_custom() {
        assert!(validate(&defaults::gitflow()).is_ok());
        assert!(validate(&defaults::githubflow()).is_ok());
        let c =
            config_from("branches:\n  custom:\n    regex: '^c$'\n    source-branches: [main]\n");
        assert!(validate(&c).is_ok());
    }

    fn unique_temp_dir(tag: &str) -> PathBuf {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let dir =
            std::env::temp_dir().join(format!("gv-loader-{tag}-{}-{nanos}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn locate_matches_lowercase_dotted_name() {
        // GitVersion's own repo uses the all-lowercase `.gitversion.yml`; it must be found.
        let dir = unique_temp_dir("lower");
        std::fs::write(dir.join(".gitversion.yml"), "workflow: GitHubFlow/v1\n").unwrap();
        let found = locate(&dir, None).expect("config should be located");
        assert_eq!(found.file_name().unwrap(), ".gitversion.yml");
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn locate_matches_yaml_extension() {
        // The `.yaml` extension (here lowercase, non-dotted) is recognised too.
        let dir = unique_temp_dir("yaml");
        std::fs::write(dir.join("gitversion.yaml"), "workflow: GitHubFlow/v1\n").unwrap();
        let found = locate(&dir, None).expect("yaml config should be located");
        assert_eq!(found.file_name().unwrap(), "gitversion.yaml");
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn locate_prefers_non_dotted_by_priority() {
        // Both present (case-insensitive): the non-dotted candidate wins regardless of casing.
        let dir = unique_temp_dir("priority");
        std::fs::write(dir.join("gitversion.yml"), "workflow: GitHubFlow/v1\n").unwrap();
        std::fs::write(dir.join(".gitversion.yaml"), "workflow: GitHubFlow/v1\n").unwrap();
        let found = locate(&dir, None).unwrap();
        assert_eq!(found.file_name().unwrap(), "gitversion.yml");
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn source_branch_reverse_mapping() {
        let c = config_from(
            "branches:\n  myfeat:\n    regex: '^myfeat$'\n    is-source-branch-for: [main]\n",
        );
        assert!(c.branches["main"]
            .source_branches
            .contains(&"myfeat".to_string()));
    }

    #[test]
    fn label_number_pattern_yaml_roundtrip() {
        // label-number-pattern is parsed from YAML and applied to the branch.
        let c = config_from(
            "branches:\n  main:\n    regex: '^main$'\n    label-number-pattern: '[0-9]+'\n",
        );
        assert_eq!(
            c.branches["main"].label_number_pattern.as_deref(),
            Some("[0-9]+")
        );
    }

    #[test]
    fn workflow_file_path_detection() {
        assert!(is_workflow_file_path("./my-workflow.yml"));
        assert!(is_workflow_file_path("../shared/gitversion.yaml"));
        assert!(is_workflow_file_path("/absolute/path.yml"));
        assert!(is_workflow_file_path("some-file.yml"));
        assert!(is_workflow_file_path("some-file.yaml"));
        assert!(!is_workflow_file_path("GitFlow/v1"));
        assert!(!is_workflow_file_path("GitHubFlow/v1"));
        assert!(!is_workflow_file_path("TrunkBased/preview1"));
    }
}