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