1use crate::version::{normalize_pretty, UnsupportedVersion};
10use serde_json::Value;
11use std::path::Path;
12use std::process::Command;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct RootVersion {
16 pub pretty_version: String,
17 pub version: String,
19 pub reference: Option<String>,
20}
21
22pub const DEFAULT_PRETTY_VERSION: &str = "1.0.0+no-version-set";
23
24fn normalize_or_raw(v: &str) -> String {
25 normalize_pretty(v).unwrap_or_else(|_| v.to_owned())
26}
27
28pub fn normalize_branch(name: &str) -> String {
31 let name = name.trim();
32 let stripped = name
33 .strip_prefix('v')
34 .or_else(|| name.strip_prefix('V'))
35 .unwrap_or(name);
36 let parts: Vec<&str> = stripped.split('.').collect();
37 let numeric_or_x = |s: &str| {
38 !s.is_empty() && (s.bytes().all(|b| b.is_ascii_digit()) || matches!(s, "x" | "X" | "*"))
39 };
40 if (1..=4).contains(&parts.len())
41 && parts[0].bytes().all(|b| b.is_ascii_digit())
42 && !parts[0].is_empty()
43 && parts[1..].iter().all(|p| numeric_or_x(p))
44 {
45 let mut out: Vec<String> = parts
46 .iter()
47 .map(|p| {
48 if matches!(*p, "x" | "X" | "*") {
49 "9999999".to_owned()
50 } else {
51 (*p).to_owned()
52 }
53 })
54 .collect();
55 while out.len() < 4 {
56 out.push("9999999".to_owned());
57 }
58 return format!("{}-dev", out.join("."));
59 }
60 format!("dev-{name}")
61}
62
63fn is_feature_branch(manifest: &Value, branch: &str) -> bool {
65 let mut non_feature: Vec<String> = manifest
66 .get("non-feature-branches")
67 .and_then(Value::as_array)
68 .map(|a| {
69 a.iter()
70 .filter_map(Value::as_str)
71 .map(str::to_owned)
72 .collect()
73 })
74 .unwrap_or_default();
75 non_feature.extend(
76 [
77 "master", "main", "latest", "next", "current", "support", "tip", "trunk", "default",
78 "develop",
79 ]
80 .iter()
81 .map(|s| (*s).to_owned()),
82 );
83 if non_feature.iter().any(|n| n == branch) {
84 return false;
85 }
86 let mut it = branch.splitn(2, '.');
88 if let (Some(head), Some(rest)) = (it.next(), it.next()) {
89 if !head.is_empty() && head.bytes().all(|b| b.is_ascii_digit()) && !rest.is_empty() {
90 return false;
91 }
92 }
93 true
94}
95
96fn git(project: &Path, args: &[&str]) -> Option<String> {
97 let out = Command::new("git")
98 .args(args)
99 .current_dir(project)
100 .env("GIT_DIR", project.join(".git"))
101 .env("GIT_WORK_TREE", project)
102 .env_remove("GIT_INDEX_FILE")
103 .env("LANGUAGE", "C")
105 .env("LC_ALL", "C")
106 .env("GIT_TERMINAL_PROMPT", "0")
107 .output()
108 .ok()?;
109 if !out.status.success() {
110 return None;
111 }
112 Some(String::from_utf8_lossy(&out.stdout).into_owned())
113}
114
115fn guess_git(manifest: &Value, project: &Path) -> Option<RootVersion> {
117 if !project.join(".git").exists() {
118 return None;
119 }
120 let output = git(
121 project,
122 &["branch", "-a", "--no-color", "--no-abbrev", "-v"],
123 )?;
124 let mut version: Option<String> = None;
125 let mut pretty: Option<String> = None;
126 let mut commit: Option<String> = None;
127 let mut is_feature = false;
128 let mut is_detached = false;
129 let mut branches: Vec<String> = Vec::new();
130 let is_hex = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_hexdigit());
131
132 for line in output.lines() {
133 if line.is_empty() {
134 continue;
135 }
136 if let Some(rest) = line.strip_prefix("* ") {
138 let rest = rest.trim_start();
139 let (name, tail) = if rest.starts_with('(') {
140 match rest.find(')') {
141 Some(i) => (&rest[..=i], rest[i + 1..].trim_start()),
142 None => continue,
143 }
144 } else {
145 match rest.find(' ') {
146 Some(i) => (&rest[..i], rest[i..].trim_start()),
147 None => continue,
148 }
149 };
150 let sha = tail.split_whitespace().next().unwrap_or("");
151 if !is_hex(sha) {
152 continue;
153 }
154 if name == "(no branch)"
155 || name.starts_with("(detached ")
156 || name.starts_with("(HEAD detached at")
157 {
158 version = Some(format!("dev-{sha}"));
159 pretty = version.clone();
160 is_feature = true;
161 is_detached = true;
162 } else {
163 version = Some(normalize_branch(name));
164 pretty = Some(format!("dev-{name}"));
165 is_feature = is_feature_branch(manifest, name);
166 }
167 commit = Some(sha.to_owned());
168 }
169 let trimmed = line.trim_start_matches("* ").trim_start();
171 let mut parts = trimmed.split_whitespace();
172 let (Some(name), Some(sha)) = (parts.next(), parts.next()) else {
173 continue;
174 };
175 if name.ends_with("/HEAD") || !is_hex(sha) {
176 continue;
177 }
178 let bare = name
179 .strip_prefix("remotes/origin/")
180 .or_else(|| name.strip_prefix("remotes/upstream/"))
181 .unwrap_or(name);
182 if bare.contains('/') {
183 continue;
184 }
185 branches.push(name.to_owned());
186 }
187
188 if is_feature {
189 if let (Some(v), Some(_)) = (&version, &pretty) {
190 let (nv, np) = guess_feature_version(manifest, v, &branches, project);
191 version = Some(nv);
192 pretty = Some(np);
193 }
194 }
195 if version.is_none() || is_detached {
196 if let Some(tag) = git(project, &["describe", "--exact-match", "--tags"]) {
197 let tag = tag.trim();
198 if let Ok(norm) = normalize_pretty(tag) {
199 version = Some(norm);
200 pretty = Some(tag.to_owned());
201 }
202 }
203 }
204 if commit.is_none() {
205 if let Some(out) = git(project, &["rev-list", "--format=%H", "-n1", "HEAD"]) {
206 commit = out
207 .lines()
208 .find(|l| !l.starts_with("commit "))
209 .map(|l| l.trim().to_owned())
210 .filter(|s| !s.is_empty());
211 }
212 }
213 let version = version?;
214 let pretty = if version.ends_with("-dev") && version.contains(".9999999") {
216 collapse_nines(&version)
217 } else {
218 pretty?
219 };
220 Some(RootVersion {
221 pretty_version: pretty,
222 version,
223 reference: commit,
224 })
225}
226
227fn collapse_nines(version: &str) -> String {
228 let mut out = version.replace(".9999999", "\u{0}");
229 while out.contains("\u{0}\u{0}") {
230 out = out.replace("\u{0}\u{0}", "\u{0}");
231 }
232 out.replace('\u{0}', ".x")
233}
234
235fn guess_feature_version(
238 manifest: &Value,
239 version: &str,
240 branches: &[String],
241 project: &Path,
242) -> (String, String) {
243 let has_alias = manifest
244 .get("extra")
245 .and_then(|e| e.get("branch-alias"))
246 .and_then(|b| b.get(version))
247 .is_some();
248 let has_self_version = manifest.to_string().contains("\"self.version\"");
249 if has_alias && !has_self_version {
250 return (version.to_owned(), version.to_owned());
251 }
252 let branch = version.strip_prefix("dev-").unwrap_or(version).to_owned();
253 if !is_feature_branch(manifest, &branch) {
254 return (version.to_owned(), version.to_owned());
255 }
256 let mut sorted: Vec<String> = branches.to_vec();
257 sorted.sort_by(|a, b| {
258 let (ar, br) = (a.starts_with("remotes/"), b.starts_with("remotes/"));
259 if ar != br {
260 return if ar {
261 std::cmp::Ordering::Greater
262 } else {
263 std::cmp::Ordering::Less
264 };
265 }
266 strnatcasecmp(b, a)
267 });
268 let mut best_len = usize::MAX;
269 let mut result = (version.to_owned(), version.to_owned());
270 for candidate in &sorted {
271 let candidate_version = candidate
272 .strip_prefix("remotes/")
273 .and_then(|r| r.split_once('/').map(|x| x.1))
274 .unwrap_or(candidate);
275 if candidate == &branch || is_feature_branch(manifest, candidate_version) {
276 continue;
277 }
278 let Some(out) = git(project, &["rev-list", &format!("{candidate}..{branch}")]) else {
279 continue;
280 };
281 if out.len() <= best_len {
283 best_len = out.len();
284 result = (
285 normalize_branch(candidate_version),
286 format!("dev-{candidate_version}"),
287 );
288 if best_len == 0 {
289 break;
290 }
291 }
292 }
293 result
294}
295
296fn strnatcasecmp(a: &str, b: &str) -> std::cmp::Ordering {
299 let (a, b) = (a.to_ascii_lowercase(), b.to_ascii_lowercase());
300 let (ab, bb) = (a.as_bytes(), b.as_bytes());
301 let (mut i, mut j) = (0, 0);
302 while i < ab.len() && j < bb.len() {
303 if ab[i].is_ascii_digit() && bb[j].is_ascii_digit() {
304 let si = i;
305 while i < ab.len() && ab[i].is_ascii_digit() {
306 i += 1;
307 }
308 let sj = j;
309 while j < bb.len() && bb[j].is_ascii_digit() {
310 j += 1;
311 }
312 let na: u128 = a[si..i].parse().unwrap_or(0);
313 let nb: u128 = b[sj..j].parse().unwrap_or(0);
314 if na != nb {
315 return na.cmp(&nb);
316 }
317 } else {
318 if ab[i] != bb[j] {
319 return ab[i].cmp(&bb[j]);
320 }
321 i += 1;
322 j += 1;
323 }
324 }
325 (ab.len() - i).cmp(&(bb.len() - j))
326}
327
328pub fn detect(manifest: &Value, project: &Path) -> RootVersion {
330 if let Some(v) = manifest.get("version").and_then(Value::as_str) {
331 return RootVersion {
332 pretty_version: v.to_owned(),
333 version: normalize_or_raw(v),
334 reference: None,
335 };
336 }
337 if let Ok(env) = std::env::var("COMPOSER_ROOT_VERSION") {
338 if !env.is_empty() {
339 let v = match env.strip_suffix("-dev") {
341 Some(num)
342 if !num.is_empty()
343 && num
344 .split('.')
345 .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) =>
346 {
347 format!("{num}.x-dev")
348 }
349 _ => env.clone(),
350 };
351 return RootVersion {
352 pretty_version: v.clone(),
353 version: normalize_or_raw(&v),
354 reference: None,
355 };
356 }
357 }
358 if let Some(g) = guess_git(manifest, project) {
359 return g;
360 }
361 RootVersion {
362 pretty_version: DEFAULT_PRETTY_VERSION.to_owned(),
363 version: "1.0.0.0".to_owned(),
364 reference: None,
365 }
366}
367
368pub const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev";
370
371pub fn parse_numeric_alias_prefix(branch: &str) -> Option<String> {
374 let n = branch.len();
375 if n < 4 || !branch.is_char_boundary(n - 4) || !branch[n - 4..].eq_ignore_ascii_case("-dev") {
376 return None;
377 }
378 let mut rest = &branch[..n - 4];
379 if let Some(r) = rest.strip_suffix(".x").or_else(|| rest.strip_suffix(".X")) {
380 rest = r;
381 }
382 let numeric = !rest.is_empty()
383 && rest
384 .split('.')
385 .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
386 numeric.then(|| format!("{rest}."))
387}
388
389fn pretty_alias(normalized: &str) -> String {
392 const X: &str = ".9999999";
393 let mut out = String::with_capacity(normalized.len());
394 let mut rest = normalized;
395 while let Some(i) = rest.find(X) {
396 out.push_str(&rest[..i]);
397 out.push_str(".x");
398 rest = &rest[i + X.len()..];
399 while let Some(r) = rest.strip_prefix(X) {
400 rest = r;
401 }
402 }
403 out.push_str(rest);
404 out
405}
406
407pub fn branch_alias_of(
415 version: &str,
416 extra: Option<&Value>,
417 default_branch: bool,
418) -> Option<(String, String)> {
419 if !(version.starts_with("dev-") || version.ends_with("-dev")) {
420 return None;
421 }
422 if let Some(map) = extra
423 .and_then(|e| e.get("branch-alias"))
424 .and_then(Value::as_object)
425 {
426 for (source, target) in map {
427 let Some(target) = target.as_str() else {
428 continue;
429 };
430 let Some(target_base) = target.strip_suffix("-dev") else {
431 continue;
432 };
433 let validated = if target == DEFAULT_BRANCH_ALIAS {
434 target.to_owned()
435 } else {
436 normalize_branch(target_base)
437 };
438 if !validated.ends_with("-dev") {
439 continue;
440 }
441 if version.to_lowercase() != source.to_lowercase() {
442 continue;
443 }
444 if let (Some(sp), Some(tp)) = (
445 parse_numeric_alias_prefix(source),
446 parse_numeric_alias_prefix(target),
447 ) {
448 if !tp.to_lowercase().starts_with(&sp.to_lowercase()) {
449 continue;
450 }
451 }
452 let pretty = pretty_alias(&validated);
453 return Some((validated, pretty));
454 }
455 }
456 if default_branch {
457 let v = version.strip_prefix('v').unwrap_or(version);
458 if parse_numeric_alias_prefix(v).is_none() {
459 return Some((
460 DEFAULT_BRANCH_ALIAS.to_owned(),
461 DEFAULT_BRANCH_ALIAS.to_owned(),
462 ));
463 }
464 }
465 None
466}
467
468pub fn branch_alias(manifest: &Value, root: &RootVersion) -> Option<(String, String)> {
471 let default_branch = manifest
472 .get("default-branch")
473 .and_then(Value::as_bool)
474 .unwrap_or(false);
475 branch_alias_of(&root.pretty_version, manifest.get("extra"), default_branch)
476}
477
478impl std::fmt::Display for UnsupportedVersionAlias {
479 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480 write!(f, "{}", self.0)
481 }
482}
483#[derive(Debug)]
484pub struct UnsupportedVersionAlias(pub UnsupportedVersion);
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use serde_json::json;
490
491 #[test]
492 fn normalize_branch_matches_semver() {
493 assert_eq!(normalize_branch("main"), "dev-main");
494 assert_eq!(normalize_branch("2.2"), "2.2.9999999.9999999-dev");
495 assert_eq!(normalize_branch("1.x"), "1.9999999.9999999.9999999-dev");
496 assert_eq!(normalize_branch("v3"), "3.9999999.9999999.9999999-dev");
497 assert_eq!(normalize_branch("feature/x"), "dev-feature/x");
498 }
499
500 #[test]
501 fn feature_branches() {
502 let m = json!({});
503 assert!(!is_feature_branch(&m, "main"));
504 assert!(!is_feature_branch(&m, "develop"));
505 assert!(!is_feature_branch(&m, "2.2"));
506 assert!(is_feature_branch(&m, "feature-x"));
507 let m = json!({"non-feature-branches": ["release-.*"]});
508 assert!(is_feature_branch(&m, "release-1")); }
510
511 #[test]
512 fn collapse() {
513 assert_eq!(collapse_nines("2.2.9999999.9999999-dev"), "2.2.x-dev");
514 assert_eq!(collapse_nines("1.9999999.9999999.9999999-dev"), "1.x-dev");
515 }
516
517 #[test]
518 fn detect_in_git_repo() {
519 let tmp = tempfile::tempdir().expect("tmp");
520 let p = tmp.path();
521 let run = |args: &[&str]| {
522 let st = Command::new("git")
523 .args(args)
524 .current_dir(p)
525 .env("GIT_AUTHOR_NAME", "t")
526 .env("GIT_AUTHOR_EMAIL", "t@t")
527 .env("GIT_COMMITTER_NAME", "t")
528 .env("GIT_COMMITTER_EMAIL", "t@t")
529 .status()
530 .expect("git");
531 assert!(st.success(), "git {args:?}");
532 };
533 run(&["init", "-q", "-b", "main"]);
534 std::fs::write(p.join("a.txt"), "a").expect("write");
535 run(&["add", "."]);
536 run(&["commit", "-q", "-m", "init"]);
537 let r = detect(&json!({}), p);
538 assert_eq!(r.pretty_version, "dev-main");
539 assert_eq!(r.version, "dev-main");
540 assert_eq!(r.reference.as_deref().map(str::len), Some(40));
541
542 run(&["checkout", "-q", "-b", "feature-x"]);
544 std::fs::write(p.join("b.txt"), "b").expect("write");
545 run(&["add", "."]);
546 run(&["commit", "-q", "-m", "feat"]);
547 let r = detect(&json!({}), p);
548 assert_eq!(r.pretty_version, "dev-main");
549
550 run(&["checkout", "-q", "-b", "2.2"]);
552 let m = json!({"extra": {"branch-alias": {"dev-2.2": "2.2.x-dev"}}});
553 let r = detect(&m, p);
554 assert_eq!(r.version, "2.2.9999999.9999999-dev");
555 assert_eq!(r.pretty_version, "2.2.x-dev");
556 assert_eq!(
557 branch_alias(&m, &r),
558 None,
559 "the alias is indexed by dev-2.2, not by the pretty version x-dev"
560 );
561
562 run(&["tag", "v1.2.3"]);
564 run(&["checkout", "-q", "--detach", "HEAD"]);
565 let r = detect(&json!({}), p);
566 assert_eq!(r.pretty_version, "v1.2.3");
567 assert_eq!(r.version, "1.2.3.0");
568 }
569}