1use 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#[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 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 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#[derive(Debug, Clone, PartialEq)]
99pub struct MergedLink {
100 pub target: String,
101 pub constraint: Constraint,
102 pub pretty: String,
104 pub text: String,
107}
108
109#[derive(Debug, Clone)]
111pub struct Merged {
112 pub manifest: Value,
116 pub requires: Vec<MergedLink>,
117 pub requires_dev: Vec<MergedLink>,
118 pub merged_names: Vec<String>,
120 pub files: Vec<String>,
122 pub include_links: Vec<IncludeLinks>,
126 pub repositories: Vec<String>,
129}
130
131#[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 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
159pub 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 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 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 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 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 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 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 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 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 fn self_version(
373 &self,
374 key: &str,
375 source: &str,
376 pretty: &str,
377 path: &str,
378 ) -> Result<(Constraint, String), String> {
379 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 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 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 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 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 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(¤t, &rebased);
518 }
519
520 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
551fn 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
571fn 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
586fn 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
601fn 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
610fn 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 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 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 assert_eq!(m.manifest["require"]["psr/log"], json!("^3.0"));
721 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 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 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 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 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 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 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 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 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 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 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 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}