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