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 (fallback to the default, as without a VCS).
8
9use 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    /// Normalised version (Composer's `version_normalized`).
18    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
28/// `VersionParser::normalizeBranch` (composer/semver): `1.2` -> numeric
29/// `1.2.x.x-dev` (x -> 9999999), else `dev-<name>`.
30pub 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
63/// `VersionGuesser::isFeatureBranch`.
64fn 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    // `\d+\..+`: numeric branch of the 1.x / 2.2 kind
87    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        // GitUtil::cleanEnv: English output, never an interactive prompt.
104        .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
115/// `guessGitVersion` + `postprocess`.
116fn 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        // Current line: `* <name|(no branch)|(HEAD detached at X)> <sha> ...`
137        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        // Candidates: `[* ] <name|remotes/origin/name> <sha>` (name without `/`), excluding `*/HEAD`.
170        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    // postprocess: `X.9999999...-dev` displays as `X.x-dev`.
215    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
235/// `guessFeatureVersion` with `git rev-list %candidate%..%branch%`: the
236/// non-feature parent branch with the shortest delta wins.
237fn 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        // At equal length, a candidate later in the order replaces the previous one.
282        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
296/// Minimal strnatcasecmp (same rules as vivacity-autoload::natsort, duplicated
297/// to avoid a cross dependency); enough to sort branch names.
298fn 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
328/// Determines the root version like RootPackageLoader.
329pub 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            // `1.2-dev` -> `1.2.x-dev`
340            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
368/// `VersionParser::DEFAULT_BRANCH_ALIAS`.
369pub const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev";
370
371/// `VersionParser::parseNumericAliasPrefix` (composer/semver): `1.2.x-dev` and
372/// `1.2-dev` -> `1.2.`, else None. Case-insensitive like the PCRE pattern.
373pub 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
389/// Pretty version of a normalised alias, like ArrayLoader:
390/// `preg_replace('{(\.9{7})+}', '.x', ...)`.
391fn 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
407/// `ArrayLoader::getBranchAlias` (Composer 2.10.3): the alias Composer
408/// attaches to a package (root or locked) whose version is a branch (`dev-*`
409/// or `*-dev`). `extra.branch-alias` takes priority (`-dev` target,
410/// normalised by normalizeBranch, source equal to the version ignoring case,
411/// compatible numeric prefix), else `9999999-dev` if `default-branch` is
412/// true and the version has no numeric prefix.
413/// Returns (normalised alias, pretty alias); the pretty one is installed.php's.
414pub 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
468/// Branch alias of the root: getBranchAlias on the composer.json, the version
469/// being the pretty version retained by RootPackageLoader.
470pub 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")); // the value is used as a regex in Composer: literal here
509    }
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        // Feature branch: the parent (main) is retained.
543        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        // Numeric branch + alias.
551        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        // Exact tag on detached HEAD.
563        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}