Skip to main content

vivacity_core/
root_version.rs

1//! Root package version: port of `RootPackageLoader::load` +
2//! `VersionGuesser::guessGitVersion` (docs/reference/RootPackageLoader.php,
3//! VersionGuesser.php, Composer 2.10.3). Order: `version` from composer.json,
4//! else `COMPOSER_ROOT_VERSION`, else git (current branch; detached HEAD ->
5//! `dev-<sha>` then exact tag; feature branch -> closest parent branch by
6//! `git rev-list`), else `1.0.0+no-version-set`.
7//! hg/fossil/svn are not ported: where one of them would give Composer a
8//! version ([`foreign_vcs_version`]), the command goes to Composer.
9//!
10//! `guess_version` is the `VersionGuesser::guessVersion` used for the root
11//! and for the packages of a `path` repository: git is run in the directory
12//! with no `GIT_DIR` pin, so it walks up to the enclosing repository exactly
13//! as Composer's `git branch` does (a package without a repository of its
14//! own takes the project's branch).
15
16use crate::version::{normalize_pretty, UnsupportedVersion};
17use serde_json::Value;
18use std::path::Path;
19use std::process::Command;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct RootVersion {
23    pub pretty_version: String,
24    /// Normalised version (Composer's `version_normalized`).
25    pub version: String,
26    pub reference: Option<String>,
27}
28
29/// `VersionGuesser::guessVersion` result after `postprocess`: the version
30/// (the parent branch when HEAD is on a feature branch) and, on a feature
31/// branch, the feature branch itself (`feature_version`,
32/// `feature_pretty_version`) — a `path` repository loads both as packages.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct GuessedVersion {
35    pub version: String,
36    pub pretty_version: String,
37    pub commit: Option<String>,
38    pub feature_version: Option<String>,
39    pub feature_pretty_version: Option<String>,
40}
41
42pub const DEFAULT_PRETTY_VERSION: &str = "1.0.0+no-version-set";
43
44fn normalize_or_raw(v: &str) -> String {
45    normalize_pretty(v).unwrap_or_else(|_| v.to_owned())
46}
47
48/// `VersionParser::normalizeBranch` (composer/semver): `1.2` -> numeric
49/// `1.2.x.x-dev` (x -> 9999999), else `dev-<name>`.
50pub fn normalize_branch(name: &str) -> String {
51    let name = name.trim();
52    let stripped = name
53        .strip_prefix('v')
54        .or_else(|| name.strip_prefix('V'))
55        .unwrap_or(name);
56    let parts: Vec<&str> = stripped.split('.').collect();
57    let numeric_or_x = |s: &str| {
58        !s.is_empty() && (s.bytes().all(|b| b.is_ascii_digit()) || matches!(s, "x" | "X" | "*"))
59    };
60    if (1..=4).contains(&parts.len())
61        && parts[0].bytes().all(|b| b.is_ascii_digit())
62        && !parts[0].is_empty()
63        && parts[1..].iter().all(|p| numeric_or_x(p))
64    {
65        let mut out: Vec<String> = parts
66            .iter()
67            .map(|p| {
68                if matches!(*p, "x" | "X" | "*") {
69                    "9999999".to_owned()
70                } else {
71                    (*p).to_owned()
72                }
73            })
74            .collect();
75        while out.len() < 4 {
76            out.push("9999999".to_owned());
77        }
78        return format!("{}-dev", out.join("."));
79    }
80    format!("dev-{name}")
81}
82
83/// `VersionGuesser::isFeatureBranch`: the `non-feature-branches` entries
84/// are regex alternatives (`release-.*`), joined in front of the built-in
85/// names and the numeric `\d+\..+` form.
86fn is_feature_branch(manifest: &Value, branch: &str) -> bool {
87    let custom: Vec<&str> = manifest
88        .get("non-feature-branches")
89        .and_then(Value::as_array)
90        .map(|a| a.iter().filter_map(Value::as_str).collect())
91        .unwrap_or_default();
92    let mut pattern = String::from("^(");
93    if !custom.is_empty() {
94        pattern.push_str(&custom.join("|"));
95    }
96    pattern
97        .push_str("|master|main|latest|next|current|support|tip|trunk|default|develop|\\d+\\..+)$");
98    match pcre2::bytes::RegexBuilder::new().build(&pattern) {
99        Ok(re) => !re.is_match(branch.as_bytes()).unwrap_or(false),
100        // An invalid custom pattern: Composer's preg_match warns and
101        // returns false -> every branch is a feature branch.
102        Err(_) => true,
103    }
104}
105
106/// What git answered during a run, per (directory, arguments): every call
107/// here only reads the repository, and a run reads it several times (the
108/// root-version warning, the version itself, installed.php). Kept only
109/// between [`remember_git_answers`] and [`forget_git_answers`], which
110/// `vivacity::run` calls around each run — the repository may change
111/// between two runs of a program that embeds vivacity, and outside a run
112/// (a test that commits between two guesses) git always runs.
113type GitAnswers = std::collections::HashMap<(std::path::PathBuf, Vec<String>), Option<String>>;
114static GIT_ANSWERS: std::sync::Mutex<Option<GitAnswers>> = std::sync::Mutex::new(None);
115
116/// Starts keeping git's answers ([`git`]), empty, until the returned guard
117/// is dropped (panics included).
118#[must_use = "git's answers are forgotten when the guard is dropped"]
119pub fn remember_git_answers() -> GitAnswersScope {
120    if let Ok(mut answers) = GIT_ANSWERS.lock() {
121        *answers = Some(GitAnswers::new());
122    }
123    GitAnswersScope(())
124}
125
126/// Keeps git's answers while alive ([`remember_git_answers`]).
127pub struct GitAnswersScope(());
128
129impl Drop for GitAnswersScope {
130    fn drop(&mut self) {
131        forget_git_answers();
132    }
133}
134
135/// Forgets git's answers and stops keeping them.
136pub fn forget_git_answers() {
137    if let Ok(mut answers) = GIT_ANSWERS.lock() {
138        *answers = None;
139    }
140}
141
142/// Runs git in `dir` like `ProcessExecutor` after `GitUtil::cleanEnv`: no
143/// `GIT_DIR`/`GIT_WORK_TREE` (git finds the repository by walking up),
144/// English output, never an interactive prompt. None when git fails. Asked
145/// twice the same thing in one run, git runs once ([`remember_git_answers`]).
146pub fn git(dir: &Path, args: &[&str]) -> Option<String> {
147    let key = (
148        dir.to_path_buf(),
149        args.iter().map(|a| (*a).to_owned()).collect::<Vec<_>>(),
150    );
151    if let Ok(answers) = GIT_ANSWERS.lock() {
152        if let Some(answer) = answers.as_ref().and_then(|a| a.get(&key)) {
153            return answer.clone();
154        }
155    }
156    let answer = run_git(dir, args);
157    if let Ok(mut answers) = GIT_ANSWERS.lock() {
158        if let Some(answers) = answers.as_mut() {
159            answers.insert(key, answer.clone());
160        }
161    }
162    answer
163}
164
165fn run_git(dir: &Path, args: &[&str]) -> Option<String> {
166    let out = Command::new("git")
167        .args(args)
168        .current_dir(dir)
169        .env_remove("GIT_DIR")
170        .env_remove("GIT_WORK_TREE")
171        .env_remove("GIT_INDEX_FILE")
172        .env("LANGUAGE", "C")
173        .env("LC_ALL", "C")
174        .env("GIT_TERMINAL_PROMPT", "0")
175        .output()
176        .ok()?;
177    if !out.status.success() {
178        return None;
179    }
180    Some(String::from_utf8_lossy(&out.stdout).into_owned())
181}
182
183/// `git branch -a --no-color --no-abbrev -v`, as `guessGitVersion` runs it.
184const BRANCH_ARGS: &[&str] = &["branch", "-a", "--no-color", "--no-abbrev", "-v"];
185
186/// The current line of `git branch -v` (`* <name> <sha> …`) as
187/// `guessGitVersion`'s regex reads it: its name and sha. `(no branch)`,
188/// `(detached from X)` and `(HEAD detached at X)` are the only parenthesised
189/// forms it knows; any other (`(HEAD detached from X)`) matches nothing. None
190/// for any other line. Slightly wider than the regex on input git never
191/// prints (uppercase hex, a sha ending the line).
192fn current_branch(line: &str) -> Option<(&str, &str)> {
193    let rest = line.strip_prefix("* ")?.trim_start();
194    let (name, tail) = if rest.starts_with('(') {
195        let i = rest.find(')')?;
196        (&rest[..=i], rest[i + 1..].trim_start())
197    } else {
198        let i = rest.find(' ')?;
199        (&rest[..i], rest[i..].trim_start())
200    };
201    let sha = tail.split_whitespace().next().unwrap_or("");
202    if sha.is_empty() || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
203        return None;
204    }
205    if name.starts_with('(')
206        && !(name == "(no branch)"
207            || name.starts_with("(detached from ")
208            || name.starts_with("(HEAD detached at "))
209    {
210        return None;
211    }
212    Some((name, sha))
213}
214
215/// Whether [`guess_version`] finds a version, with as few git calls as it
216/// takes to know: one when HEAD is on a branch or detached at a commit, a
217/// second (`describe`) otherwise. `RootPackageLoader::load` warns when it
218/// does not (`vivacity_resolver::root::undetected_version_warning`); the
219/// version itself is guessed later, by the command.
220pub fn version_guessable(project: &Path) -> bool {
221    let Some(output) = git(project, BRANCH_ARGS) else {
222        return false;
223    };
224    if output.lines().any(|l| current_branch(l).is_some()) {
225        return true;
226    }
227    git(project, &["describe", "--exact-match", "--tags"])
228        .is_some_and(|tag| normalize_pretty(tag.trim()).is_ok())
229}
230
231/// `VersionGuesser::guessVersion` (git only) + `postprocess`.
232pub fn guess_version(manifest: &Value, project: &Path) -> Option<GuessedVersion> {
233    let output = git(project, BRANCH_ARGS)?;
234    let mut version: Option<String> = None;
235    let mut pretty: Option<String> = None;
236    let mut commit: Option<String> = None;
237    let mut is_feature = false;
238    let mut is_detached = false;
239    let mut branches: Vec<String> = Vec::new();
240    let is_hex = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_hexdigit());
241
242    for line in output.lines() {
243        if line.is_empty() {
244            continue;
245        }
246        // Current line: `* <name|(no branch)|(HEAD detached at X)> <sha> ...`
247        if line.starts_with("* ") {
248            // A current line the regex does not take (`(HEAD detached from
249            // X)`) leaves the version unknown (tag lookup, then the
250            // caller's default), and is no candidate either.
251            let Some((name, sha)) = current_branch(line) else {
252                continue;
253            };
254            if name.starts_with('(') {
255                version = Some(format!("dev-{sha}"));
256                pretty = version.clone();
257                is_feature = true;
258                is_detached = true;
259            } else {
260                version = Some(normalize_branch(name));
261                pretty = Some(format!("dev-{name}"));
262                is_feature = is_feature_branch(manifest, name);
263            }
264            commit = Some(sha.to_owned());
265        }
266        // Candidates: `[* ] <name|remotes/origin/name> <sha>` (name without `/`), excluding `*/HEAD`.
267        let trimmed = line.trim_start_matches("* ").trim_start();
268        let mut parts = trimmed.split_whitespace();
269        let (Some(name), Some(sha)) = (parts.next(), parts.next()) else {
270            continue;
271        };
272        if name.ends_with("/HEAD") || !is_hex(sha) {
273            continue;
274        }
275        let bare = name
276            .strip_prefix("remotes/origin/")
277            .or_else(|| name.strip_prefix("remotes/upstream/"))
278            .unwrap_or(name);
279        if bare.contains('/') {
280            continue;
281        }
282        branches.push(name.to_owned());
283    }
284
285    let mut feature: Option<(String, String)> = None;
286    if is_feature {
287        if let (Some(v), Some(p)) = (&version, &pretty) {
288            feature = Some((v.clone(), p.clone()));
289            let (nv, np) = guess_feature_version(manifest, v, &branches, project);
290            version = Some(nv);
291            pretty = Some(np);
292        }
293    }
294    if version.is_none() || is_detached {
295        if let Some(tag) = git(project, &["describe", "--exact-match", "--tags"]) {
296            let tag = tag.trim();
297            if let Ok(norm) = normalize_pretty(tag) {
298                version = Some(norm);
299                pretty = Some(tag.to_owned());
300                feature = None;
301            }
302        }
303    }
304    if commit.is_none() {
305        if let Some(out) = git(project, &["rev-list", "--format=%H", "-n1", "HEAD"]) {
306            commit = out
307                .lines()
308                .find(|l| !l.starts_with("commit "))
309                .map(|l| l.trim().to_owned())
310                .filter(|s| !s.is_empty());
311        }
312    }
313    let version = version?;
314    let pretty = pretty?;
315    // postprocess: a feature branch equal to its guess is dropped;
316    // `X.9999999...-dev` displays as `X.x-dev`.
317    let feature = feature.filter(|(fv, fp)| !(fv == &version && fp == &pretty));
318    let collapse = |version: &str, pretty: String| {
319        if version.ends_with("-dev") && version.contains(".9999999") {
320            collapse_nines(version)
321        } else {
322            pretty
323        }
324    };
325    let pretty = collapse(&version, pretty);
326    let (feature_version, feature_pretty_version) = match feature {
327        Some((fv, fp)) => {
328            let fp = collapse(&fv, fp);
329            (Some(fv), Some(fp))
330        }
331        None => (None, None),
332    };
333    Some(GuessedVersion {
334        version,
335        pretty_version: pretty,
336        commit,
337        feature_version,
338        feature_pretty_version,
339    })
340}
341
342/// `guessGitVersion` for the root package.
343fn guess_git(manifest: &Value, project: &Path) -> Option<RootVersion> {
344    let g = guess_version(manifest, project)?;
345    Some(RootVersion {
346        pretty_version: g.pretty_version,
347        version: g.version,
348        reference: g.commit,
349    })
350}
351
352fn collapse_nines(version: &str) -> String {
353    let mut out = version.replace(".9999999", "\u{0}");
354    while out.contains("\u{0}\u{0}") {
355        out = out.replace("\u{0}\u{0}", "\u{0}");
356    }
357    out.replace('\u{0}', ".x")
358}
359
360/// `guessFeatureVersion` with `git rev-list %candidate%..%branch%`: the
361/// non-feature parent branch with the shortest delta wins.
362fn guess_feature_version(
363    manifest: &Value,
364    version: &str,
365    branches: &[String],
366    project: &Path,
367) -> (String, String) {
368    let has_alias = manifest
369        .get("extra")
370        .and_then(|e| e.get("branch-alias"))
371        .and_then(|b| b.get(version))
372        .is_some();
373    let has_self_version = manifest.to_string().contains("\"self.version\"");
374    if has_alias && !has_self_version {
375        return (version.to_owned(), version.to_owned());
376    }
377    let branch = version.strip_prefix("dev-").unwrap_or(version).to_owned();
378    if !is_feature_branch(manifest, &branch) {
379        return (version.to_owned(), version.to_owned());
380    }
381    let mut sorted: Vec<String> = branches.to_vec();
382    sorted.sort_by(|a, b| {
383        let (ar, br) = (a.starts_with("remotes/"), b.starts_with("remotes/"));
384        if ar != br {
385            return if ar {
386                std::cmp::Ordering::Greater
387            } else {
388                std::cmp::Ordering::Less
389            };
390        }
391        strnatcasecmp(b, a)
392    });
393    let mut best_len = usize::MAX;
394    let mut result = (version.to_owned(), version.to_owned());
395    for candidate in &sorted {
396        let candidate_version = candidate
397            .strip_prefix("remotes/")
398            .and_then(|r| r.split_once('/').map(|x| x.1))
399            .unwrap_or(candidate);
400        if candidate == &branch || is_feature_branch(manifest, candidate_version) {
401            continue;
402        }
403        let Some(out) = git(project, &["rev-list", &format!("{candidate}..{branch}")]) else {
404            continue;
405        };
406        // At equal length, a candidate later in the order replaces the previous one.
407        if out.len() <= best_len {
408            best_len = out.len();
409            result = (
410                normalize_branch(candidate_version),
411                format!("dev-{candidate_version}"),
412            );
413            if best_len == 0 {
414                break;
415            }
416        }
417    }
418    result
419}
420
421/// Minimal strnatcasecmp (same rules as vivacity-autoload::natsort, duplicated
422/// to avoid a cross dependency); enough to sort branch names.
423fn strnatcasecmp(a: &str, b: &str) -> std::cmp::Ordering {
424    let (a, b) = (a.to_ascii_lowercase(), b.to_ascii_lowercase());
425    let (ab, bb) = (a.as_bytes(), b.as_bytes());
426    let (mut i, mut j) = (0, 0);
427    while i < ab.len() && j < bb.len() {
428        if ab[i].is_ascii_digit() && bb[j].is_ascii_digit() {
429            let si = i;
430            while i < ab.len() && ab[i].is_ascii_digit() {
431                i += 1;
432            }
433            let sj = j;
434            while j < bb.len() && bb[j].is_ascii_digit() {
435                j += 1;
436            }
437            let na: u128 = a[si..i].parse().unwrap_or(0);
438            let nb: u128 = b[sj..j].parse().unwrap_or(0);
439            if na != nb {
440                return na.cmp(&nb);
441            }
442        } else {
443            if ab[i] != bb[j] {
444                return ab[i].cmp(&bb[j]);
445            }
446            i += 1;
447            j += 1;
448        }
449    }
450    (ab.len() - i).cmp(&(bb.len() - j))
451}
452
453/// `VersionGuesser::guessVersion` after git (VersionGuesser.php:84-104):
454/// `hg branch`, then `fossil branch list` / `fossil tag list`, then `svn info
455/// --xml`, each run in `dir` — a zero exit gives Composer a version (always
456/// for hg; for fossil and svn, nearly always). vivacity reads none of them:
457/// `Some(what)` when one of them would answer, so that the caller hands the
458/// command to Composer; `None` otherwise — and then without running anything
459/// unless a working copy is found walking up from `dir` (`.hg`, `_FOSSIL_` /
460/// `.fslckout`, `.svn`), where each tool looks for it. A missing binary
461/// fails as it does for Composer, which then defaults like vivacity.
462/// Called only once git has given no version.
463pub fn foreign_vcs_version(dir: &Path) -> Option<&'static str> {
464    let mut hg = false;
465    let mut fossil = false;
466    let mut svn = false;
467    for d in dir.ancestors() {
468        hg |= d.join(".hg").is_dir();
469        fossil |= d.join("_FOSSIL_").is_file() || d.join(".fslckout").is_file();
470        svn |= d.join(".svn").is_dir();
471    }
472    let answers = |program: &str, args: &[&str]| {
473        Command::new(program)
474            .args(args)
475            .current_dir(dir)
476            .stdin(std::process::Stdio::null())
477            .stdout(std::process::Stdio::null())
478            .stderr(std::process::Stdio::null())
479            .status()
480            .is_ok_and(|s| s.success())
481    };
482    if hg && answers("hg", &["branch"]) {
483        return Some("Mercurial (`hg branch`)");
484    }
485    if fossil && (answers("fossil", &["branch", "list"]) || answers("fossil", &["tag", "list"])) {
486        return Some("Fossil (`fossil branch list`)");
487    }
488    if svn && answers("svn", &["info", "--xml"]) {
489        return Some("Subversion (`svn info --xml`)");
490    }
491    None
492}
493
494/// `RootPackageLoader::load` guesses the root's version — no `version` in
495/// composer.json, no `COMPOSER_ROOT_VERSION` — and git gives none, but
496/// hg, fossil or svn would ([`foreign_vcs_version`]): the reason to hand the
497/// command to Composer. Free when no working copy of those is found above
498/// the project: git runs only when one is.
499pub fn root_version_from_foreign_vcs(manifest: &Value, project: &Path) -> Option<&'static str> {
500    if manifest.get("version").is_some_and(|v| !v.is_null()) || root_version_from_env().is_some() {
501        return None;
502    }
503    if !foreign_vcs_marked(project) || version_guessable(project) {
504        return None;
505    }
506    foreign_vcs_version(project)
507}
508
509/// A working copy of hg, fossil or svn above `dir` (`stat`s only): the
510/// condition for [`foreign_vcs_version`] to run anything.
511pub fn foreign_vcs_marked(dir: &Path) -> bool {
512    dir.ancestors().any(|d| {
513        d.join(".hg").is_dir()
514            || d.join(".svn").is_dir()
515            || d.join("_FOSSIL_").is_file()
516            || d.join(".fslckout").is_file()
517    })
518}
519
520/// `VersionGuesser::getRootVersionFromEnv`: `COMPOSER_ROOT_VERSION` when
521/// set and non-empty, `1.2-dev` spelled `1.2.x-dev`.
522pub fn root_version_from_env() -> Option<String> {
523    let env = std::env::var("COMPOSER_ROOT_VERSION").ok()?;
524    // `if (Platform::getEnv(...))`: `0` is as falsy as an empty string.
525    if env.is_empty() || env == "0" {
526        return None;
527    }
528    let lower = env.to_ascii_lowercase();
529    Some(match lower.strip_suffix("-dev") {
530        Some(num)
531            if !num.is_empty()
532                && num
533                    .split('.')
534                    .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) =>
535        {
536            format!("{}.x-dev", &env[..num.len()])
537        }
538        _ => env.clone(),
539    })
540}
541
542/// Determines the root version like RootPackageLoader.
543pub fn detect(manifest: &Value, project: &Path) -> RootVersion {
544    if let Some(v) = manifest.get("version").and_then(Value::as_str) {
545        return RootVersion {
546            pretty_version: v.to_owned(),
547            version: normalize_or_raw(v),
548            reference: None,
549        };
550    }
551    if let Some(v) = root_version_from_env() {
552        return RootVersion {
553            pretty_version: v.clone(),
554            version: normalize_or_raw(&v),
555            reference: None,
556        };
557    }
558    if let Some(g) = guess_git(manifest, project) {
559        return g;
560    }
561    RootVersion {
562        pretty_version: DEFAULT_PRETTY_VERSION.to_owned(),
563        version: "1.0.0.0".to_owned(),
564        reference: None,
565    }
566}
567
568/// `VersionParser::DEFAULT_BRANCH_ALIAS`.
569pub const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev";
570
571/// `VersionParser::parseNumericAliasPrefix` (composer/semver): `1.2.x-dev` and
572/// `1.2-dev` -> `1.2.`, else None. Case-insensitive like the PCRE pattern.
573pub fn parse_numeric_alias_prefix(branch: &str) -> Option<String> {
574    let n = branch.len();
575    if n < 4 || !branch.is_char_boundary(n - 4) || !branch[n - 4..].eq_ignore_ascii_case("-dev") {
576        return None;
577    }
578    let mut rest = &branch[..n - 4];
579    if let Some(r) = rest.strip_suffix(".x").or_else(|| rest.strip_suffix(".X")) {
580        rest = r;
581    }
582    let numeric = !rest.is_empty()
583        && rest
584            .split('.')
585            .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
586    numeric.then(|| format!("{rest}."))
587}
588
589/// Pretty version of a normalised alias, like ArrayLoader:
590/// `preg_replace('{(\.9{7})+}', '.x', ...)`.
591fn pretty_alias(normalized: &str) -> String {
592    const X: &str = ".9999999";
593    let mut out = String::with_capacity(normalized.len());
594    let mut rest = normalized;
595    while let Some(i) = rest.find(X) {
596        out.push_str(&rest[..i]);
597        out.push_str(".x");
598        rest = &rest[i + X.len()..];
599        while let Some(r) = rest.strip_prefix(X) {
600            rest = r;
601        }
602    }
603    out.push_str(rest);
604    out
605}
606
607/// `ArrayLoader::getBranchAlias` (Composer 2.10.3): the alias Composer
608/// attaches to a package (root or locked) whose version is a branch (`dev-*`
609/// or `*-dev`). `extra.branch-alias` takes priority (`-dev` target,
610/// normalised by normalizeBranch, source equal to the version ignoring case,
611/// compatible numeric prefix), else `9999999-dev` if `default-branch` is
612/// true and the version has no numeric prefix.
613/// Returns (normalised alias, pretty alias); the pretty one is installed.php's.
614pub fn branch_alias_of(
615    version: &str,
616    extra: Option<&Value>,
617    default_branch: bool,
618) -> Option<(String, String)> {
619    if !(version.starts_with("dev-") || version.ends_with("-dev")) {
620        return None;
621    }
622    if let Some(map) = extra
623        .and_then(|e| e.get("branch-alias"))
624        .and_then(Value::as_object)
625    {
626        for (source, target) in map {
627            let Some(target) = target.as_str() else {
628                continue;
629            };
630            let Some(target_base) = target.strip_suffix("-dev") else {
631                continue;
632            };
633            let validated = if target == DEFAULT_BRANCH_ALIAS {
634                target.to_owned()
635            } else {
636                normalize_branch(target_base)
637            };
638            if !validated.ends_with("-dev") {
639                continue;
640            }
641            if version.to_lowercase() != source.to_lowercase() {
642                continue;
643            }
644            if let (Some(sp), Some(tp)) = (
645                parse_numeric_alias_prefix(source),
646                parse_numeric_alias_prefix(target),
647            ) {
648                if !tp.to_lowercase().starts_with(&sp.to_lowercase()) {
649                    continue;
650                }
651            }
652            let pretty = pretty_alias(&validated);
653            return Some((validated, pretty));
654        }
655    }
656    if default_branch {
657        let v = version.strip_prefix('v').unwrap_or(version);
658        if parse_numeric_alias_prefix(v).is_none() {
659            return Some((
660                DEFAULT_BRANCH_ALIAS.to_owned(),
661                DEFAULT_BRANCH_ALIAS.to_owned(),
662            ));
663        }
664    }
665    None
666}
667
668/// Branch alias of the root: getBranchAlias on the composer.json, the version
669/// being the pretty version retained by RootPackageLoader.
670pub fn branch_alias(manifest: &Value, root: &RootVersion) -> Option<(String, String)> {
671    let default_branch = manifest
672        .get("default-branch")
673        .and_then(Value::as_bool)
674        .unwrap_or(false);
675    branch_alias_of(&root.pretty_version, manifest.get("extra"), default_branch)
676}
677
678impl std::fmt::Display for UnsupportedVersionAlias {
679    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
680        write!(f, "{}", self.0)
681    }
682}
683#[derive(Debug)]
684pub struct UnsupportedVersionAlias(pub UnsupportedVersion);
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use serde_json::json;
690
691    #[test]
692    fn normalize_branch_matches_semver() {
693        assert_eq!(normalize_branch("main"), "dev-main");
694        assert_eq!(normalize_branch("2.2"), "2.2.9999999.9999999-dev");
695        assert_eq!(normalize_branch("1.x"), "1.9999999.9999999.9999999-dev");
696        assert_eq!(normalize_branch("v3"), "3.9999999.9999999.9999999-dev");
697        assert_eq!(normalize_branch("feature/x"), "dev-feature/x");
698    }
699
700    #[test]
701    fn feature_branches() {
702        let m = json!({});
703        assert!(!is_feature_branch(&m, "main"));
704        assert!(!is_feature_branch(&m, "develop"));
705        assert!(!is_feature_branch(&m, "2.2"));
706        assert!(is_feature_branch(&m, "feature-x"));
707        let m = json!({"non-feature-branches": ["release-.*"]});
708        assert!(!is_feature_branch(&m, "release-1"));
709        assert!(is_feature_branch(&m, "feature-1"));
710    }
711
712    #[test]
713    fn collapse() {
714        assert_eq!(collapse_nines("2.2.9999999.9999999-dev"), "2.2.x-dev");
715        assert_eq!(collapse_nines("1.9999999.9999999.9999999-dev"), "1.x-dev");
716    }
717
718    #[test]
719    fn detect_in_git_repo() {
720        let tmp = tempfile::tempdir().expect("tmp");
721        let p = tmp.path();
722        let run = |args: &[&str]| {
723            let st = Command::new("git")
724                .args(args)
725                .current_dir(p)
726                .env("GIT_AUTHOR_NAME", "t")
727                .env("GIT_AUTHOR_EMAIL", "t@t")
728                .env("GIT_COMMITTER_NAME", "t")
729                .env("GIT_COMMITTER_EMAIL", "t@t")
730                .status()
731                .expect("git");
732            assert!(st.success(), "git {args:?}");
733        };
734        run(&["init", "-q", "-b", "main"]);
735        std::fs::write(p.join("a.txt"), "a").expect("write");
736        run(&["add", "."]);
737        run(&["commit", "-q", "-m", "init"]);
738        let r = detect(&json!({}), p);
739        assert_eq!(r.pretty_version, "dev-main");
740        assert_eq!(r.version, "dev-main");
741        assert_eq!(r.reference.as_deref().map(str::len), Some(40));
742
743        // Feature branch: the parent (main) is retained.
744        run(&["checkout", "-q", "-b", "feature-x"]);
745        std::fs::write(p.join("b.txt"), "b").expect("write");
746        run(&["add", "."]);
747        run(&["commit", "-q", "-m", "feat"]);
748        let r = detect(&json!({}), p);
749        assert_eq!(r.pretty_version, "dev-main");
750        let g = guess_version(&json!({}), p).expect("guess");
751        assert_eq!(g.feature_pretty_version.as_deref(), Some("dev-feature-x"));
752        assert_eq!(g.feature_version.as_deref(), Some("dev-feature-x"));
753        // A sub-directory without a repository of its own: git walks up.
754        std::fs::create_dir_all(p.join("packages/x")).expect("mkdir");
755        let g = guess_version(&json!({}), &p.join("packages/x")).expect("guess");
756        assert_eq!(g.pretty_version, "dev-main");
757        assert_eq!(g.commit.as_deref().map(str::len), Some(40));
758
759        // Numeric branch + alias.
760        run(&["checkout", "-q", "-b", "2.2"]);
761        let m = json!({"extra": {"branch-alias": {"dev-2.2": "2.2.x-dev"}}});
762        let r = detect(&m, p);
763        assert_eq!(r.version, "2.2.9999999.9999999-dev");
764        assert_eq!(r.pretty_version, "2.2.x-dev");
765        assert_eq!(
766            branch_alias(&m, &r),
767            None,
768            "the alias is indexed by dev-2.2, not by the pretty version x-dev"
769        );
770
771        // Exact tag on detached HEAD.
772        run(&["tag", "v1.2.3"]);
773        run(&["checkout", "-q", "--detach", "HEAD"]);
774        let r = detect(&json!({}), p);
775        assert_eq!(r.pretty_version, "v1.2.3");
776        assert_eq!(r.version, "1.2.3.0");
777
778        // A commit on the detached HEAD: `* (HEAD detached from v1.2.3)`
779        // matches none of the forms Composer's regex knows, no tag matches
780        // either: no version (the callers fall back to their default).
781        std::fs::write(p.join("c.txt"), "c").expect("write");
782        run(&["add", "."]);
783        run(&["commit", "-q", "-m", "detached"]);
784        assert!(guess_version(&json!({}), p).is_none());
785        assert_eq!(detect(&json!({}), p).pretty_version, DEFAULT_PRETTY_VERSION);
786    }
787}