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