Skip to main content

vivacity_resolver/
root.rs

1//! The root package as `RootPackageLoader::load` builds it
2//! (docs/reference/RootPackageLoader.php): version (composer.json,
3//! COMPOSER_ROOT_VERSION, git, otherwise `1.0.0+no-version-set`), links,
4//! `minimum-stability`, `prefer-stable`, and the three extractions from the
5//! require constraints: aliases (`X as Y`), stability flags (`@dev`,
6//! branches), references (`#sha`).
7
8use crate::constraint::parse_constraints;
9use crate::loader;
10use crate::package::{Links, Origin, Package};
11use crate::version::{
12    self, group, normalize, regex, stability_rank, VersionError, STABILITIES_REGEX,
13};
14use pcre2::bytes::Regex;
15use serde_json::{Map, Value};
16use std::collections::BTreeMap;
17use std::path::Path;
18use std::sync::OnceLock;
19
20pub const DEFAULT_PRETTY_VERSION: &str = "1.0.0+no-version-set";
21
22/// `RootPackage::getAliases()`: an alias declared in a require.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct RootAlias {
25    pub package: String,
26    pub version: String,
27    pub alias: String,
28    pub alias_normalized: String,
29}
30
31#[derive(Debug, Clone)]
32pub struct RootPackage {
33    pub package: Package,
34    /// `RootAliasPackage`: (normalized alias, pretty alias) if `extra.branch-alias` applies.
35    pub branch_alias: Option<(String, String)>,
36    pub minimum_stability: String,
37    pub prefer_stable: bool,
38    /// name -> stability rank (`BasePackage::STABILITIES`).
39    pub stability_flags: BTreeMap<String, i32>,
40    pub aliases: Vec<RootAlias>,
41    /// name -> git reference.
42    pub references: BTreeMap<String, String>,
43    /// `config.platform`.
44    pub platform_overrides: Map<String, Value>,
45    pub manifest: Value,
46}
47
48#[derive(Debug, thiserror::Error)]
49#[error("{0}")]
50pub struct RootError(pub String);
51
52/// What a dry run changes on the root package in memory instead of in
53/// composer.json.
54#[derive(Debug, Clone, Default)]
55pub struct RootPatch {
56    /// `(name, constraint)` to add (`require` or, with `dev`, `require-dev`).
57    pub requirements: Vec<(String, String)>,
58    pub dev: bool,
59    /// `(from require-dev?, name as typed)` to remove.
60    pub removals: Vec<(bool, String)>,
61}
62
63impl RootPackage {
64    /// Loads composer.json (already parsed) with the root version guessed by
65    /// vivacity-core (same rules as RootPackageLoader + VersionGuesser).
66    pub fn load(manifest: &Value, project_dir: &Path) -> Result<RootPackage, RootError> {
67        Self::load_with_version(
68            manifest,
69            vivacity_core::root_version::detect(manifest, project_dir),
70        )
71    }
72
73    /// `load` with the root version already detected: `install` detects it
74    /// for its missing requirements and hands it to the lock verification,
75    /// which then does not run git again. Used only when composer.json has
76    /// no `version` of its own, as in `load`.
77    pub fn load_with_version(
78        manifest: &Value,
79        rv: vivacity_core::root_version::RootVersion,
80    ) -> Result<RootPackage, RootError> {
81        let mut config = manifest
82            .as_object()
83            .cloned()
84            .ok_or_else(|| RootError("composer.json is not an object".into()))?;
85        if !config.contains_key("name") {
86            config.insert("name".into(), Value::String("__root__".into()));
87        }
88        let mut auto_versioned = false;
89        if !config.contains_key("version") {
90            if rv.pretty_version == vivacity_core::root_version::DEFAULT_PRETTY_VERSION {
91                config.insert("version".into(), Value::String("1.0.0".into()));
92                auto_versioned = true;
93            } else {
94                config.insert("version".into(), Value::String(rv.pretty_version.clone()));
95                config.insert(
96                    "version_normalized".into(),
97                    Value::String(rv.version.clone()),
98                );
99                if let Some(commit) = rv.reference {
100                    let r = serde_json::json!({"type": "", "url": "", "reference": commit});
101                    config.insert("source".into(), r.clone());
102                    config.insert("dist".into(), r);
103                }
104            }
105        }
106        let value = Value::Object(config.clone());
107        let (mut package, alias) =
108            loader::load(&value, Origin::Root, false).map_err(|e| RootError(e.0))?;
109        if auto_versioned {
110            package.pretty_version = DEFAULT_PRETTY_VERSION.to_owned();
111        }
112        let minimum_stability = match config.get("minimum-stability").and_then(Value::as_str) {
113            Some(s) => normalize_stability(s)?,
114            None => "stable".to_owned(),
115        };
116        let mut aliases = Vec::new();
117        let mut stability_flags = BTreeMap::new();
118        let mut references = BTreeMap::new();
119        for links in [&package.requires, &package.dev_requires] {
120            let map: Vec<(String, String)> = links
121                .iter()
122                // `$link->getConstraint()->getPrettyString()`: for
123                // `self.version`, the root version.
124                .map(|l| {
125                    let pretty = if l.pretty_constraint == "self.version" {
126                        // Pretty version at parseLinks time (before
127                        // `setPrettyVersion('1.0.0+no-version-set')`).
128                        if auto_versioned {
129                            "1.0.0".to_owned()
130                        } else {
131                            package.pretty_version.clone()
132                        }
133                    } else {
134                        l.pretty_constraint.clone()
135                    };
136                    (l.target.clone(), pretty)
137                })
138                .collect();
139            extract_aliases(&map, &mut aliases)?;
140            extract_stability_flags(&map, &minimum_stability, &mut stability_flags);
141            extract_references(&map, &mut references);
142            if map.iter().any(|(n, _)| *n == package.name) {
143                return Err(RootError(format!(
144                    "Root package '{}' cannot require itself in its composer.json\nDid you accidentally name your root package after an external package?",
145                    package.pretty_name
146                )));
147            }
148        }
149        let prefer_stable = config
150            .get("prefer-stable")
151            .map(|v| match v {
152                Value::Bool(b) => *b,
153                Value::Number(n) => n.as_f64() != Some(0.0),
154                Value::String(s) => !(s.is_empty() || s == "0"),
155                Value::Null => false,
156                _ => true,
157            })
158            .unwrap_or(false);
159        let platform_overrides = config
160            .get("config")
161            .and_then(|c| c.get("platform"))
162            .and_then(Value::as_object)
163            .cloned()
164            .unwrap_or_default();
165        Ok(RootPackage {
166            package,
167            branch_alias: alias,
168            minimum_stability,
169            prefer_stable,
170            stability_flags,
171            aliases,
172            references,
173            platform_overrides,
174            manifest: manifest.clone(),
175        })
176    }
177
178    /// `require` + `require-dev` links (`array_merge`: dev-requires
179    /// overwrite a duplicate target, at its position).
180    /// The in-memory root patch of a dry run (`RequireCommand`,
181    /// `RemoveCommand`): the new links are `array_merge`d onto the key's
182    /// links (a duplicate key keeps its position), removed from the other
183    /// key by their name, and the references / stability flags are
184    /// extended; aliases are not re-extracted (the reference does not).
185    pub fn apply_patch(&mut self, patch: &RootPatch) -> Result<(), RootError> {
186        if !patch.requirements.is_empty() {
187            let map: Map<String, Value> = patch
188                .requirements
189                .iter()
190                .map(|(n, c)| (n.clone(), Value::String(c.clone())))
191                .collect();
192            let kind = if patch.dev {
193                crate::package::LinkType::DevRequire
194            } else {
195                crate::package::LinkType::Require
196            };
197            let new_links = loader::parse_links(
198                &self.package.name,
199                &self.package.pretty_version,
200                kind,
201                Some(&Value::Object(map)),
202                false,
203            )
204            .map_err(|e| RootError(e.0))?;
205            let (own_links, other_links) = if kind == crate::package::LinkType::DevRequire {
206                (&mut self.package.dev_requires, &mut self.package.requires)
207            } else {
208                (&mut self.package.requires, &mut self.package.dev_requires)
209            };
210            for l in new_links.iter() {
211                own_links.insert(l.clone());
212            }
213            for (name, _) in &patch.requirements {
214                other_links.remove(name);
215            }
216            let pairs: Vec<(String, String)> = patch.requirements.clone();
217            extract_references(&pairs, &mut self.references);
218            extract_stability_flags(&pairs, &self.minimum_stability, &mut self.stability_flags);
219        }
220        // `RemoveCommand`: `unset($links[$type][$name])` with the name as
221        // typed against lowercase keys.
222        for (dev, name) in &patch.removals {
223            if *dev {
224                self.package.dev_requires.remove(name);
225            } else {
226                self.package.requires.remove(name);
227            }
228        }
229        Ok(())
230    }
231
232    pub fn all_requires(&self) -> Links {
233        let mut out = self.package.requires.clone();
234        for l in self.package.dev_requires.iter() {
235            out.insert(l.clone());
236        }
237        out
238    }
239}
240
241/// `VersionParser::normalizeStability`.
242/// The refusals `RootPackageLoader::load` raises from the manifest alone, in
243/// its order: the root package's own name, then — after the links are parsed,
244/// which changes nothing for a raw manifest — a package that requires itself,
245/// then the name of every link of the five `BasePackage::$supportedLinkTypes`.
246///
247/// Composer refuses these for EVERY command, because `Factory::createComposer`
248/// loads the root package; vivacity only built a `RootPackage` for a
249/// resolution, so `install` and `dump-autoload` used to accept them. They call
250/// this directly — the checks that need a parsed manifest (a malformed alias,
251/// an unknown `minimum-stability`, an unparsable constraint) stay inside
252/// `load`, since reaching them means guessing the root version, i.e. running
253/// git, on the install hot path.
254///
255/// The message is returned as Composer words it, prefixes included. vivacity
256/// prints it without Symfony Console's box, as it already does for path
257/// repositories: the box is wrapped to the terminal width, followed by the
258/// command synopsis, and sometimes chained.
259///
260/// These rules are reported even when Composer would have answered with
261/// ANOTHER message — its JSON schema runs first and catches an uppercase or
262/// malformed `name`, and `parent::load` catches an unparsable constraint before
263/// the link-name loop. Reporting ours then differs in words; staying silent
264/// would differ in OUTCOME, letting `dump-autoload` write an autoloader where
265/// Composer writes nothing. Words are the cheaper divergence, and the project
266/// already takes one for the box.
267pub fn manifest_error(manifest: &Value) -> Option<String> {
268    use crate::lockfile::package_naming_error;
269    let config = manifest.as_object()?;
270    // `if (!isset($config['name'])) { '__root__' } elseif ($err = …)`.
271    //
272    // The schema runs first, and it holds the same shape pattern for `name`
273    // but CASE SENSITIVELY (`^[a-z0-9]([_.-]?[a-z0-9]+)*/…$`, measured in
274    // `res/composer-schema.json`). So a root name with an uppercase letter or a
275    // bad shape is refused with the schema's message, which vivacity does not
276    // reproduce — only what the schema lets through is ours to report, i.e. the
277    // `.json` suffix and the reserved names.
278    let name = config.get("name").and_then(Value::as_str);
279    if let Some(name) = name {
280        if let Some(err) = package_naming_error(name, false) {
281            return Some(format!("Your package name {err}"));
282        }
283    }
284    // `if (isset($links[$config['name']]))`, for require then require-dev. The
285    // targets are lowercased by `parseLinks`; the name passed the check above,
286    // so it is already lowercase ASCII here.
287    if let Some(name) = name {
288        for link_type in ["require", "require-dev"] {
289            let Some(links) = config.get(link_type).and_then(Value::as_object) else {
290                continue;
291            };
292            if links.keys().any(|k| k.to_ascii_lowercase() == name) {
293                return Some(format!(
294                    "Root package '{name}' cannot require itself in its composer.json\nDid you accidentally name your root package after an external package?"
295                ));
296            }
297        }
298    }
299    // `foreach (array_keys(BasePackage::$supportedLinkTypes) as $linkType)`,
300    // in that order, first error wins.
301    for link_type in LINK_TYPES {
302        let Some(links) = config.get(*link_type).and_then(Value::as_object) else {
303            continue;
304        };
305        for target in links.keys() {
306            if let Some(err) = package_naming_error(target, true) {
307                return Some(format!("{link_type}.{err}"));
308            }
309        }
310    }
311    None
312}
313
314/// `array_keys(BasePackage::$supportedLinkTypes)`, in its order.
315const LINK_TYPES: &[&str] = &["require", "conflict", "provide", "replace", "require-dev"];
316
317/// The warning `RootPackageLoader::load` writes when it gives the root
318/// package the default version (`RootPackageLoader.php:91-117`): no
319/// `version` key in composer.json (a `null` one is refused by Composer's
320/// schema before `load`, and by `load_with_version` here), no
321/// `COMPOSER_ROOT_VERSION` (PHP-truthy), and nothing `guessVersion` can
322/// read from git — unless the root has no `name` (`__root__`) or is of type
323/// `project`. A `name` with a naming error throws before the version is
324/// looked at (`:85-89`): no warning then. Composer writes it through
325/// `IOInterface::warning`, a `NORMAL` line on stderr, each time it creates
326/// the command's `Composer` instance.
327///
328/// git runs only once the manifest has ruled nothing out, as in Composer,
329/// and only to know whether a version would be found
330/// (`root_version::version_guessable`: one call when HEAD is on a branch).
331pub fn undetected_version_warning(manifest: &Value, project_dir: &Path) -> Option<String> {
332    let env_version = vivacity_core::root_version::root_version_from_env().is_some();
333    undetected_version_warning_with(manifest, project_dir, env_version)
334}
335
336fn undetected_version_warning_with(
337    manifest: &Value,
338    project_dir: &Path,
339    env_version: bool,
340) -> Option<String> {
341    let config = manifest.as_object()?;
342    let name = config.get("name")?.as_str()?;
343    if crate::lockfile::package_naming_error(name, false).is_some() {
344        return None;
345    }
346    if config.contains_key("version") || env_version {
347        return None;
348    }
349    // `'project' !== ($config['type'] ?? '')`: exact, case included.
350    if config.get("type").and_then(Value::as_str) == Some("project") {
351        return None;
352    }
353    if vivacity_core::root_version::version_guessable(project_dir) {
354        return None;
355    }
356    Some(format!(
357        "Composer could not detect the root package ({name}) version, defaulting to '1.0.0'. See https://getcomposer.org/root-version"
358    ))
359}
360
361pub fn normalize_stability(s: &str) -> Result<String, RootError> {
362    let lower = s.to_lowercase();
363    match lower.as_str() {
364        "stable" | "beta" | "alpha" | "dev" => Ok(lower),
365        "rc" => Ok("RC".to_owned()),
366        _ => Err(RootError(format!(
367            "Invalid stability string \"{s}\", expected one of stable, RC, beta, alpha or dev"
368        ))),
369    }
370}
371
372/// `RootPackageLoader::extractAliases`.
373fn extract_aliases(
374    requires: &[(String, String)],
375    aliases: &mut Vec<RootAlias>,
376) -> Result<(), RootError> {
377    static RE: OnceLock<Regex> = OnceLock::new();
378    let re = regex(
379        &RE,
380        r"(?:^|\| *|, *)([^,\s#|]+)(?:#[^ ]+)? +as +([^,\s|]+)(?:$| *\|| *,)",
381        false,
382    );
383    for (name, req) in requires {
384        if let Ok(Some(caps)) = re.captures(req.as_bytes()) {
385            let v = group(&caps, 1);
386            let a = group(&caps, 2);
387            aliases.push(RootAlias {
388                package: name.to_lowercase(),
389                version: normalize(v, Some(req)).map_err(|e: VersionError| RootError(e.0))?,
390                alias: a.to_owned(),
391                alias_normalized: normalize(a, Some(req))
392                    .map_err(|e: VersionError| RootError(e.0))?,
393            });
394        } else if req.contains(" as ") {
395            return Err(RootError(format!(
396                "Invalid alias definition in \"{name}\": \"{req}\". Aliases should be in the form \"exact-version as other-exact-version\"."
397            )));
398        }
399    }
400    Ok(())
401}
402
403/// `preg_split` of the constraints into "and" pieces across the "or"s.
404fn split_constraints(req: &str) -> Vec<String> {
405    static OR: OnceLock<Regex> = OnceLock::new();
406    static AND: OnceLock<Regex> = OnceLock::new();
407    let or = regex(&OR, r"\s*\|\|?\s*", false);
408    let and = regex(
409        &AND,
410        r"(?<!^|as|[=>< ,]) *(?<!-)[, ](?!-) *(?!,|as|$)",
411        false,
412    );
413    let mut out = Vec::new();
414    for part in split(or, req.trim()) {
415        out.extend(split(and, &part));
416    }
417    out
418}
419
420fn split(re: &Regex, subject: &str) -> Vec<String> {
421    let bytes = subject.as_bytes();
422    let mut out = Vec::new();
423    let mut last = 0;
424    for m in re.find_iter(bytes).flatten() {
425        if m.start() == m.end() && m.start() == last && last == bytes.len() {
426            break;
427        }
428        out.push(subject[last..m.start()].to_owned());
429        last = m.end();
430    }
431    out.push(subject[last..].to_owned());
432    out
433}
434
435/// composer-merge-plugin's `ExtraPackage::mergeStabilityFlags` for one
436/// merged file: `StabilityFlags::extractAll` over the file's own
437/// requirements (an explicit `@flag`, the most unstable across the
438/// constraint's parts, else the parsed stability when it is dev and not
439/// more stable than the minimum), each raised to at least the root's
440/// current flag, then `array_merge`d over the root's flags.
441pub fn merge_plugin_stability_flags(
442    flags: &mut BTreeMap<String, i32>,
443    minimum_stability: &str,
444    requires: &[(String, String)],
445) {
446    static AT: OnceLock<Regex> = OnceLock::new();
447    static AS: OnceLock<Regex> = OnceLock::new();
448    let at = regex(&AT, &format!("^[^@]*?@({STABILITIES_REGEX})$"), true);
449    let as_re = regex(&AS, r"^([^,\s@]+) as .+$", false);
450    let minimum = stability_rank(minimum_stability);
451    for (req_name, pretty) in requires {
452        let name = req_name.to_lowercase();
453        let mut explicit: Option<i32> = None;
454        for c in split_constraints(pretty) {
455            if let Ok(Some(caps)) = at.captures(c.as_bytes()) {
456                let Ok(stab) = normalize_stability(group(&caps, 1)) else {
457                    continue;
458                };
459                let rank = stability_rank(&stab);
460                explicit = Some(explicit.map_or(rank, |e| e.max(rank)));
461            }
462        }
463        let stability = match explicit {
464            Some(e) => Some(e),
465            None => {
466                // Drop aliasing if used.
467                let v = match as_re.captures(pretty.as_bytes()) {
468                    Ok(Some(caps)) => group(&caps, 1).to_owned(),
469                    _ => pretty.clone(),
470                };
471                let rank = stability_rank(version::parse_stability(&v));
472                if rank == stability_rank("stable") || minimum > rank {
473                    None
474                } else {
475                    Some(rank)
476                }
477            }
478        };
479        if let Some(st) = stability {
480            let current = flags.get(&name).copied();
481            flags.insert(name, current.map_or(st, |c| c.max(st)));
482        }
483    }
484}
485
486/// `RootPackageLoader::extractStabilityFlags`.
487pub fn extract_stability_flags(
488    requires: &[(String, String)],
489    minimum_stability: &str,
490    flags: &mut BTreeMap<String, i32>,
491) {
492    static AT: OnceLock<Regex> = OnceLock::new();
493    static AS: OnceLock<Regex> = OnceLock::new();
494    static PLAIN: OnceLock<Regex> = OnceLock::new();
495    let at = regex(&AT, &format!("^[^@]*?@({STABILITIES_REGEX})$"), true);
496    let as_re = regex(&AS, r"^([^,\s@]+) as .+$", false);
497    let plain = regex(&PLAIN, r"^[^,\s@]+$", false);
498    let minimum = stability_rank(minimum_stability);
499    for (req_name, req) in requires {
500        let constraints = split_constraints(req);
501        let mut matched = false;
502        for c in &constraints {
503            if let Ok(Some(caps)) = at.captures(c.as_bytes()) {
504                let name = req_name.to_lowercase();
505                let Ok(stab) = normalize_stability(group(&caps, 1)) else {
506                    continue;
507                };
508                let rank = stability_rank(&stab);
509                if flags.get(&name).is_some_and(|f| *f > rank) {
510                    continue;
511                }
512                flags.insert(name, rank);
513                matched = true;
514            }
515        }
516        if matched {
517            continue;
518        }
519        for c in &constraints {
520            let stripped = match as_re.captures(c.as_bytes()) {
521                Ok(Some(caps)) => group(&caps, 1).to_owned(),
522                _ => c.clone(),
523            };
524            if plain.is_match(stripped.as_bytes()).unwrap_or(false) {
525                let stability = version::parse_stability(&stripped);
526                if stability != "stable" {
527                    let name = req_name.to_lowercase();
528                    let rank = stability_rank(stability);
529                    if flags.get(&name).is_some_and(|f| *f > rank) || minimum > rank {
530                        continue;
531                    }
532                    flags.insert(name, rank);
533                }
534            }
535        }
536    }
537}
538
539/// `RootPackageLoader::extractReferences`.
540pub fn extract_references(
541    requires: &[(String, String)],
542    references: &mut BTreeMap<String, String>,
543) {
544    static AS: OnceLock<Regex> = OnceLock::new();
545    static REF: OnceLock<Regex> = OnceLock::new();
546    let as_re = regex(&AS, r"^([^,\s@]+) as .+$", false);
547    let re = regex(&REF, r"^[^,\s@]+?#([a-f0-9]+)$", false);
548    for (name, req) in requires {
549        let stripped = match as_re.captures(req.as_bytes()) {
550            Ok(Some(caps)) => group(&caps, 1).to_owned(),
551            _ => req.clone(),
552        };
553        if let Ok(Some(caps)) = re.captures(stripped.as_bytes()) {
554            if version::parse_stability(&stripped) == "dev" {
555                references.insert(name.to_lowercase(), group(&caps, 1).to_owned());
556            }
557        }
558    }
559}
560
561/// Constraint of a root require (with `as`: the source part).
562pub fn root_constraint(pretty: &str) -> Result<crate::constraint::Constraint, VersionError> {
563    Ok(parse_constraints(pretty)?.constraint)
564}
565
566#[cfg(test)]
567mod tests {
568    #[test]
569    fn undetected_version_warning_conditions() {
570        use super::undetected_version_warning_with as warning;
571        use serde_json::json;
572        let tmp = tempfile::tempdir().expect("tmp");
573        let p = tmp.path();
574        let git = |args: &[&str]| {
575            std::process::Command::new("git")
576                .args(args)
577                .current_dir(p)
578                .env("GIT_AUTHOR_NAME", "t")
579                .env("GIT_AUTHOR_EMAIL", "t@t")
580                .env("GIT_COMMITTER_NAME", "t")
581                .env("GIT_COMMITTER_EMAIL", "t@t")
582                .env("GIT_CONFIG_GLOBAL", "/dev/null")
583                .env("GIT_CONFIG_NOSYSTEM", "1")
584                .output()
585                .expect("git")
586        };
587        // A temporary directory inside some repository would give a version.
588        if git(&["rev-parse", "--git-dir"]).status.success() {
589            eprintln!("temporary directory inside a git repository: skipped");
590            return;
591        }
592        let lib = json!({"name": "acme/lib"});
593        let line = "Composer could not detect the root package (acme/lib) version, defaulting to '1.0.0'. See https://getcomposer.org/root-version";
594        assert_eq!(warning(&lib, p, false).as_deref(), Some(line));
595        assert_eq!(
596            warning(&json!({"name": "acme/lib", "type": "library"}), p, false).as_deref(),
597            Some(line)
598        );
599        // `version: null`: refused before the version is looked at (the
600        // schema in Composer, `load_with_version` here).
601        assert!(warning(&json!({"name": "acme/lib", "version": null}), p, false).is_none());
602        // Composer's `Project` is not `project` (its schema refuses it first).
603        assert!(warning(&json!({"name": "acme/lib", "type": "Project"}), p, false).is_some());
604        // No line: no name (`__root__`), a name Composer refuses first, a
605        // project, a version, `COMPOSER_ROOT_VERSION`.
606        assert!(warning(&json!({}), p, false).is_none());
607        assert!(warning(&json!({"name": null}), p, false).is_none());
608        assert!(warning(&json!({"name": "con/thing"}), p, false).is_none());
609        assert!(warning(&json!({"name": "acme/lib", "type": "project"}), p, false).is_none());
610        assert!(warning(&json!({"name": "acme/lib", "version": "2.0.0"}), p, false).is_none());
611        assert!(warning(&lib, p, true).is_none());
612        // A repository without a commit: nothing to guess.
613        assert!(git(&["init", "-q", "-b", "main"]).status.success());
614        assert!(warning(&lib, p, false).is_some());
615        // A branch: its version.
616        std::fs::write(p.join("a.txt"), "a").expect("write");
617        assert!(git(&["add", "."]).status.success());
618        assert!(git(&["commit", "-q", "-m", "one"]).status.success());
619        assert!(warning(&lib, p, false).is_none());
620        // `(HEAD detached at X)`: `dev-<sha>`.
621        assert!(git(&["checkout", "-q", "--detach", "HEAD"])
622            .status
623            .success());
624        assert!(warning(&lib, p, false).is_none());
625        // `(HEAD detached from X)` without a tag: nothing to guess.
626        std::fs::write(p.join("b.txt"), "b").expect("write");
627        assert!(git(&["add", "."]).status.success());
628        assert!(git(&["commit", "-q", "-m", "two"]).status.success());
629        assert!(warning(&lib, p, false).is_some());
630        // … with an exact tag: the tag.
631        assert!(git(&["tag", "v1.0.0"]).status.success());
632        assert!(warning(&lib, p, false).is_none());
633    }
634
635    #[test]
636    fn merge_plugin_flags_follow_the_plugin() {
637        use super::merge_plugin_stability_flags as flags_of;
638        use std::collections::BTreeMap;
639        let req = |pairs: &[(&str, &str)]| -> Vec<(String, String)> {
640            pairs
641                .iter()
642                .map(|(n, c)| ((*n).to_owned(), (*c).to_owned()))
643                .collect()
644        };
645        // Explicit flag: the most unstable part of the constraint wins,
646        // whatever the minimum stability; the name is lowercased.
647        let mut f = BTreeMap::new();
648        flags_of(
649            &mut f,
650            "stable",
651            &req(&[("Acme/Lib", "^1.0@beta || ^2.0@RC")]),
652        );
653        assert_eq!(f.get("acme/lib"), Some(&10));
654        // Parsed dev stability counts unless the minimum is more unstable
655        // than it (`$this->minimumStability > $stability`), and never a
656        // stable one; unlike the loader, no "single plain token" check.
657        let mut f = BTreeMap::new();
658        flags_of(
659            &mut f,
660            "stable",
661            &req(&[("a/b", "dev-main || ^1.0"), ("c/d", "^1.0")]),
662        );
663        assert_eq!(f.get("a/b"), Some(&20));
664        assert!(!f.contains_key("c/d"));
665        let mut f = BTreeMap::new();
666        flags_of(&mut f, "dev", &req(&[("a/b", "1.0.0-beta")]));
667        assert!(f.is_empty(), "beta is more stable than the dev minimum");
668        // `max($stability, current)`: an existing flag is never lowered,
669        // and a requirement without a flag leaves it untouched.
670        let mut f: BTreeMap<String, i32> = [("a/b".to_owned(), 20)].into();
671        flags_of(
672            &mut f,
673            "stable",
674            &req(&[("a/b", "^1.0@beta"), ("a/b", "^1.0")]),
675        );
676        assert_eq!(f.get("a/b"), Some(&20));
677        // The alias is dropped before parsing.
678        let mut f = BTreeMap::new();
679        flags_of(&mut f, "stable", &req(&[("a/b", "dev-main as 1.0.0")]));
680        assert_eq!(f.get("a/b"), Some(&20));
681    }
682
683    use super::*;
684    use serde_json::json;
685
686    #[test]
687    fn extracts_flags_aliases_references() {
688        let reqs = vec![
689            ("a/b".to_owned(), "^1.0@beta".to_owned()),
690            ("c/d".to_owned(), "dev-main as 1.0.x-dev".to_owned()),
691            ("e/f".to_owned(), "dev-main#abcdef".to_owned()),
692            ("g/h".to_owned(), "1.x-dev || ^2.0".to_owned()),
693            ("i/j".to_owned(), "^1.0".to_owned()),
694        ];
695        let mut flags = BTreeMap::new();
696        extract_stability_flags(&reqs, "stable", &mut flags);
697        assert_eq!(flags.get("a/b"), Some(&10));
698        assert_eq!(flags.get("c/d"), Some(&20));
699        assert_eq!(flags.get("e/f"), Some(&20));
700        assert_eq!(flags.get("g/h"), Some(&20));
701        assert_eq!(flags.get("i/j"), None);
702        let mut aliases = Vec::new();
703        extract_aliases(&reqs, &mut aliases).unwrap();
704        assert_eq!(aliases.len(), 1);
705        assert_eq!(aliases[0].alias_normalized, "1.0.9999999.9999999-dev");
706        let mut refs = BTreeMap::new();
707        extract_references(&reqs, &mut refs);
708        assert_eq!(refs.get("e/f").map(String::as_str), Some("abcdef"));
709    }
710
711    #[test]
712    fn loads_root_without_git() {
713        let m = json!({"name": "acme/app", "require": {"php": "^8.1", "monolog/monolog": "^3"}, "minimum-stability": "RC", "prefer-stable": true});
714        let r = RootPackage::load(&m, Path::new("/nonexistent-vivacity")).unwrap();
715        assert_eq!(r.package.pretty_version, DEFAULT_PRETTY_VERSION);
716        assert_eq!(r.package.version, "1.0.0.0");
717        assert_eq!(r.minimum_stability, "RC");
718        assert!(r.prefer_stable);
719        assert_eq!(r.package.requires.len(), 2);
720    }
721
722    #[test]
723    fn manifest_error_follows_the_loader() {
724        use serde_json::json;
725        // A real manifest is accepted, platform packages included.
726        assert_eq!(
727            manifest_error(&json!({
728                "name": "acme/app",
729                "require": {"php": ">=8.1", "ext-json": "*", "psr/log": "^3", "a-b/c--d": "*"},
730                "require-dev": {"phpunit/phpunit": "^11"},
731            })),
732            None
733        );
734        // The root name: only what the schema lets through is ours to report.
735        assert_eq!(
736            manifest_error(&json!({"name": "acme/thing.json"})).as_deref(),
737            Some("Your package name acme/thing.json is invalid, package names can not end in .json, consider renaming it or perhaps using a -json suffix instead.")
738        );
739        assert!(manifest_error(&json!({"name": "con/thing"})).is_some());
740        // An uppercase or malformed root name is refused by Composer's JSON
741        // schema first, so its message is the schema's and ours is the
742        // loader's. Both refuse and both write nothing, which is the part that
743        // matters; staying silent here would let `dump-autoload` write.
744        assert_eq!(
745            manifest_error(&json!({"name": "acme/ThingBaz"})).as_deref(),
746            Some("Your package name acme/ThingBaz is invalid, it should not contain uppercase characters. We suggest using acme/thing-baz instead.")
747        );
748        assert!(manifest_error(&json!({"name": "acme"}))
749            .is_some_and(|e| e
750                .starts_with("Your package name acme is invalid, it should have a vendor name")));
751        // A trailing newline: the schema's `$` tolerates it, the loader's `D`
752        // does not — so the loader is what answers, and so do we.
753        assert!(manifest_error(&json!({"name": "acme/app\n"})).is_some());
754        // No name at all is `__root__`, never an error.
755        assert_eq!(manifest_error(&json!({"require": {"a/b": "*"}})), None);
756        // A package that requires itself, from either section.
757        for section in ["require", "require-dev"] {
758            let m = json!({"name": "acme/app", section: {"acme/app": "*"}});
759            assert!(
760                manifest_error(&m).is_some_and(|e| e
761                    .starts_with("Root package 'acme/app' cannot require itself")
762                    && e.contains("\nDid you accidentally")),
763                "{section}"
764            );
765        }
766        // The five link types, each prefixed with its own name.
767        for section in ["require", "conflict", "provide", "replace", "require-dev"] {
768            let m = json!({"name": "acme/app", section: {"Acme/Thing": "^1"}});
769            assert_eq!(
770                manifest_error(&m).as_deref(),
771                Some(&*format!("{section}.Acme/Thing is invalid, it should not contain uppercase characters. Please use acme/thing instead.")),
772                "{section}"
773            );
774        }
775        // Composer's order: the root name first, then self-require, then the
776        // link names in the order of `BasePackage::$supportedLinkTypes`.
777        let all = json!({
778            "name": "acme/thing.json",
779            "require": {"acme/thing.json": "*", "Bad/Name": "*"},
780            "conflict": {"Other/Name": "*"},
781        });
782        assert!(manifest_error(&all).is_some_and(|e| e.starts_with("Your package name")));
783        let links = json!({"require": {"Bad/Name": "*"}, "conflict": {"Other/Name": "*"}});
784        assert!(manifest_error(&links).is_some_and(|e| e.starts_with("require.Bad/Name")));
785    }
786}