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