1use crate::installers::{self, Placement};
13use crate::lock::{Lock, LockPackage};
14use crate::pathutil::{find_shortest_path, normalize_path};
15use serde_json::Value;
16use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18
19#[derive(Debug, Clone)]
20pub struct Layout {
21 root: PathBuf,
24 paths: BTreeMap<String, String>,
26 pub installers_tag: Option<String>,
28 removals: BTreeMap<String, String>,
32}
33
34#[derive(Debug, PartialEq, Eq)]
37pub enum PluginVerdict {
38 Allowed,
39 Blocked,
41 Unlisted,
43}
44
45fn absolutize(dir: &Path) -> PathBuf {
46 if dir.is_absolute() {
47 dir.to_path_buf()
48 } else {
49 std::env::current_dir()
50 .map(|c| c.join(dir))
51 .unwrap_or_else(|_| dir.to_path_buf())
52 }
53}
54
55fn pattern_matches(pattern: &str, name: &str) -> bool {
57 let p = pattern.to_ascii_lowercase();
58 let n = name.to_ascii_lowercase();
59 let parts: Vec<&str> = p.split('*').collect();
60 if parts.len() == 1 {
61 return p == n;
62 }
63 let mut rest = n.as_str();
64 for (i, part) in parts.iter().enumerate() {
65 if i == 0 {
66 let Some(r) = rest.strip_prefix(part) else {
67 return false;
68 };
69 rest = r;
70 } else if i == parts.len() - 1 {
71 return rest.ends_with(part);
72 } else {
73 let Some(pos) = rest.find(part) else {
74 return false;
75 };
76 rest = &rest[pos + part.len()..];
77 }
78 }
79 true
80}
81
82pub fn merged_allow_plugins(project: Option<&Value>, global: Option<&Value>) -> Option<Value> {
88 match (project, global) {
89 (Some(Value::Object(p)), Some(Value::Object(g))) => {
90 let mut m = p.clone();
91 for (k, v) in g {
92 if !m.contains_key(k) {
93 m.insert(k.clone(), v.clone());
94 }
95 }
96 Some(Value::Object(m))
97 }
98 (Some(p), _) => Some(p.clone()),
99 (None, Some(g)) => Some(g.clone()),
100 (None, None) => None,
101 }
102}
103
104fn plugin_verdict(allow: Option<&Value>, package: &str) -> PluginVerdict {
106 match allow {
107 Some(Value::Bool(true)) => PluginVerdict::Allowed,
108 Some(Value::Bool(false)) => PluginVerdict::Blocked,
109 Some(Value::Object(rules)) => {
110 for (pattern, v) in rules {
111 if pattern_matches(pattern, package) {
112 return if v == &Value::Bool(true) {
113 PluginVerdict::Allowed
114 } else {
115 PluginVerdict::Blocked
116 };
117 }
118 }
119 PluginVerdict::Unlisted
120 }
121 _ => PluginVerdict::Unlisted,
122 }
123}
124
125pub fn plugin_allowed(manifest: &Value, package: &str) -> PluginVerdict {
127 let allow = merged_allow_plugins(
128 manifest.get("config").and_then(|c| c.get("allow-plugins")),
129 global_allow_plugins().as_ref(),
130 );
131 plugin_verdict(allow.as_ref(), package)
132}
133
134pub fn global_allow_plugins() -> Option<Value> {
136 global_config_value("allow-plugins")
137}
138
139pub fn global_config_value(key: &str) -> Option<Value> {
142 let path = crate::fetch::composer_home()?.join("config.json");
143 let text = std::fs::read_to_string(path).ok()?;
144 let v: Value = serde_json::from_str(&text).ok()?;
145 v.get("config")?.get(key).cloned()
146}
147
148fn vendor_rel(name: &str, target_dir: Option<&str>) -> String {
150 match target_dir {
151 Some(t) => format!("vendor/{name}/{t}"),
152 None => format!("vendor/{name}"),
153 }
154}
155
156fn place(
158 table: Option<&installers::Table>,
159 root_extra: Option<&Value>,
160 name: &str,
161 package_type: &str,
162 package_extra: Option<&Value>,
163 target_dir: Option<&str>,
164) -> Result<String, String> {
165 let Some(table) = table else {
166 return Ok(vendor_rel(name, target_dir));
167 };
168 match installers::placement(table, root_extra, name, package_type, package_extra) {
169 Ok(Placement::Vendor) => Ok(vendor_rel(name, target_dir)),
170 Ok(Placement::Custom(p)) => {
171 if crate::pathutil::is_absolute_path(&p) {
172 return Err(format!(
173 "installers: {name} would install at an absolute path `{p}`"
174 ));
175 }
176 let rel = normalize_path(&p);
177 if rel.is_empty() || rel == "." {
178 return Err(format!(
179 "installers: {name} would install at the project root"
180 ));
181 }
182 if rel.starts_with("../") || rel == ".." {
183 return Err(format!(
184 "installers: {name} would install outside the project (`{p}`)"
185 ));
186 }
187 if rel == "vendor" || rel.starts_with("vendor/") {
188 return Err(format!(
189 "installers: {name} targets `{p}` inside vendor/ (not emulated: use the default vendor layout)"
190 ));
191 }
192 Ok(rel)
193 }
194 Err(e) => Err(format!("installers: {name} ({package_type}): {e}")),
195 }
196}
197
198impl Layout {
199 pub fn vendor_only(project_dir: &Path, lock: &Lock, with_dev: bool) -> Layout {
202 let mut paths = BTreeMap::new();
203 for p in lock.wanted_packages(with_dev) {
204 if !p.is_virtual(false) {
205 paths.insert(p.name().to_owned(), vendor_rel(p.name(), p.target_dir()));
206 }
207 }
208 Layout {
209 root: absolutize(project_dir),
210 paths,
211 installers_tag: None,
212 removals: BTreeMap::new(),
213 }
214 }
215
216 pub fn resolve(
219 project_dir: &Path,
220 lock: &Lock,
221 manifest: &Value,
222 with_dev: bool,
223 plugins_enabled: bool,
224 ) -> Result<Layout, Vec<String>> {
225 let root = absolutize(project_dir);
226 let mut issues: Vec<String> = Vec::new();
227 let wanted: Vec<&LockPackage> = lock.wanted_packages(with_dev).collect();
228 let previous = installed_packages(&root);
229 let has_state = root.join("vendor/composer/installed.json").is_file();
230
231 let lock_plugin = wanted.iter().find(|p| p.name() == "composer/installers");
237 let prev_plugin = previous
238 .iter()
239 .find(|p| p["name"].as_str() == Some("composer/installers"));
240 let mut table: Option<&installers::Table> = None;
241 let mut installers_tag = None;
242 if plugins_enabled && (lock_plugin.is_some() || prev_plugin.is_some()) {
243 let allow = merged_allow_plugins(
244 manifest.get("config").and_then(|c| c.get("allow-plugins")),
245 global_allow_plugins().as_ref(),
246 );
247 match plugin_verdict(allow.as_ref(), "composer/installers") {
248 PluginVerdict::Allowed => {
249 let version = lock_plugin
250 .map(|p| p.version().to_owned())
251 .or_else(|| {
252 prev_plugin.and_then(|p| p["version"].as_str().map(str::to_owned))
253 })
254 .unwrap_or_default();
255 let Some(t) = installers::table_for(&version) else {
256 return Err(vec![format!(
257 "composer/installers {version} is not a ported version (ported: {})",
258 installers::ported_versions().collect::<Vec<_>>().join(", ")
259 )]);
260 };
261 if has_state && lock_plugin.is_some() != prev_plugin.is_some() {
262 let root_extra = manifest.get("extra");
263 let taken = |name: &str, ty: &str, extra: Option<&Value>| {
264 !matches!(
265 installers::placement(t, root_extra, name, ty, extra),
266 Ok(Placement::Vendor)
267 )
268 };
269 let any_taken = wanted
270 .iter()
271 .any(|p| taken(p.name(), p.package_type(), p.raw.get("extra")))
272 || previous.iter().any(|p| {
273 taken(
274 p["name"].as_str().unwrap_or(""),
275 p["type"].as_str().unwrap_or("library"),
276 p.get("extra"),
277 )
278 });
279 if any_taken {
280 let how = if lock_plugin.is_some() {
281 "added to"
282 } else {
283 "removed from"
284 };
285 return Err(vec![format!(
286 "composer/installers is being {how} an existing install (installed.json and composer.lock disagree): let Composer handle this transition"
287 )]);
288 }
289 }
290 if lock_plugin.is_some() {
291 installers_tag = Some(t.tag.clone());
292 table = Some(t);
293 }
294 }
295 PluginVerdict::Blocked => {} PluginVerdict::Unlisted => {
297 return Err(vec![
298 "composer/installers is a plugin not covered by config.allow-plugins (Composer would refuse to run it)"
299 .to_owned(),
300 ]);
301 }
302 }
303 }
304
305 let root_extra = manifest.get("extra");
306 let flex_packs = lock.flex_packs(manifest, with_dev, plugins_enabled);
307 let mut paths: BTreeMap<String, String> = BTreeMap::new();
308 for p in &wanted {
309 if p.is_virtual(flex_packs) {
310 continue;
311 }
312 match place(
313 table,
314 root_extra,
315 p.name(),
316 p.package_type(),
317 p.raw.get("extra"),
318 p.target_dir(),
319 ) {
320 Ok(rel) => {
321 paths.insert(p.name().to_owned(), rel);
322 }
323 Err(e) => issues.push(e),
324 }
325 }
326
327 if table.is_some() {
329 let mut by_path: BTreeMap<&str, &str> = BTreeMap::new();
330 for (name, rel) in &paths {
331 if let Some(other) = by_path.insert(rel.as_str(), name.as_str()) {
332 issues.push(format!(
333 "installers: {name} and {other} would both install at `{rel}`"
334 ));
335 }
336 }
337 let customs: Vec<(&str, &str)> = paths
338 .iter()
339 .filter(|(_, rel)| !rel.starts_with("vendor/"))
340 .map(|(n, r)| (n.as_str(), r.as_str()))
341 .collect();
342 for (name, rel) in &customs {
343 for (other, other_rel) in &paths {
344 if other.as_str() != *name && other_rel.starts_with(&format!("{rel}/")) {
345 issues.push(format!(
346 "installers: {name} at `{rel}` would contain {other} at `{other_rel}`"
347 ));
348 }
349 }
350 }
351 }
352
353 let mut removals = BTreeMap::new();
357 let wanted_names: std::collections::BTreeSet<&str> =
358 wanted.iter().map(|p| p.name()).collect();
359 let vendor_composer =
360 normalize_path(&format!("{}/vendor/composer", root.to_string_lossy()));
361 let root_norm = normalize_path(&root.to_string_lossy());
362 for prev in &previous {
363 let name = prev["name"].as_str().unwrap_or("");
364 if name.is_empty() || wanted_names.contains(name) {
365 continue;
366 }
367 let Some(old_ip) = prev.get("install-path").and_then(Value::as_str) else {
368 continue; };
370 let old_abs = if crate::pathutil::is_absolute_path(old_ip) {
371 normalize_path(old_ip)
372 } else {
373 normalize_path(&format!("{vendor_composer}/{old_ip}"))
374 };
375 let Some(old_rel) = old_abs
376 .strip_prefix(&format!("{root_norm}/"))
377 .filter(|r| !r.is_empty())
378 else {
379 issues.push(format!(
380 "installed package {name} lives outside the project (`{old_ip}`): not removing it"
381 ));
382 continue;
383 };
384 let expected = place(
385 table,
386 root_extra,
387 name,
388 prev.get("type")
389 .and_then(Value::as_str)
390 .unwrap_or("library"),
391 prev.get("extra"),
392 prev.get("target-dir")
393 .and_then(Value::as_str)
394 .map(|t| t.trim_matches('/'))
395 .filter(|t| !t.is_empty()),
396 );
397 match expected {
398 Ok(rel) if rel == old_rel => {
399 let dir = if rel.starts_with("vendor/") {
402 format!("vendor/{name}")
403 } else {
404 rel
405 };
406 removals.insert(name.to_owned(), dir);
407 }
408 Ok(rel) => issues.push(format!(
409 "installed package {name} is at `{old_rel}` but the current layout puts it at `{rel}`: let Composer handle this removal"
410 )),
411 Err(e) => issues.push(format!("removal of {name}: {e}")),
412 }
413 }
414
415 if issues.is_empty() {
416 Ok(Layout {
417 root,
418 paths,
419 installers_tag,
420 removals,
421 })
422 } else {
423 Err(issues)
424 }
425 }
426
427 pub fn root(&self) -> &Path {
428 &self.root
429 }
430
431 pub fn rel(&self, name: &str) -> Option<&str> {
433 self.paths.get(name).map(String::as_str)
434 }
435
436 pub fn abs(&self, name: &str) -> Option<PathBuf> {
438 self.rel(name).map(|r| self.root.join(r))
439 }
440
441 pub fn package_root(&self, name: &str) -> Option<PathBuf> {
444 let rel = self.rel(name)?;
445 Some(if rel.starts_with("vendor/") {
446 self.root.join("vendor").join(name)
447 } else {
448 self.root.join(rel)
449 })
450 }
451
452 pub fn install_path(&self, name: &str) -> Option<String> {
455 let rel = self.rel(name)?;
456 let root = self.root.to_string_lossy();
457 Some(find_shortest_path(
458 &format!("{root}/vendor/composer"),
459 &format!("{root}/{rel}"),
460 true,
461 ))
462 }
463
464 pub fn removals(&self) -> impl Iterator<Item = (&str, PathBuf)> {
466 self.removals
467 .iter()
468 .map(|(n, rel)| (n.as_str(), self.root.join(rel)))
469 }
470}
471
472fn installed_packages(root: &Path) -> Vec<Value> {
473 let path = root.join("vendor/composer/installed.json");
474 let Ok(text) = std::fs::read_to_string(&path) else {
475 return Vec::new();
476 };
477 let Ok(v) = serde_json::from_str::<Value>(&text) else {
478 return Vec::new();
479 };
480 v.get("packages")
481 .and_then(Value::as_array)
482 .cloned()
483 .unwrap_or_default()
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use serde_json::json;
490
491 fn lock_with(packages: Value) -> Lock {
492 Lock::parse(
493 &json!({"packages": packages, "packages-dev": [], "plugin-api-version": "2.6.0"})
494 .to_string(),
495 )
496 .expect("lock")
497 }
498
499 fn pkg(name: &str, ty: &str, version: &str) -> Value {
500 json!({"name": name, "version": version, "type": ty,
501 "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}})
502 }
503
504 fn root() -> PathBuf {
505 PathBuf::from(if cfg!(windows) { "C:/proj" } else { "/proj" })
508 }
509
510 #[test]
511 fn allow_plugins_patterns_and_merge() {
512 assert!(pattern_matches("composer/*", "composer/installers"));
513 assert!(pattern_matches(
514 "Composer/Installers",
515 "composer/installers"
516 ));
517 assert!(pattern_matches("*", "anything/here"));
518 assert!(!pattern_matches("composer/*", "other/installers"));
519 assert!(pattern_matches("*/installers", "composer/installers"));
520 let rules = json!({"composer/*": false, "composer/installers": true});
521 assert_eq!(
523 plugin_verdict(Some(&rules), "composer/installers"),
524 PluginVerdict::Blocked
525 );
526 assert_eq!(
527 plugin_verdict(Some(&json!(true)), "x/y"),
528 PluginVerdict::Allowed
529 );
530 assert_eq!(
531 plugin_verdict(Some(&json!({})), "x/y"),
532 PluginVerdict::Unlisted
533 );
534 assert_eq!(plugin_verdict(None, "x/y"), PluginVerdict::Unlisted);
535 let merged = merged_allow_plugins(
536 Some(&json!({"a/b": false})),
537 Some(&json!({"a/b": true, "c/d": true})),
538 );
539 assert_eq!(merged, Some(json!({"a/b": false, "c/d": true})));
540 }
541
542 #[test]
543 fn without_plugin_everything_goes_to_vendor() {
544 let lock = lock_with(json!([
545 pkg("a/b", "wordpress-plugin", "1.0.0"),
546 pkg("a/meta", "metapackage", "1.0.0")
547 ]));
548 let manifest =
549 json!({"extra": {"installer-paths": {"web/{$name}": ["type:wordpress-plugin"]}}});
550 let l = Layout::resolve(&root(), &lock, &manifest, true, true).expect("layout");
551 assert_eq!(l.rel("a/b"), Some("vendor/a/b"));
552 assert_eq!(l.rel("a/meta"), None);
553 assert_eq!(l.install_path("a/b").as_deref(), Some("../a/b"));
554 assert!(l.installers_tag.is_none());
555 }
556
557 #[test]
558 fn plugin_allowed_places_packages_and_blocked_keeps_vendor() {
559 let lock = lock_with(json!([
560 pkg("composer/installers", "composer-plugin", "v2.3.0"),
561 pkg("wpackagist-plugin/akismet", "wordpress-plugin", "5.3"),
562 pkg(
563 "wpackagist-theme/twentytwentyfour",
564 "wordpress-theme",
565 "1.0"
566 ),
567 pkg("monolog/monolog", "library", "3.0.0"),
568 pkg("composer/pcre", "library", "3.0.0"),
569 ]));
570 let manifest = json!({
571 "config": {"allow-plugins": {"composer/installers": true}},
572 "extra": {"installer-paths": {"web/app/plugins/{$name}/": ["type:wordpress-plugin"]}}
573 });
574 let l = Layout::resolve(&root(), &lock, &manifest, true, true).expect("layout");
575 assert_eq!(l.installers_tag.as_deref(), Some("v2.3.0"));
576 assert_eq!(
577 l.rel("wpackagist-plugin/akismet"),
578 Some("web/app/plugins/akismet")
579 );
580 assert_eq!(
581 l.rel("wpackagist-theme/twentytwentyfour"),
582 Some("wp-content/themes/twentytwentyfour")
583 );
584 assert_eq!(l.rel("monolog/monolog"), Some("vendor/monolog/monolog"));
585 assert_eq!(
586 l.rel("composer/installers"),
587 Some("vendor/composer/installers")
588 );
589 assert_eq!(
590 l.install_path("wpackagist-plugin/akismet").as_deref(),
591 Some("../../web/app/plugins/akismet")
592 );
593 assert_eq!(l.install_path("composer/pcre").as_deref(), Some("./pcre"));
594 assert_eq!(
595 l.install_path("monolog/monolog").as_deref(),
596 Some("../monolog/monolog")
597 );
598 assert_eq!(
599 l.package_root("wpackagist-plugin/akismet"),
600 Some(root().join("web/app/plugins/akismet"))
601 );
602
603 let blocked = json!({"config": {"allow-plugins": {"composer/installers": false}}});
604 let l = Layout::resolve(&root(), &lock, &blocked, true, true).expect("layout");
605 assert_eq!(
606 l.rel("wpackagist-plugin/akismet"),
607 Some("vendor/wpackagist-plugin/akismet")
608 );
609
610 let unlisted = json!({"config": {"allow-plugins": {"other/x": true}}});
611 let err = Layout::resolve(&root(), &lock, &unlisted, true, true).expect_err("fallback");
612 assert!(err[0].contains("allow-plugins"), "{err:?}");
613
614 let missing = json!({});
615 assert!(Layout::resolve(&root(), &lock, &missing, true, true).is_err());
616 }
617
618 #[test]
619 fn unported_version_custom_framework_and_dangerous_targets_are_issues() {
620 let manifest = json!({"config": {"allow-plugins": true}});
621 let old = lock_with(json!([
622 pkg("composer/installers", "composer-plugin", "v1.12.0"),
623 pkg("a/b", "drupal-module", "1.0")
624 ]));
625 let err = Layout::resolve(&root(), &old, &manifest, true, true).expect_err("v1");
626 assert!(err[0].contains("not a ported version"), "{err:?}");
627
628 let cake = lock_with(json!([
629 pkg("composer/installers", "composer-plugin", "v2.2.0"),
630 pkg("a/b", "cakephp-plugin", "1.0")
631 ]));
632 let err = Layout::resolve(&root(), &cake, &manifest, true, true).expect_err("cake");
633 assert!(err[0].contains("custom path logic"), "{err:?}");
634
635 let lock = lock_with(json!([
636 pkg("composer/installers", "composer-plugin", "v2.3.0"),
637 pkg("a/b", "drupal-module", "1.0"),
638 pkg("a/c", "drupal-module", "1.0")
639 ]));
640 for (paths, needle) in [
641 (json!({"{$name}/../../x": ["a/b"]}), "outside the project"),
642 (json!({"vendor/{$name}": ["a/b"]}), "inside vendor/"),
643 (json!({"/abs/{$name}": ["a/b"]}), "absolute path"),
644 (json!({"same/": ["a/b", "a/c"]}), "would both install"),
645 (json!({"modules/": ["a/b"]}), "would contain"),
646 ] {
647 let m = json!({"config": {"allow-plugins": true}, "extra": {"installer-paths": paths}});
648 let err = Layout::resolve(&root(), &lock, &m, true, true).expect_err(needle);
649 assert!(err.iter().any(|e| e.contains(needle)), "{needle}: {err:?}");
650 }
651 let m =
653 json!({"config": {"allow-plugins": true}, "extra": {"installer-paths": {"": ["a/b"]}}});
654 let err = Layout::resolve(&root(), &lock, &m, true, true).expect_err("root");
655 assert!(err.iter().any(|e| e.contains("project root")), "{err:?}");
656 }
657
658 #[test]
659 fn removals_follow_installed_json_when_paths_agree() {
660 let dir = tempfile::tempdir().expect("tmp");
661 let vc = dir.path().join("vendor/composer");
662 std::fs::create_dir_all(&vc).expect("mkdir");
663 let plugin_entry = json!({"name": "composer/installers", "version": "v2.3.0", "type": "composer-plugin", "install-path": "./installers"});
664 std::fs::write(
665 vc.join("installed.json"),
666 json!({"packages": [
667 plugin_entry,
668 {"name": "gone/lib", "version": "1.0", "type": "library", "install-path": "../gone/lib"},
669 {"name": "gone/legacy", "version": "1.0", "type": "library", "target-dir": "Acme/Legacy", "install-path": "../gone/legacy/Acme/Legacy"},
670 {"name": "gone/plugin", "version": "1.0", "type": "wordpress-plugin", "install-path": "../../wp-content/plugins/plugin"},
671 {"name": "moved/plugin", "version": "1.0", "type": "wordpress-plugin", "install-path": "../../old/plugin"},
672 {"name": "gone/meta", "version": "1.0", "type": "metapackage", "install-path": null}
673 ], "dev": true, "dev-package-names": []})
674 .to_string(),
675 )
676 .expect("write");
677 let lock = lock_with(json!([pkg(
678 "composer/installers",
679 "composer-plugin",
680 "v2.3.0"
681 )]));
682 let manifest = json!({"config": {"allow-plugins": true}});
683 let err = Layout::resolve(dir.path(), &lock, &manifest, true, true).expect_err("moved");
684 assert!(
685 err.iter()
686 .any(|e| e.contains("moved/plugin") && e.contains("old/plugin")),
687 "{err:?}"
688 );
689
690 std::fs::write(
693 vc.join("installed.json"),
694 json!({"packages": [
695 plugin_entry,
696 {"name": "gone/lib", "version": "1.0", "type": "library", "install-path": "../gone/lib"},
697 {"name": "gone/legacy", "version": "1.0", "type": "library", "target-dir": "Acme/Legacy", "install-path": "../gone/legacy/Acme/Legacy"},
698 {"name": "gone/plugin", "version": "1.0", "type": "wordpress-plugin", "install-path": "../../wp-content/plugins/plugin"}
699 ], "dev": true, "dev-package-names": []})
700 .to_string(),
701 )
702 .expect("write");
703 let l = Layout::resolve(dir.path(), &lock, &manifest, true, true).expect("layout");
704 let removals: Vec<(String, PathBuf)> =
705 l.removals().map(|(n, p)| (n.to_owned(), p)).collect();
706 assert_eq!(
707 removals,
708 vec![
709 (
710 "gone/legacy".to_owned(),
711 l.root().join("vendor/gone/legacy")
712 ),
713 ("gone/lib".to_owned(), l.root().join("vendor/gone/lib")),
714 (
715 "gone/plugin".to_owned(),
716 l.root().join("wp-content/plugins/plugin")
717 ),
718 ]
719 );
720 }
721
722 #[test]
723 fn plugin_present_on_one_side_only_is_a_transition_for_composer() {
724 let dir = tempfile::tempdir().expect("tmp");
725 let vc = dir.path().join("vendor/composer");
726 std::fs::create_dir_all(&vc).expect("mkdir");
727 let manifest = json!({"config": {"allow-plugins": true}});
728 let with_plugin = lock_with(json!([
729 pkg("composer/installers", "composer-plugin", "v2.3.0"),
730 pkg("a/wp", "wordpress-plugin", "1.0"),
731 ]));
732 let without_plugin = lock_with(json!([pkg("a/wp", "wordpress-plugin", "1.0")]));
733 let libs_only = lock_with(json!([
734 pkg("composer/installers", "composer-plugin", "v2.3.0"),
735 pkg("a/lib", "library", "1.0"),
736 ]));
737
738 std::fs::write(
740 vc.join("installed.json"),
741 json!({"packages": [{"name": "a/wp", "version": "1.0", "type": "wordpress-plugin", "install-path": "../a/wp"}], "dev": true, "dev-package-names": []}).to_string(),
742 )
743 .expect("write");
744 let err =
745 Layout::resolve(dir.path(), &with_plugin, &manifest, true, true).expect_err("added");
746 assert!(err[0].contains("added to an existing install"), "{err:?}");
747 std::fs::write(
749 vc.join("installed.json"),
750 json!({"packages": [{"name": "a/lib", "version": "1.0", "type": "library", "install-path": "../a/lib"}], "dev": true, "dev-package-names": []}).to_string(),
751 )
752 .expect("write");
753 assert!(Layout::resolve(dir.path(), &libs_only, &manifest, true, true).is_ok());
754
755 std::fs::write(
757 vc.join("installed.json"),
758 json!({"packages": [
759 {"name": "composer/installers", "version": "v2.3.0", "type": "composer-plugin", "install-path": "./installers"},
760 {"name": "a/wp", "version": "1.0", "type": "wordpress-plugin", "install-path": "../../wp-content/plugins/wp"}
761 ], "dev": true, "dev-package-names": []}).to_string(),
762 )
763 .expect("write");
764 let err = Layout::resolve(dir.path(), &without_plugin, &manifest, true, true)
765 .expect_err("removed");
766 assert!(
767 err[0].contains("removed from an existing install"),
768 "{err:?}"
769 );
770
771 let l =
773 Layout::resolve(dir.path(), &with_plugin, &manifest, true, false).expect("no-plugins");
774 assert_eq!(l.rel("a/wp"), Some("vendor/a/wp"));
775 assert!(l.installers_tag.is_none());
776 std::fs::remove_file(vc.join("installed.json")).expect("rm");
778 let l = Layout::resolve(dir.path(), &with_plugin, &manifest, true, true).expect("fresh");
779 assert_eq!(l.rel("a/wp"), Some("wp-content/plugins/wp"));
780 }
781}