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