Skip to main content

vivacity_core/
layout.rs

1//! Where each package of the lock gets installed: `vendor/<name>[/<target-dir>]`
2//! by LibraryInstaller, or the path composer/installers gives it when that
3//! plugin is locked, allowed (`config.allow-plugins`) and ported
4//! (`installers::table_for`). A single pass, before touching the disk;
5//! anything not reproducible byte for byte becomes an `issue` (falls back to
6//! Composer).
7//!
8//! The path is relative to the project root and normalised (`normalizePath`,
9//! no trailing slash); that is the form Composer normalises before computing
10//! `install-path` (FilesystemRepository::write).
11
12use 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    /// Project root, absolute (as given, not canonicalised: the relative paths
22    /// derived from it do not depend on symlinks).
23    root: PathBuf,
24    /// name -> project-relative path (absent for a metapackage).
25    paths: BTreeMap<String, String>,
26    /// Emulated composer/installers tag, if the plugin is active.
27    pub installers_tag: Option<String>,
28    /// Installed packages (installed.json) to remove: name -> relative
29    /// directory to delete (`vendor/<name>` or the plugin's target), after
30    /// checking that Composer would recompute the same path today.
31    removals: BTreeMap<String, String>,
32}
33
34/// `allow-plugins` verdict for a package, like PluginManager in
35/// non-interactive mode.
36#[derive(Debug, PartialEq, Eq)]
37pub enum PluginVerdict {
38    Allowed,
39    /// Explicitly refused: Composer skips the plugin with a warning.
40    Blocked,
41    /// No rule covers it: Composer stops with an error.
42    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
55/// `BasePackage::packageNameToRegexp`: `{^<quote(pattern) with * -> .*>$}i`.
56fn 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
82/// `Config::merge` for `allow-plugins`: the project value replaces the global
83/// one unless both are objects, in which case
84/// `array_merge($project, $global, $project)`: the project keys first, in
85/// their order, then those only the global config brings. Order matters: the
86/// first matching rule decides.
87pub 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
104/// `PluginManager::parseAllowedPlugins` + `isPluginAllowed` (non-interactive).
105fn 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
125/// Verdict for `package` under the project config merged with the global one.
126pub 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
134/// `config.allow-plugins` from COMPOSER_HOME/config.json.
135pub fn global_allow_plugins() -> Option<Value> {
136    global_config_value("allow-plugins")
137}
138
139/// One `config.<key>` value of COMPOSER_HOME/config.json — the global
140/// layer of `Config::merge`, below the root composer.json.
141pub 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
148/// Project-relative path of a package handled by LibraryInstaller.
149fn 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
156/// Decision for a package (name, type, extra) under the current configuration.
157fn 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    /// Everything in vendor/ (no layout plugin), for tests and code paths
200    /// that have no plugin-aware lock.
201    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    /// The full pass: plugin, allow-plugins, paths, refused targets, and the
217    /// removal plan for installed.json packages that went away.
218    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        // Is the plugin active? Composer loads it from installed.json
232        // (PluginManager::loadInstalledPlugins) and installs it first in the
233        // transaction; vivacity only emulates the states where both views
234        // agree. A plugin present on one side only (added, removed, or in
235        // require-dev with --no-dev) is a transition left to Composer.
236        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 => {} // Composer ignores it: everything in vendor/
296                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        // Conflicting targets: two packages at the same place, or one under the other.
328        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        // Removal plan: Composer recomputes the path of a removed package with
354        // the current configuration; we only delete if that path is the one
355        // where the package was laid out (installed.json), else fallback.
356        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; // metapackage
369            };
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                    // LibraryInstaller::removeCode deletes getPackageBasePath:
400                    // vendor/<name> without the target-dir.
401                    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    /// Project-relative path (None: metapackage or unknown package).
432    pub fn rel(&self, name: &str) -> Option<&str> {
433        self.paths.get(name).map(String::as_str)
434    }
435
436    /// Absolute install path.
437    pub fn abs(&self, name: &str) -> Option<PathBuf> {
438        self.rel(name).map(|r| self.root.join(r))
439    }
440
441    /// Root to empty before laying out the package: `vendor/<name>`
442    /// (target-dir included) for LibraryInstaller, the target itself otherwise.
443    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    /// `install-path` of installed.json / installed.php: relative to
453    /// vendor/composer (`findShortestPath($repoDir, $path, true)`).
454    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    /// installed.json packages to remove, with their absolute path.
465    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        // On Windows `/proj` is not absolute (no drive) and would be
506        // absolutized under the cwd; an explicit drive keeps the test stable.
507        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        // First matching rule: `composer/*` -> refused.
522        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        // The root project: empty template.
652        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        // Without the moved package, the plan is accepted; a target-dir package
691        // is deleted at vendor/<name> (getPackageBasePath), not at the sub-path.
692        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        // Addition: installed.json without the plugin, lock with it, and an affected package.
739        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        // Same addition without a package of a type taken by the plugin: nothing to transition.
748        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        // Removal: installed.json with the plugin and a package outside vendor/, lock without.
756        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        // --no-plugins: Composer ignores the plugin on both sides, everything in vendor/.
772        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        // No on-disk state: the lock decides (fresh install).
777        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}