Skip to main content

vivacity_resolver/
merge_plugin.rs

1//! Emulation of `wikimedia/composer-merge-plugin` v2.1.0 (v2.0.1 alike;
2//! docs/reference/plugins/composer-merge-plugin/, MIT) for an `install`
3//! and an autoload dump: the `composer.json` files named by
4//! `extra.merge-plugin.include` / `require` (PHP `glob()` patterns, no
5//! flags) are merged into the root package — `require` / `require-dev`,
6//! `autoload` / `autoload-dev` (paths re-based on each file's directory),
7//! `conflict` / `replace` / `provide`, `extra` with `merge-extra` — in the
8//! order and with the duplicate rules of `ExtraPackage::mergeInto` /
9//! `mergeDevInto`, recursively (`recurse`).
10//!
11//! What vivacity does not do: the plugin's implicit `composer update` of
12//! the merged requirements on the run that installs the plugin itself
13//! (`onPostPackageInstall` → `onPostInstallOrUpdate`), which rewrites
14//! `composer.lock` against the live repositories (DECISIONS, 2026-09-17);
15//! `merge-scripts`, suggests, aliases and references, the prepended
16//! repositories (resolution only).
17//!
18//! The merged `require` links are structured: a duplicate key without
19//! `ignore-duplicates` or `replace` is Composer's conjunctive
20//! `MultiConstraint` (`mergeConstraints`, with the `Intervals::isSubsetOf`
21//! short-cuts), pretty `"<old>, <new>"`. The textual `require` of the
22//! merged manifest carries an equivalent constraint text (the conjunction
23//! distributed over `||`), for the consumers that parse text (the
24//! platform check); the lock check takes the structured links.
25
26use crate::constraint::{parse_constraints, Constraint};
27use crate::intervals::is_subset_of;
28use serde_json::{Map, Value};
29use std::collections::BTreeMap;
30use std::path::Path;
31use vivacity_core::glob::glob_plain;
32use vivacity_core::phparray::{array_merge, array_merge_recursive, merge_deep};
33
34pub const PLUGIN_NAME: &str = "wikimedia/composer-merge-plugin";
35
36/// `PluginState::loadSettings`.
37#[derive(Debug, Clone)]
38pub struct Settings {
39    pub includes: Vec<String>,
40    pub requires: Vec<String>,
41    pub recurse: bool,
42    pub replace: bool,
43    pub ignore_duplicates: bool,
44    pub merge_dev: bool,
45    pub merge_extra: bool,
46    pub merge_extra_deep: bool,
47    pub merge_replace: bool,
48}
49
50fn patterns(v: Option<&Value>) -> Vec<String> {
51    match v {
52        Some(Value::String(s)) => vec![s.clone()],
53        Some(Value::Array(a)) => a
54            .iter()
55            .filter_map(Value::as_str)
56            .map(str::to_owned)
57            .collect(),
58        _ => Vec::new(),
59    }
60}
61
62fn flag(cfg: Option<&Value>, key: &str, default: bool) -> bool {
63    // `(bool)$config[$key]`: PHP truthiness.
64    match cfg.and_then(|c| c.get(key)) {
65        None | Some(Value::Null) => default,
66        Some(Value::Bool(b)) => *b,
67        Some(Value::Number(n)) => n.as_f64() != Some(0.0),
68        Some(Value::String(s)) => !(s.is_empty() || s == "0"),
69        Some(Value::Array(a)) => !a.is_empty(),
70        Some(Value::Object(o)) => !o.is_empty(),
71    }
72}
73
74impl Settings {
75    pub fn from_manifest(manifest: &Value) -> Settings {
76        let cfg = manifest.get("extra").and_then(|e| e.get("merge-plugin"));
77        Settings {
78            includes: patterns(cfg.and_then(|c| c.get("include"))),
79            requires: patterns(cfg.and_then(|c| c.get("require"))),
80            recurse: flag(cfg, "recurse", true),
81            replace: flag(cfg, "replace", false),
82            ignore_duplicates: flag(cfg, "ignore-duplicates", false),
83            merge_dev: flag(cfg, "merge-dev", true),
84            merge_extra: flag(cfg, "merge-extra", false),
85            merge_extra_deep: flag(cfg, "merge-extra-deep", false),
86            merge_replace: flag(cfg, "merge-replace", true),
87        }
88    }
89
90    /// `extra.merge-plugin` names at least one pattern.
91    pub fn declared(manifest: &Value) -> bool {
92        let s = Settings::from_manifest(manifest);
93        !(s.includes.is_empty() && s.requires.is_empty())
94    }
95}
96
97/// A merged `require` / `require-dev` link, as `Link` would carry it.
98#[derive(Debug, Clone, PartialEq)]
99pub struct MergedLink {
100    pub target: String,
101    pub constraint: Constraint,
102    /// `getPrettyConstraint()`: what an error line shows.
103    pub pretty: String,
104    /// A constraint text that parses to `constraint` (the pretty string,
105    /// or the conjunction distributed over `||`), for the manifest.
106    pub text: String,
107}
108
109/// The root package after the merge.
110#[derive(Debug, Clone)]
111pub struct Merged {
112    /// The root manifest with the merged sections (`require`,
113    /// `require-dev` as equivalent text; `autoload`, `autoload-dev`,
114    /// `conflict`, `replace`, `provide`, `extra`).
115    pub manifest: Value,
116    pub requires: Vec<MergedLink>,
117    pub requires_dev: Vec<MergedLink>,
118    /// `getMergedRequirements()`: the names added, replaced or merged.
119    pub merged_names: Vec<String>,
120    /// The files merged, in order.
121    pub files: Vec<String>,
122    /// Per merged file, in merge order: its own `require` and
123    /// `require-dev` entries (name, constraint text as written) — what
124    /// `mergeStabilityFlags` extracts flags from, before any merge.
125    pub include_links: Vec<IncludeLinks>,
126    /// A merged file declares `repositories` (`prependRepositories`):
127    /// not emulated at resolution.
128    pub repositories: Vec<String>,
129}
130
131/// The raw requirement sections of one merged file.
132#[derive(Debug, Clone, Default)]
133pub struct IncludeLinks {
134    pub file: String,
135    pub require: Vec<(String, String)>,
136    pub require_dev: Vec<(String, String)>,
137}
138
139struct Merger<'a> {
140    project_dir: &'a Path,
141    settings: Settings,
142    with_dev: bool,
143    root_version: String,
144    root_pretty_version: String,
145    root: Value,
146    requires: BTreeMap<String, MergedLink>,
147    requires_dev: BTreeMap<String, MergedLink>,
148    /// Insertion order of the link maps (`$origin[$name]` keeps the
149    /// original position, a new name is appended).
150    require_order: Vec<String>,
151    require_dev_order: Vec<String>,
152    merged_names: Vec<String>,
153    loaded: Vec<String>,
154    files: Vec<String>,
155    include_links: Vec<IncludeLinks>,
156    repositories: Vec<String>,
157}
158
159/// `MergePlugin::onInit` + `onInstallUpdateOrDump` in one pass: the root
160/// manifest, the root version (`RootPackage`: `version` or the VCS guess,
161/// normalised and pretty), the dev mode of the run.
162pub fn merge(
163    project_dir: &Path,
164    manifest: &Value,
165    root_version: &str,
166    root_pretty_version: &str,
167    with_dev: bool,
168) -> Result<Merged, String> {
169    let settings = Settings::from_manifest(manifest);
170    let mut m = Merger {
171        project_dir,
172        settings: settings.clone(),
173        with_dev: with_dev && settings.merge_dev,
174        root_version: root_version.to_owned(),
175        root_pretty_version: root_pretty_version.to_owned(),
176        root: manifest.clone(),
177        requires: BTreeMap::new(),
178        requires_dev: BTreeMap::new(),
179        require_order: Vec::new(),
180        require_dev_order: Vec::new(),
181        merged_names: Vec::new(),
182        loaded: Vec::new(),
183        files: Vec::new(),
184        include_links: Vec::new(),
185        repositories: Vec::new(),
186    };
187    for key in ["require", "require-dev"] {
188        let (map, order) = if key == "require" {
189            (&mut m.requires, &mut m.require_order)
190        } else {
191            (&mut m.requires_dev, &mut m.require_dev_order)
192        };
193        if let Some(links) = manifest.get(key).and_then(Value::as_object) {
194            for (target, pretty) in links {
195                let Some(pretty) = pretty.as_str() else {
196                    continue;
197                };
198                let name = target.to_lowercase();
199                // `RootPackageLoader`: the root's own `self.version` is its
200                // version; the pretty string stays (the lock check skips it).
201                let source = if pretty == "self.version" {
202                    root_version
203                } else {
204                    pretty
205                };
206                let parsed = parse_constraints(source)
207                    .map_err(|e| format!("composer.json {key}.{target}: {e}"))?;
208                map.insert(
209                    name.clone(),
210                    MergedLink {
211                        target: name.clone(),
212                        constraint: parsed.constraint,
213                        pretty: pretty.to_owned(),
214                        text: pretty.to_owned(),
215                    },
216                );
217                order.push(name);
218            }
219        }
220    }
221    let includes = settings.includes.clone();
222    let requires = settings.requires.clone();
223    m.merge_files(&includes, false)?;
224    m.merge_files(&requires, true)?;
225    // The textual require sections of the merged manifest.
226    for (key, map, order) in [
227        ("require", &m.requires, &m.require_order),
228        ("require-dev", &m.requires_dev, &m.require_dev_order),
229    ] {
230        if map.is_empty() {
231            continue;
232        }
233        let mut out = Map::new();
234        for name in order {
235            if let Some(l) = map.get(name) {
236                out.insert(name.clone(), Value::String(l.text.clone()));
237            }
238        }
239        m.root[key] = Value::Object(out);
240    }
241    // `$mergedRequirements` is keyed by name.
242    let mut merged_names: Vec<String> = Vec::new();
243    for n in m.merged_names {
244        if !merged_names.contains(&n) {
245            merged_names.push(n);
246        }
247    }
248    Ok(Merged {
249        manifest: m.root,
250        requires: m
251            .require_order
252            .iter()
253            .filter_map(|n| m.requires.get(n).cloned())
254            .collect(),
255        requires_dev: m
256            .require_dev_order
257            .iter()
258            .filter_map(|n| m.requires_dev.get(n).cloned())
259            .collect(),
260        merged_names,
261        files: m.files,
262        include_links: m.include_links,
263        repositories: m.repositories,
264    })
265}
266
267impl Merger<'_> {
268    /// `mergeFiles`: each pattern globbed (flags 0), a `require` pattern
269    /// must match; the matches in glob order, each merged once.
270    fn merge_files(&mut self, patterns: &[String], required: bool) -> Result<(), String> {
271        let mut paths: Vec<String> = Vec::new();
272        for pattern in patterns {
273            let found = glob_plain(pattern, self.project_dir);
274            if required && found.is_empty() {
275                return Err(format!(
276                    "merge-plugin: No files matched required '{pattern}'"
277                ));
278            }
279            paths.extend(found);
280        }
281        for path in paths {
282            self.merge_file(&path)?;
283        }
284        Ok(())
285    }
286
287    /// `mergeFile`: a path is merged once (by its spelling).
288    fn merge_file(&mut self, path: &str) -> Result<(), String> {
289        if self.loaded.iter().any(|p| p == path) {
290            return Ok(());
291        }
292        let text = std::fs::read_to_string(self.project_dir.join(path))
293            .map_err(|e| format!("merge-plugin: {path}: {e}"))?;
294        let json: Value = serde_json::from_str(&text)
295            .map_err(|e| format!("merge-plugin: \"{path}\" does not contain valid JSON\n{e}"))?;
296        // A JSON `[]` is an empty PHP array: nothing to merge.
297        if matches!(&json, Value::Array(a) if a.is_empty()) {
298            self.loaded.push(path.to_owned());
299            return Ok(());
300        }
301        if !json.is_object() {
302            return Err(format!(
303                "merge-plugin: \"{path}\" does not contain valid JSON"
304            ));
305        }
306        let base = {
307            let d = vivacity_core::pathutil::php_dirname(path);
308            if d == "." {
309                String::new()
310            } else {
311                format!("{d}/")
312            }
313        };
314        let name = json
315            .get("name")
316            .and_then(Value::as_str)
317            .map(str::to_owned)
318            .unwrap_or_else(|| format!("merge-plugin/{}", path.replace('/', "-")));
319        self.files.push(path.to_owned());
320        self.loaded.push(path.to_owned());
321        let raw_links = |key: &str| -> Vec<(String, String)> {
322            json.get(key)
323                .and_then(Value::as_object)
324                .map(|m| {
325                    m.iter()
326                        .filter_map(|(k, v)| v.as_str().map(|c| (k.clone(), c.to_owned())))
327                        .collect()
328                })
329                .unwrap_or_default()
330        };
331        self.include_links.push(IncludeLinks {
332            file: path.to_owned(),
333            require: raw_links("require"),
334            require_dev: if self.with_dev {
335                raw_links("require-dev")
336            } else {
337                Vec::new()
338            },
339        });
340        if json.get("repositories").is_some_and(|r| !php_empty(r)) {
341            self.repositories.push(path.to_owned());
342        }
343
344        // mergeInto
345        self.merge_requires("require", &json, &name, path)?;
346        self.merge_package_links("conflict", &json, &name, path)?;
347        if self.settings.merge_replace {
348            self.merge_package_links("replace", &json, &name, path)?;
349        }
350        self.merge_package_links("provide", &json, &name, path)?;
351        self.merge_autoload("autoload", &json, &base);
352        self.merge_extra(&json);
353        if self.with_dev {
354            // mergeDevInto
355            self.merge_requires("require-dev", &json, &name, path)?;
356            self.merge_autoload("autoload-dev", &json, &base);
357        }
358        if self.settings.recurse {
359            let inner = Settings::from_manifest(&json);
360            let rebase = |p: &String| format!("{base}{p}");
361            let includes: Vec<String> = inner.includes.iter().map(rebase).collect();
362            let requires: Vec<String> = inner.requires.iter().map(rebase).collect();
363            self.merge_files(&includes, false)?;
364            self.merge_files(&requires, true)?;
365        }
366        Ok(())
367    }
368
369    /// `replaceSelfVersionDependencies` for one link: `self.version` becomes
370    /// the root's constraint on a package named like the include (looked up
371    /// by the link's source, as the plugin does) or the root's version.
372    fn self_version(
373        &self,
374        key: &str,
375        source: &str,
376        pretty: &str,
377        path: &str,
378    ) -> Result<(Constraint, String), String> {
379        // `ArrayLoader::load`: an invalid constraint fails the whole run.
380        let parse = |text: &str| {
381            parse_constraints(text)
382                .map(|p| p.constraint)
383                .map_err(|e| format!("merge-plugin: {path} {key}: {e}"))
384        };
385        if pretty != "self.version" {
386            return Ok((parse(pretty)?, pretty.to_owned()));
387        }
388        // `$root->getX()[$link->getSource()]`: the root's link (merged so
389        // far) whose target is the include's own name.
390        let lower = source.to_lowercase();
391        let existing: Option<String> = match key {
392            "require" => self.requires.get(&lower).map(|l| l.pretty.clone()),
393            "require-dev" => self.requires_dev.get(&lower).map(|l| l.pretty.clone()),
394            _ => self
395                .root
396                .get(key)
397                .and_then(Value::as_object)
398                .and_then(|m| m.get(&lower))
399                .and_then(Value::as_str)
400                .map(str::to_owned),
401        };
402        if let Some(existing) = existing {
403            return Ok((parse(&existing)?, existing));
404        }
405        Ok((parse(&self.root_version)?, self.root_pretty_version.clone()))
406    }
407
408    /// `mergeRequires` + `mergeOrDefer` on `require` or `require-dev`.
409    fn merge_requires(
410        &mut self,
411        key: &str,
412        json: &Value,
413        source: &str,
414        path: &str,
415    ) -> Result<(), String> {
416        let Some(links) = json.get(key).and_then(Value::as_object) else {
417            return Ok(());
418        };
419        if links.is_empty() {
420            return Ok(());
421        }
422        let ignore = self.settings.ignore_duplicates;
423        let replace = self.settings.replace;
424        for (target, pretty) in links {
425            let Some(pretty) = pretty.as_str() else {
426                continue;
427            };
428            let name = target.to_lowercase();
429            let (constraint, pretty) = self.self_version(key, source, pretty, path)?;
430            let incoming = MergedLink {
431                target: name.clone(),
432                constraint,
433                text: pretty.clone(),
434                pretty,
435            };
436            let (map, order) = if key == "require" {
437                (&mut self.requires, &mut self.require_order)
438            } else {
439                (&mut self.requires_dev, &mut self.require_dev_order)
440            };
441            match map.get(&name) {
442                Some(_) if ignore => {}
443                Some(_) if replace => {
444                    map.insert(name.clone(), incoming);
445                    self.merged_names.push(name);
446                }
447                Some(origin) => {
448                    let merged = merge_constraints(origin, &incoming);
449                    map.insert(name.clone(), merged);
450                    self.merged_names.push(name);
451                }
452                None => {
453                    map.insert(name.clone(), incoming);
454                    order.push(name.clone());
455                    self.merged_names.push(name);
456                }
457            }
458        }
459        Ok(())
460    }
461
462    /// `mergePackageLinks`: `array_merge($root->getX(), $links)` keyed by
463    /// target — the include overwrites a duplicate key in place, new keys
464    /// append; `self.version` replaced.
465    fn merge_package_links(
466        &mut self,
467        key: &str,
468        json: &Value,
469        source: &str,
470        path: &str,
471    ) -> Result<(), String> {
472        let Some(links) = json.get(key).and_then(Value::as_object) else {
473            return Ok(());
474        };
475        if links.is_empty() {
476            return Ok(());
477        }
478
479        let mut out: Map<String, Value> = self
480            .root
481            .get(key)
482            .and_then(Value::as_object)
483            .cloned()
484            .unwrap_or_default();
485        for (target, pretty) in links {
486            let Some(pretty) = pretty.as_str() else {
487                continue;
488            };
489            let (_, pretty) = self.self_version(key, source, pretty, path)?;
490            let name = target.to_lowercase();
491            // The root's key may be spelled with capitals: same package.
492            if let Some(existing) = out.keys().find(|k| k.to_lowercase() == name).cloned() {
493                out.insert(existing, Value::String(pretty));
494            } else {
495                out.insert(name, Value::String(pretty));
496            }
497        }
498        self.root[key] = Value::Object(out);
499        Ok(())
500    }
501
502    /// `mergeAutoload`: `array_merge_recursive($root->getAutoload(),
503    /// fixRelativePaths($autoload))`.
504    fn merge_autoload(&mut self, key: &str, json: &Value, base: &str) {
505        let Some(autoload) = json.get(key) else {
506            return;
507        };
508        if !autoload.is_object() || autoload.as_object().is_some_and(|o| o.is_empty()) {
509            return;
510        }
511        let rebased = rebase(autoload, base);
512        let current = self
513            .root
514            .get(key)
515            .cloned()
516            .unwrap_or_else(|| Value::Object(Map::new()));
517        self.root[key] = array_merge_recursive(&current, &rebased);
518    }
519
520    /// `mergeExtra`: only with `merge-extra`; the root wins unless
521    /// `replace`; deep with `merge-extra-deep`; the include's own
522    /// `merge-plugin` block stripped first.
523    fn merge_extra(&mut self, json: &Value) {
524        let Some(extra) = json.get("extra").and_then(Value::as_object) else {
525            return;
526        };
527        let mut extra = extra.clone();
528        extra.remove("merge-plugin");
529        if !self.settings.merge_extra || extra.is_empty() {
530            return;
531        }
532        let extra = Value::Object(extra);
533        let root_extra = self
534            .root
535            .get("extra")
536            .cloned()
537            .unwrap_or_else(|| Value::Object(Map::new()));
538        let (first, second) = if self.settings.replace {
539            (&root_extra, &extra)
540        } else {
541            (&extra, &root_extra)
542        };
543        self.root["extra"] = if self.settings.merge_extra_deep {
544            merge_deep(first, second)
545        } else {
546            array_merge(first, second)
547        };
548    }
549}
550
551/// `mergeConstraints`: the subset short-cuts, else the conjunction with
552/// the pretty `"<old>, <new>"`.
553fn merge_constraints(origin: &MergedLink, incoming: &MergedLink) -> MergedLink {
554    if is_subset_of(&origin.constraint, &incoming.constraint) {
555        return origin.clone();
556    }
557    if is_subset_of(&incoming.constraint, &origin.constraint) {
558        return incoming.clone();
559    }
560    MergedLink {
561        target: origin.target.clone(),
562        constraint: Constraint::create(
563            vec![origin.constraint.clone(), incoming.constraint.clone()],
564            true,
565        ),
566        pretty: format!("{}, {}", origin.pretty, incoming.pretty),
567        text: conjunction_text(&origin.text, &incoming.text),
568    }
569}
570
571/// `fixRelativePaths`: every leaf string prefixed by the include's
572/// directory (`array_walk_recursive`).
573fn rebase(v: &Value, base: &str) -> Value {
574    match v {
575        Value::String(s) => Value::String(format!("{base}{s}")),
576        Value::Array(a) => Value::Array(a.iter().map(|x| rebase(x, base)).collect()),
577        Value::Object(o) => Value::Object(
578            o.iter()
579                .map(|(k, x)| (k.clone(), rebase(x, base)))
580                .collect(),
581        ),
582        other => other.clone(),
583    }
584}
585
586/// The text of `a AND b` that Composer's parser reads as such: it splits
587/// on `||` first, so `"A || B, C"` would mean `A || (B, C)`; the
588/// conjunction is distributed over the alternatives of each side
589/// (`(A || B), C` → `A, C || B, C`) — the same set, for the consumers
590/// that parse text (the platform check).
591fn conjunction_text(a: &str, b: &str) -> String {
592    let mut out = Vec::new();
593    for x in split_or(a) {
594        for y in split_or(b) {
595            out.push(format!("{x}, {y}"));
596        }
597    }
598    out.join(" || ")
599}
600
601/// Top-level `||` / `|` alternatives of a constraint text.
602fn split_or(text: &str) -> Vec<&str> {
603    text.split("||")
604        .flat_map(|s| s.split('|'))
605        .map(str::trim)
606        .filter(|s| !s.is_empty())
607        .collect()
608}
609
610/// PHP `empty()` on a JSON value.
611fn php_empty(v: &Value) -> bool {
612    match v {
613        Value::Null => true,
614        Value::Bool(b) => !b,
615        Value::Number(n) => n.as_f64() == Some(0.0),
616        Value::String(s) => s.is_empty() || s == "0",
617        Value::Array(a) => a.is_empty(),
618        Value::Object(o) => o.is_empty(),
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use serde_json::json;
626
627    fn write(dir: &Path, rel: &str, v: &Value) {
628        let p = dir.join(rel);
629        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
630        std::fs::write(p, serde_json::to_string_pretty(v).unwrap()).unwrap();
631    }
632
633    fn root() -> Value {
634        json!({
635            "name": "acme/app",
636            "require": {"monolog/monolog": "^2.8", "psr/log": "^3.0"},
637            "require-dev": {"phpunit/phpunit": "^10"},
638            "autoload": {"psr-4": {"App\\": "src/"}, "files": ["helpers.php"]},
639            "conflict": {"old/lib": "<1.0"},
640            "extra": {"merge-plugin": {"include": ["modules/*/composer.json", "composer.ext.json"], "require": ["required/composer.json"]}, "keep": 1, "shared": {"a": 1}}
641        })
642    }
643
644    #[test]
645    fn merges_every_section_like_the_plugin() {
646        let tmp = tempfile::tempdir().unwrap();
647        let d = tmp.path();
648        write(
649            d,
650            "modules/Base/composer.json",
651            &json!({
652                "name": "acme/base",
653                "require": {"monolog/monolog": "^2.11 || ^1.0", "guzzlehttp/guzzle": "^7.5", "acme/self": "self.version"},
654                "require-dev": {"mockery/mockery": "^1.6"},
655                "autoload": {"psr-4": {"App\\": "src/", "Base\\": "lib/"}, "files": ["boot.php"], "exclude-from-classmap": ["tests/"]},
656                "autoload-dev": {"psr-4": {"BaseTests\\": "tests/"}},
657                "provide": {"psr/log-implementation": "3.0"},
658                "replace": {"acme/base-compat": "self.version"},
659                "conflict": {"old/lib": "<2.0", "bad/lib": "*"},
660                "extra": {"merge-plugin": {"include": ["nested/composer.json"]}, "shared": {"b": 2}, "only-include": true}
661            }),
662        );
663        write(
664            d,
665            "modules/Base/nested/composer.json",
666            &json!({
667                "require": {"nested/dep": "^1.0"},
668                "autoload": {"classmap": ["Legacy/"]}
669            }),
670        );
671        write(
672            d,
673            "composer.ext.json",
674            &json!({"require": {"psr/log": "^3.0"}}),
675        );
676        write(
677            d,
678            "required/composer.json",
679            &json!({"require": {"symfony/yaml": "^7.4"}}),
680        );
681        let m = merge(d, &root(), "1.2.3.0", "1.2.3", true).unwrap();
682        assert_eq!(
683            m.files,
684            vec![
685                "modules/Base/composer.json",
686                "modules/Base/nested/composer.json",
687                "composer.ext.json",
688                "required/composer.json"
689            ]
690        );
691        // require: the duplicate monolog key is the conjunction (neither is a subset).
692        let mono = m
693            .requires
694            .iter()
695            .find(|l| l.target == "monolog/monolog")
696            .unwrap();
697        assert_eq!(mono.pretty, "^2.8, ^2.11 || ^1.0");
698        assert!(mono.constraint.matches_version("2.11.0.0"));
699        assert!(
700            !mono.constraint.matches_version("1.5.0.0"),
701            "1.x is outside ^2.8"
702        );
703        assert!(!mono.constraint.matches_version("2.10.0.0"), "below ^2.11");
704        // Its text parses to the same set: distributed over ||.
705        assert_eq!(
706            m.manifest["require"]["monolog/monolog"],
707            json!("^2.8, ^2.11 || ^2.8, ^1.0")
708        );
709        let reparsed = parse_constraints("^2.8, ^2.11 || ^2.8, ^1.0")
710            .unwrap()
711            .constraint;
712        for v in ["2.11.0.0", "2.10.0.0", "1.5.0.0", "3.0.0.0"] {
713            assert_eq!(
714                reparsed.matches_version(v),
715                mono.constraint.matches_version(v),
716                "{v}"
717            );
718        }
719        // psr/log ^3.0 twice: a subset of itself → unchanged pretty.
720        assert_eq!(m.manifest["require"]["psr/log"], json!("^3.0"));
721        // self.version → the root's version.
722        assert_eq!(m.manifest["require"]["acme/self"], json!("1.2.3"));
723        assert_eq!(m.manifest["replace"]["acme/base-compat"], json!("1.2.3"));
724        // New keys appended in order: guzzle, acme/self, nested, symfony/yaml.
725        let order: Vec<&str> = m.requires.iter().map(|l| l.target.as_str()).collect();
726        assert_eq!(
727            order,
728            vec![
729                "monolog/monolog",
730                "psr/log",
731                "guzzlehttp/guzzle",
732                "acme/self",
733                "nested/dep",
734                "symfony/yaml"
735            ]
736        );
737        assert_eq!(
738            m.requires_dev
739                .iter()
740                .map(|l| l.target.as_str())
741                .collect::<Vec<_>>(),
742            vec!["phpunit/phpunit", "mockery/mockery"]
743        );
744        // A merged duplicate counts as merged even when the subset short-cut kept one side (psr/log).
745        assert_eq!(
746            m.merged_names,
747            vec![
748                "monolog/monolog",
749                "guzzlehttp/guzzle",
750                "acme/self",
751                "mockery/mockery",
752                "nested/dep",
753                "psr/log",
754                "symfony/yaml"
755            ]
756        );
757        // autoload: paths re-based, duplicate PSR-4 key becomes a list.
758        assert_eq!(
759            m.manifest["autoload"],
760            json!({
761                "psr-4": {"App\\": ["src/", "modules/Base/src/"], "Base\\": "modules/Base/lib/"},
762                "files": ["helpers.php", "modules/Base/boot.php"],
763                "exclude-from-classmap": ["modules/Base/tests/"],
764                "classmap": ["modules/Base/nested/Legacy/"]
765            })
766        );
767        assert_eq!(
768            m.manifest["autoload-dev"],
769            json!({"psr-4": {"BaseTests\\": "modules/Base/tests/"}})
770        );
771        // links: the include overwrites a duplicate conflict key in place.
772        assert_eq!(
773            m.manifest["conflict"],
774            json!({"old/lib": "<2.0", "bad/lib": "*"})
775        );
776        assert_eq!(
777            m.manifest["provide"],
778            json!({"psr/log-implementation": "3.0"})
779        );
780        // extra untouched without merge-extra (the include's block ignored).
781        assert_eq!(m.manifest["extra"]["keep"], json!(1));
782        assert!(m.manifest["extra"].get("only-include").is_none());
783    }
784
785    #[test]
786    fn duplicate_rules_dev_off_and_extra() {
787        let tmp = tempfile::tempdir().unwrap();
788        let d = tmp.path();
789        write(
790            d,
791            "inc/composer.json",
792            &json!({
793                "require": {"monolog/monolog": "^2.11"},
794                "require-dev": {"x/y": "*"},
795                "autoload-dev": {"psr-4": {"T\\": "t/"}},
796                "extra": {"shared": {"b": 2}, "keep": 2, "new": 3}
797            }),
798        );
799        let mut r = root();
800        r["extra"]["merge-plugin"] = json!({"include": ["inc/composer.json"], "ignore-duplicates": true, "merge-dev": false, "merge-extra": true});
801        let m = merge(d, &r, "1.0.0.0", "1.0.0", true).unwrap();
802        assert_eq!(
803            m.manifest["require"]["monolog/monolog"],
804            json!("^2.8"),
805            "ignored duplicate"
806        );
807        assert!(m.merged_names.is_empty());
808        assert!(
809            m.requires_dev.iter().all(|l| l.target != "x/y"),
810            "merge-dev false"
811        );
812        assert!(m.manifest.get("autoload-dev").is_none());
813        // merge-extra shallow, root wins.
814        assert_eq!(m.manifest["extra"]["keep"], json!(1));
815        assert_eq!(m.manifest["extra"]["shared"], json!({"a": 1}));
816        assert_eq!(m.manifest["extra"]["new"], json!(3));
817        // replace: the include wins; deep: maps merge.
818        r["extra"]["merge-plugin"] = json!({"include": ["inc/composer.json"], "replace": true, "merge-extra": true, "merge-extra-deep": true});
819        let m = merge(d, &r, "1.0.0.0", "1.0.0", false).unwrap();
820        assert_eq!(m.manifest["require"]["monolog/monolog"], json!("^2.11"));
821        assert_eq!(m.manifest["extra"]["keep"], json!(2));
822        assert_eq!(m.manifest["extra"]["shared"], json!({"a": 1, "b": 2}));
823        // Subset short-cut: ^2.8 then ^2.9 → ^2.9 (the narrower side).
824        write(
825            d,
826            "inc/composer.json",
827            &json!({"require": {"monolog/monolog": "^2.9"}}),
828        );
829        r["extra"]["merge-plugin"] = json!({"include": ["inc/composer.json"]});
830        let m = merge(d, &r, "1.0.0.0", "1.0.0", false).unwrap();
831        assert_eq!(m.manifest["require"]["monolog/monolog"], json!("^2.9"));
832    }
833
834    #[test]
835    fn required_pattern_must_match_and_invalid_json_fails() {
836        let tmp = tempfile::tempdir().unwrap();
837        let d = tmp.path();
838        let mut r = root();
839        r["extra"]["merge-plugin"] = json!({"require": ["nowhere/*.json"]});
840        assert_eq!(
841            merge(d, &r, "1.0.0.0", "1.0.0", true).unwrap_err(),
842            "merge-plugin: No files matched required 'nowhere/*.json'"
843        );
844        std::fs::create_dir_all(d.join("inc")).unwrap();
845        std::fs::write(d.join("inc/composer.json"), "{ nope").unwrap();
846        r["extra"]["merge-plugin"] = json!({"include": "inc/composer.json"});
847        assert!(merge(d, &r, "1.0.0.0", "1.0.0", true)
848            .unwrap_err()
849            .contains("does not contain valid JSON"));
850        // An invalid constraint in an include fails like ArrayLoader would.
851        std::fs::write(
852            d.join("inc/composer.json"),
853            r#"{"require": {"x/y": "^^1"}}"#,
854        )
855        .unwrap();
856        assert!(merge(d, &r, "1.0.0.0", "1.0.0", true)
857            .unwrap_err()
858            .contains("inc/composer.json require"));
859        // The root's own self.version is its version; an empty include merges nothing.
860        std::fs::write(d.join("inc/composer.json"), "[]").unwrap();
861        let mut r2 = r.clone();
862        r2["require"]["acme/app-alias"] = json!("self.version");
863        let m = merge(d, &r2, "1.0.0.0", "1.0.0", true).unwrap();
864        let l = m
865            .requires
866            .iter()
867            .find(|l| l.target == "acme/app-alias")
868            .unwrap();
869        assert_eq!(l.pretty, "self.version");
870        assert!(l.constraint.matches_version("1.0.0.0"));
871        // No match on an include: nothing merged, manifest identical.
872        r["extra"]["merge-plugin"] = json!({"include": ["absent/*.json"]});
873        let m = merge(d, &r, "1.0.0.0", "1.0.0", true).unwrap();
874        assert_eq!(m.manifest, r);
875        assert!(m.files.is_empty());
876    }
877}