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] = [
17 "GitVersion.yml",
18 "GitVersion.yaml",
19 ".GitVersion.yml",
20 ".GitVersion.yaml",
21];
22
23pub fn locate(dir: &Path, repo_root: Option<&Path>) -> Option<PathBuf> {
29 let mut search_dirs = vec![dir.to_path_buf()];
30 if let Some(root) = repo_root {
31 if root != dir {
32 search_dirs.push(root.to_path_buf());
33 }
34 }
35 for d in search_dirs {
36 let Ok(entries) = std::fs::read_dir(&d) else {
38 continue;
39 };
40 let files: Vec<(String, PathBuf)> = entries
41 .flatten()
42 .filter(|e| e.path().is_file())
43 .filter_map(|e| e.file_name().to_str().map(|s| (s.to_string(), e.path())))
44 .collect();
45 for cand in CANDIDATES {
46 if let Some((_, path)) = files
47 .iter()
48 .find(|(name, _)| name.eq_ignore_ascii_case(cand))
49 {
50 return Some(path.clone());
51 }
52 }
53 }
54 None
55}
56
57fn is_workflow_file_path(s: &str) -> bool {
61 s.starts_with("./")
62 || s.starts_with("../")
63 || s.starts_with('/')
64 || s.ends_with(".yml")
65 || s.ends_with(".yaml")
66}
67
68fn load_workflow_file(wf_path: &str, config_dir: &Path) -> Result<GitVersionConfiguration> {
72 let abs = if Path::new(wf_path).is_absolute() {
73 Path::new(wf_path).to_path_buf()
74 } else {
75 config_dir.join(wf_path)
76 };
77 let text = std::fs::read_to_string(&abs)
78 .with_context(|| t!("config.read_failed", path = abs.display()))?;
79 serde_yaml::from_str(&text)
80 .with_context(|| t!("config.yaml_parse_failed", path = abs.display()))
81}
82
83pub fn load(
85 explicit_path: Option<&Path>,
86 work_dir: &Path,
87 repo_root: Option<&Path>,
88) -> Result<GitVersionConfiguration> {
89 let path = match explicit_path {
90 Some(p) => Some(p.to_path_buf()),
91 None => locate(work_dir, repo_root),
92 };
93
94 let Some(path) = path else {
95 return Ok(defaults::gitflow());
97 };
98
99 let text = std::fs::read_to_string(&path)
100 .with_context(|| t!("config.read_failed", path = path.display()))?;
101 let overrides: GitVersionConfiguration = serde_yaml::from_str(&text)
102 .with_context(|| t!("config.yaml_parse_failed", path = path.display()))?;
103
104 let config_dir = path.parent().unwrap_or(work_dir);
106 let mut base = match overrides.workflow.as_deref() {
107 Some(wf) if is_workflow_file_path(wf) => load_workflow_file(wf, config_dir)?,
108 wf => defaults::for_workflow(wf),
109 };
110 merge(&mut base, overrides);
111 apply_source_branch_mappings(&mut base);
112 validate(&base).with_context(|| t!("config.validate_failed", path = path.display()))?;
113 Ok(base)
114}
115
116pub fn validate(config: &GitVersionConfiguration) -> Result<()> {
121 const HELP: &str = "\nSee https://gitversion.net/docs/reference/configuration for more info";
122 for (name, bc) in &config.branches {
123 if bc.regex.is_none() {
124 anyhow::bail!(
125 "Branch configuration '{name}' is missing required configuration 'regex'{HELP}"
126 );
127 }
128 let missing: Vec<&str> = bc
129 .source_branches
130 .iter()
131 .filter(|sb| !config.branches.contains_key(*sb))
132 .map(|s| s.as_str())
133 .collect();
134 if !missing.is_empty() {
135 anyhow::bail!(
136 "Branch configuration '{name}' defines these 'source-branches' that are not configured: '[{}]'{HELP}",
137 missing.join(",")
138 );
139 }
140 }
141 Ok(())
142}
143
144pub fn apply_source_branch_mappings(config: &mut GitVersionConfiguration) {
147 let mappings: Vec<(String, Vec<String>)> = config
148 .branches
149 .iter()
150 .filter(|(_, b)| !b.is_source_branch_for.is_empty())
151 .map(|(k, b)| (k.clone(), b.is_source_branch_for.clone()))
152 .collect();
153 for (source, targets) in mappings {
154 for target in targets {
155 if let Some(tb) = config.branches.get_mut(&target) {
156 if !tb.source_branches.contains(&source) {
157 tb.source_branches.push(source.clone());
158 }
159 }
160 }
161 }
162}
163
164pub fn merge(base: &mut GitVersionConfiguration, over: GitVersionConfiguration) {
166 macro_rules! ov {
167 ($field:ident) => {
168 if over.$field.is_some() {
169 base.$field = over.$field;
170 }
171 };
172 }
173 ov!(workflow);
174 ov!(assembly_versioning_scheme);
175 ov!(assembly_file_versioning_scheme);
176 ov!(assembly_informational_format);
177 ov!(assembly_versioning_format);
178 ov!(assembly_file_versioning_format);
179 ov!(tag_prefix);
180 ov!(version_in_branch_pattern);
181 ov!(next_version);
182 ov!(major_version_bump_message);
183 ov!(minor_version_bump_message);
184 ov!(patch_version_bump_message);
185 ov!(no_bump_message);
186 ov!(tag_pre_release_weight);
187 ov!(commit_date_format);
188 ov!(semantic_version_format);
189 ov!(update_build_number);
190 ov!(increment);
191 ov!(mode);
192 ov!(label);
193 ov!(regex);
194 ov!(commit_message_incrementing);
195 ov!(prevent_increment);
196 ov!(track_merge_target);
197 ov!(track_merge_message);
198 ov!(tracks_release_branches);
199 ov!(is_release_branch);
200 ov!(is_main_branch);
201 ov!(pre_release_weight);
202 ov!(label_number_pattern);
203
204 if !over.strategies.is_empty() {
205 base.strategies = over.strategies;
206 }
207 if !over.source_branches.is_empty() {
208 base.source_branches = over.source_branches;
209 }
210 if !over.is_source_branch_for.is_empty() {
211 base.is_source_branch_for = over.is_source_branch_for;
212 }
213 if over.ignore.commits_before.is_some()
214 || !over.ignore.sha.is_empty()
215 || !over.ignore.paths.is_empty()
216 {
217 base.ignore = over.ignore;
218 }
219 if !over.merge_message_formats.is_empty() {
220 base.merge_message_formats
221 .extend(over.merge_message_formats);
222 }
223 if !over.exec.is_empty() {
224 base.exec.extend(over.exec);
225 }
226
227 for (key, ob) in over.branches {
229 let entry = base.branches.entry(key).or_default();
230 merge_branch(entry, ob);
231 }
232}
233
234fn merge_branch(base: &mut BranchConfiguration, over: BranchConfiguration) {
235 macro_rules! ov {
236 ($field:ident) => {
237 if over.$field.is_some() {
238 base.$field = over.$field;
239 }
240 };
241 }
242 ov!(regex);
243 ov!(label);
244 ov!(increment);
245 ov!(mode);
246 ov!(commit_message_incrementing);
247 ov!(prevent_increment);
248 ov!(track_merge_target);
249 ov!(track_merge_message);
250 ov!(tracks_release_branches);
251 ov!(is_release_branch);
252 ov!(is_main_branch);
253 ov!(pre_release_weight);
254 ov!(label_number_pattern);
255 if !over.source_branches.is_empty() {
256 base.source_branches = over.source_branches;
257 }
258 if !over.is_source_branch_for.is_empty() {
259 base.is_source_branch_for = over.is_source_branch_for;
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 fn config_from(yaml: &str) -> GitVersionConfiguration {
268 let over: GitVersionConfiguration = serde_yaml::from_str(yaml).unwrap();
269 let mut base = defaults::for_workflow(over.workflow.as_deref());
270 merge(&mut base, over);
271 apply_source_branch_mappings(&mut base);
272 base
273 }
274
275 #[test]
276 fn validate_rejects_missing_regex() {
277 let c = config_from("branches:\n custom:\n label: x\n");
278 let err = validate(&c).unwrap_err().to_string();
279 assert!(err.contains("'custom'") && err.contains("'regex'"), "{err}");
280 }
281
282 #[test]
283 fn validate_rejects_unknown_source_branch() {
284 let c =
285 config_from("branches:\n custom:\n regex: '^c$'\n source-branches: [nope]\n");
286 let err = validate(&c).unwrap_err().to_string();
287 assert!(
288 err.contains("not configured") && err.contains("nope"),
289 "{err}"
290 );
291 }
292
293 #[test]
294 fn validate_accepts_defaults_and_valid_custom() {
295 assert!(validate(&defaults::gitflow()).is_ok());
296 assert!(validate(&defaults::githubflow()).is_ok());
297 let c =
298 config_from("branches:\n custom:\n regex: '^c$'\n source-branches: [main]\n");
299 assert!(validate(&c).is_ok());
300 }
301
302 fn unique_temp_dir(tag: &str) -> PathBuf {
303 let nanos = std::time::SystemTime::now()
304 .duration_since(std::time::UNIX_EPOCH)
305 .unwrap()
306 .as_nanos();
307 let dir =
308 std::env::temp_dir().join(format!("gv-loader-{tag}-{}-{nanos}", std::process::id()));
309 std::fs::create_dir_all(&dir).unwrap();
310 dir
311 }
312
313 #[test]
314 fn locate_matches_lowercase_dotted_name() {
315 let dir = unique_temp_dir("lower");
317 std::fs::write(dir.join(".gitversion.yml"), "workflow: GitHubFlow/v1\n").unwrap();
318 let found = locate(&dir, None).expect("config should be located");
319 assert_eq!(found.file_name().unwrap(), ".gitversion.yml");
320 std::fs::remove_dir_all(&dir).ok();
321 }
322
323 #[test]
324 fn locate_matches_yaml_extension() {
325 let dir = unique_temp_dir("yaml");
327 std::fs::write(dir.join("gitversion.yaml"), "workflow: GitHubFlow/v1\n").unwrap();
328 let found = locate(&dir, None).expect("yaml config should be located");
329 assert_eq!(found.file_name().unwrap(), "gitversion.yaml");
330 std::fs::remove_dir_all(&dir).ok();
331 }
332
333 #[test]
334 fn locate_prefers_non_dotted_by_priority() {
335 let dir = unique_temp_dir("priority");
337 std::fs::write(dir.join("gitversion.yml"), "workflow: GitHubFlow/v1\n").unwrap();
338 std::fs::write(dir.join(".gitversion.yaml"), "workflow: GitHubFlow/v1\n").unwrap();
339 let found = locate(&dir, None).unwrap();
340 assert_eq!(found.file_name().unwrap(), "gitversion.yml");
341 std::fs::remove_dir_all(&dir).ok();
342 }
343
344 #[test]
345 fn source_branch_reverse_mapping() {
346 let c = config_from(
347 "branches:\n myfeat:\n regex: '^myfeat$'\n is-source-branch-for: [main]\n",
348 );
349 assert!(c.branches["main"]
350 .source_branches
351 .contains(&"myfeat".to_string()));
352 }
353
354 #[test]
355 fn label_number_pattern_yaml_roundtrip() {
356 let c = config_from(
358 "branches:\n main:\n regex: '^main$'\n label-number-pattern: '[0-9]+'\n",
359 );
360 assert_eq!(
361 c.branches["main"].label_number_pattern.as_deref(),
362 Some("[0-9]+")
363 );
364 }
365
366 #[test]
367 fn workflow_file_path_detection() {
368 assert!(is_workflow_file_path("./my-workflow.yml"));
369 assert!(is_workflow_file_path("../shared/gitversion.yaml"));
370 assert!(is_workflow_file_path("/absolute/path.yml"));
371 assert!(is_workflow_file_path("some-file.yml"));
372 assert!(is_workflow_file_path("some-file.yaml"));
373 assert!(!is_workflow_file_path("GitFlow/v1"));
374 assert!(!is_workflow_file_path("GitHubFlow/v1"));
375 assert!(!is_workflow_file_path("TrunkBased/preview1"));
376 }
377}