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 pub repository_definitions: Vec<Value>,
135}
136
137#[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 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
166pub 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 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 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 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 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 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 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 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 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 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 fn self_version(
401 &self,
402 key: &str,
403 source: &str,
404 pretty: &str,
405 path: &str,
406 ) -> Result<(Constraint, String), String> {
407 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 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 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 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 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 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(¤t, &rebased);
546 }
547
548 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
579fn 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
599fn 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
614fn 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
629fn 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
638fn 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 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 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 assert_eq!(m.manifest["require"]["psr/log"], json!("^3.0"));
749 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 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 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 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 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 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 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 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 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 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 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 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}