1use crate::layout::Layout;
11use crate::lock::{DistKind, Lock, LockPackage};
12use serde_json::Value;
13use std::path::Path;
14
15pub const EMULATED_PLUGINS: &[&str] = &[
20 "symfony/runtime",
21 "composer/installers",
22 "pestphp/pest-plugin",
23 "dealerdirect/phpcodesniffer-composer-installer",
24 "phpstan/extension-installer",
25 "rector/extension-installer",
26 "wikimedia/composer-merge-plugin",
27 "composer/package-versions-deprecated",
28 "symfony/flex",
33 "yiisoft/yii2-composer",
34 "codeception/c3",
35 "bamarni/composer-bin-plugin",
36 "roots/wordpress-core-installer",
37 "mnsami/composer-custom-directory-installer",
38];
39
40pub const LICENSE_CHECK_PLUGIN: &str = "metasyntactical/composer-plugin-license-check";
54
55pub fn license_check_configured(root_manifest: &Value) -> bool {
59 let Some(cfg) = root_manifest
60 .get("extra")
61 .and_then(|e| e.get(LICENSE_CHECK_PLUGIN))
62 else {
63 return false;
64 };
65 ["allow-list", "deny-list"].iter().any(|k| {
66 cfg.get(*k).and_then(Value::as_array).is_some_and(|l| {
67 l.iter()
68 .any(|v| !matches!(v, Value::Null | Value::Bool(false)))
69 })
70 })
71}
72
73pub const BENIGN_PLUGINS: &[&str] = &[
74 "php-http/discovery",
75 "drupal/core-project-message",
78 "drupal/core-recipe-unpack",
81 LICENSE_CHECK_PLUGIN,
82 "ibexa/post-install",
85 "symfony/thanks",
90 "ergebnis/composer-normalize",
93];
94
95pub const LAYOUT_PLUGINS: &[&str] = &[
98 "cweagans/composer-patches",
99 "oomphinc/composer-installers-extender",
100];
101
102pub const RESOLUTION_INERT: &[&str] = &[
110 "composer/installers",
111 "symfony/runtime",
112 "pestphp/pest-plugin",
113 "dealerdirect/phpcodesniffer-composer-installer",
114 "phpstan/extension-installer",
115 "rector/extension-installer",
116 "composer/package-versions-deprecated",
117 "ergebnis/composer-normalize",
118 "codeception/c3",
121 "bamarni/composer-bin-plugin",
125 "roots/wordpress-core-installer",
127 "metasyntactical/composer-plugin-license-check",
130 "ibexa/post-install",
131 "php-http/discovery",
135 "drupal/core-project-message",
136 "cweagans/composer-patches",
139 "mlocati/composer-patcher",
140 "oomphinc/composer-installers-extender",
141 "mnsami/composer-custom-directory-installer",
142];
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum ResolutionCommand {
147 Update,
148 Require,
149 Remove,
150}
151
152impl ResolutionCommand {
153 pub fn name(self) -> &'static str {
154 match self {
155 ResolutionCommand::Update => "update",
156 ResolutionCommand::Require => "require",
157 ResolutionCommand::Remove => "remove",
158 }
159 }
160}
161
162pub fn resolution_effect(
167 plugin: &str,
168 command: ResolutionCommand,
169 with_install: bool,
170) -> Option<String> {
171 if RESOLUTION_INERT.contains(&plugin) {
172 return None;
173 }
174 Some(match plugin {
175 "symfony/flex" => {
182 if command == ResolutionCommand::Require {
183 "resolves Flex aliases and applies recipes on require (not emulated)".to_owned()
184 } else if with_install && command == ResolutionCommand::Remove {
185 "applies recipes to the packages it installs (not emulated; --no-install resolves natively)".to_owned()
186 } else {
187 return None;
191 }
192 }
193 "symfony/thanks" => {
201 if command == ResolutionCommand::Update && with_install {
202 "prints a reminder after a package update, after querying GitHub (not emulated; --no-install resolves natively)".to_owned()
203 } else {
204 return None;
205 }
206 }
207 "yiisoft/yii2-composer" => {
211 if with_install {
212 "prints yiisoft/yii2's upgrade notes after an update with install (not emulated; --no-install resolves natively)".to_owned()
213 } else {
214 return None;
215 }
216 }
217 "wikimedia/composer-merge-plugin" => return None,
223 "drupal/core-recipe-unpack" => {
226 if command == ResolutionCommand::Require {
227 "unpacks recipes into composer.json on require (not emulated)".to_owned()
228 } else {
229 return None;
230 }
231 }
232 _ => "is not on vivacity's known-plugin list".to_owned(),
233 })
234}
235
236pub fn resolution_issues(
241 project_dir: &Path,
242 root_manifest: &Value,
243 command: ResolutionCommand,
244 plugins_enabled: bool,
245 with_install: bool,
246) -> Vec<ScopeIssue> {
247 let mut issues = Vec::new();
248 for name in active_plugins(project_dir, root_manifest, plugins_enabled) {
249 if let Some(effect) = resolution_effect(&name, command, with_install) {
250 issues.push(ScopeIssue::ResolutionPlugin(name, effect));
251 }
252 }
253 issues
254}
255
256pub fn active_plugins(
260 project_dir: &Path,
261 root_manifest: &Value,
262 plugins_enabled: bool,
263) -> Vec<String> {
264 if !plugins_enabled {
265 return Vec::new();
266 }
267 let mut names: Vec<String> = Vec::new();
268 let mut collect = |packages: Vec<Value>| {
269 for p in packages {
270 if p.get("type").and_then(Value::as_str) == Some("composer-plugin") {
271 if let Some(n) = p.get("name").and_then(Value::as_str) {
272 if !names.iter().any(|x| x == n) {
273 names.push(n.to_owned());
274 }
275 }
276 }
277 }
278 };
279 collect(crate::layout::installed_packages_of(
280 project_dir,
281 root_manifest,
282 ));
283 if let Some(home) = crate::fetch::composer_home() {
284 collect(crate::layout::installed_packages_at(
285 &home.join("vendor").join("composer"),
286 ));
287 }
288 names.retain(|name| {
289 matches!(
290 crate::layout::plugin_allowed(root_manifest, name),
291 crate::layout::PluginVerdict::Allowed
292 )
293 });
294 names
295}
296
297#[derive(Debug, PartialEq, Eq)]
298pub enum ScopeIssue {
299 UnknownPlugin(String),
301 ResolutionPlugin(String, String),
304 LayoutPlugin(String),
306 Layout(String),
309 NoUsableDist(String),
311 Config(String),
315 MergePlugin(String),
320}
321
322impl std::fmt::Display for ScopeIssue {
323 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324 match self {
325 ScopeIssue::UnknownPlugin(p) => {
326 write!(f, "plugin {p} is not on vivacity's known-plugin list")
327 }
328 ScopeIssue::LayoutPlugin(p) => {
329 write!(f, "plugin {p} changes the install layout (not emulated)")
330 }
331 ScopeIssue::ResolutionPlugin(p, what) => write!(f, "plugin {p} {what}"),
332 ScopeIssue::Layout(why) => write!(f, "{why}"),
333 ScopeIssue::NoUsableDist(p) => {
334 write!(
335 f,
336 "package {p} has no usable dist (no zip or tar dist, no path source)"
337 )
338 }
339 ScopeIssue::Config(why) => write!(f, "config {why} is not supported natively"),
340 ScopeIssue::MergePlugin(why) => write!(f, "wikimedia/composer-merge-plugin: {why}"),
341 }
342 }
343}
344
345#[derive(Debug, Default)]
346pub struct ScopeReport {
347 pub issues: Vec<ScopeIssue>,
349 pub skipped_plugins: Vec<String>,
351 pub layout: Option<Layout>,
353}
354
355impl ScopeReport {
356 pub fn is_native_ok(&self) -> bool {
357 self.issues.is_empty()
358 }
359}
360
361pub fn analyze(
364 project_dir: &Path,
365 lock: &Lock,
366 root_manifest: &Value,
367 with_dev: bool,
368 plugins_enabled: bool,
369) -> ScopeReport {
370 let mut report = ScopeReport::default();
371
372 for p in lock.wanted_packages(with_dev) {
373 classify_package(project_dir, p, plugins_enabled, &mut report);
374 }
375 if plugins_enabled
378 && matches!(
379 crate::layout::plugin_allowed(root_manifest, crate::yii2_composer::PLUGIN_NAME),
380 crate::layout::PluginVerdict::Allowed
381 )
382 && lock
383 .wanted_packages(with_dev)
384 .any(|p| p.name() == crate::yii2_composer::PLUGIN_NAME)
385 && lock
386 .wanted_packages(with_dev)
387 .any(|p| p.name() == crate::yii2_composer::YII2_DEV)
388 {
389 report.issues.push(ScopeIssue::UnknownPlugin(format!(
390 "{} with {} (the Yii.php shims are not emulated); it",
391 crate::yii2_composer::PLUGIN_NAME,
392 crate::yii2_composer::YII2_DEV
393 )));
394 }
395 if plugins_enabled
398 && license_check_configured(root_manifest)
399 && matches!(
400 crate::layout::plugin_allowed(root_manifest, LICENSE_CHECK_PLUGIN),
401 crate::layout::PluginVerdict::Allowed
402 )
403 && lock
404 .wanted_packages(with_dev)
405 .any(|p| p.name() == LICENSE_CHECK_PLUGIN)
406 {
407 report.issues.push(ScopeIssue::UnknownPlugin(format!(
408 "{LICENSE_CHECK_PLUGIN} with an allow-list or deny-list in `extra` (it stops the install on a denied licence); it"
409 )));
410 }
411 if plugins_enabled
415 && matches!(
416 crate::layout::plugin_allowed(root_manifest, crate::bamarni_bin::PLUGIN_NAME),
417 crate::layout::PluginVerdict::Allowed
418 )
419 && lock
420 .wanted_packages(with_dev)
421 .any(|p| p.name() == crate::bamarni_bin::PLUGIN_NAME)
422 {
423 match crate::bamarni_bin::config(root_manifest.get("extra")) {
424 Ok(cfg) if cfg.forward_command => report.issues.push(ScopeIssue::UnknownPlugin(
425 format!(
426 "{} with extra.bamarni-bin.forward-command true (forwards the command to vendor-bin/*, not emulated); it",
427 crate::bamarni_bin::PLUGIN_NAME
428 ),
429 )),
430 Ok(_) => {}
431 Err(msg) => report.issues.push(ScopeIssue::Config(format!(
432 "extra.bamarni-bin ({msg})"
433 ))),
434 }
435 }
436 report.issues.extend(
437 config_issues(root_manifest, lock, with_dev)
438 .into_iter()
439 .map(ScopeIssue::Config),
440 );
441 match Layout::resolve(project_dir, lock, root_manifest, with_dev, plugins_enabled) {
442 Ok(layout) => report.layout = Some(layout),
443 Err(issues) => report
444 .issues
445 .extend(issues.into_iter().map(ScopeIssue::Layout)),
446 }
447 report
448}
449
450pub fn plugin_issues(lock: &Lock, with_dev: bool) -> Vec<ScopeIssue> {
454 let mut report = ScopeReport::default();
455 for p in lock.wanted_packages(with_dev) {
456 classify_plugin(p, &mut report);
457 }
458 report.issues
459}
460
461pub fn effective_preferred_install(
490 root_manifest: &Value,
491 prefer_source: bool,
492 prefer_dist: bool,
493 prefer_install: Option<&str>,
494) -> Result<Option<Value>, String> {
495 if prefer_install.is_none() && !prefer_source && !prefer_dist {
496 return Ok(None);
497 }
498 let config = root_manifest
499 .get("config")
500 .and_then(|c| c.get("preferred-install"))
501 .cloned()
502 .or_else(|| crate::layout::global_config_value("preferred-install"));
503 let mut flag_source = prefer_source;
504 let mut flag_dist = prefer_dist;
505 let mut cleared = false;
506 if let Some(value) = prefer_install {
507 if prefer_source {
508 return Err(
509 "--prefer-source can not be used together with --prefer-install".to_owned(),
510 );
511 }
512 if prefer_dist {
513 return Err("--prefer-dist can not be used together with --prefer-install".to_owned());
514 }
515 match value {
516 "dist" => flag_dist = true,
517 "source" => flag_source = true,
518 "auto" => cleared = true,
519 other => {
520 return Err(format!(
521 "--prefer-install accepts one of \"dist\", \"source\" or \"auto\", got {other}"
522 ))
523 }
524 }
525 }
526 if flag_source {
527 return Ok(Some(Value::String("source".to_owned())));
528 }
529 if flag_dist {
530 return Ok(Some(Value::String("dist".to_owned())));
531 }
532 if cleared {
533 return Ok(Some(match config {
536 Some(Value::Object(m)) => Value::Object(m),
537 _ => Value::String("auto".to_owned()),
538 }));
539 }
540 Ok(None)
541}
542
543fn prefers_source(preference: &Value, name: &str, is_dev: bool) -> bool {
553 match preference {
554 Value::String(s) => match s.as_str() {
555 "dist" => false,
556 "source" => true,
557 _ => is_dev,
558 },
559 Value::Object(map) => {
560 for (pattern, pref) in map {
561 if pattern_matches(pattern, name) {
562 return match pref.as_str() {
563 Some("dist") => false,
564 Some("auto") => is_dev,
565 _ => true,
566 };
567 }
568 }
569 is_dev
570 }
571 _ => is_dev,
572 }
573}
574
575fn pattern_matches(pattern: &str, name: &str) -> bool {
581 let name = name.to_ascii_lowercase();
582 let pieces: Vec<String> = pattern
583 .to_ascii_lowercase()
584 .split('*')
585 .map(str::to_owned)
586 .collect();
587 if pieces.len() == 1 {
588 return name == pieces[0];
589 }
590 let first = &pieces[0];
591 let last = &pieces[pieces.len() - 1];
592 if !name.starts_with(first.as_str()) || !name.ends_with(last.as_str()) {
593 return false;
594 }
595 if name.len() < first.len() + last.len() {
597 return false;
598 }
599 let mut rest = &name[first.len()..name.len() - last.len()];
600 for piece in &pieces[1..pieces.len() - 1] {
601 match rest.find(piece.as_str()) {
602 Some(pos) => rest = &rest[pos + piece.len()..],
603 None => return false,
604 }
605 }
606 true
607}
608
609fn is_dev_version(version: &str) -> bool {
611 version.starts_with("dev-") || version.ends_with("-dev")
612}
613
614pub fn config_issues(root_manifest: &Value, lock: &Lock, with_dev: bool) -> Vec<String> {
615 let value = |key: &str| -> Option<Value> {
616 root_manifest
617 .get("config")
618 .and_then(|c| c.get(key))
619 .cloned()
620 .or_else(|| crate::layout::global_config_value(key))
621 };
622 let mut out = Vec::new();
623 if let Some(v) = value("preferred-install") {
624 if lock
625 .wanted_packages(with_dev)
626 .any(|p| prefers_source(&v, p.name(), is_dev_version(p.version())))
627 {
628 out.push(format!("preferred-install {v}"));
629 }
630 }
631 out
632}
633
634fn classify_package(
635 project_dir: &Path,
636 p: &LockPackage,
637 plugins_enabled: bool,
638 report: &mut ScopeReport,
639) {
640 if plugins_enabled {
644 classify_plugin(p, report);
645 }
646 if p.is_metapackage() {
647 return;
648 }
649 let usable = match p.dist_kind() {
650 DistKind::Zip | DistKind::Tar => true,
651 DistKind::Path => cfg!(unix) && p.dist_url().is_some_and(|u| project_dir.join(u).is_dir()),
655 DistKind::Other | DistKind::Missing => false,
656 };
657 if !usable {
658 report
659 .issues
660 .push(ScopeIssue::NoUsableDist(p.name().to_owned()));
661 }
662}
663
664fn classify_plugin(p: &LockPackage, report: &mut ScopeReport) {
665 if p.package_type() != "composer-plugin" {
666 return;
667 }
668 let name = p.name().to_owned();
669 if EMULATED_PLUGINS.contains(&name.as_str()) {
670 } else if BENIGN_PLUGINS.contains(&name.as_str()) {
672 report.skipped_plugins.push(name);
673 } else if LAYOUT_PLUGINS.contains(&name.as_str()) {
674 report.issues.push(ScopeIssue::LayoutPlugin(name));
675 } else {
676 report.issues.push(ScopeIssue::UnknownPlugin(name));
677 }
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683 use crate::lock::Lock;
684 use serde_json::json;
685
686 fn lock_with(packages: serde_json::Value) -> Lock {
687 Lock::parse(&json!({ "packages": packages, "packages-dev": [] }).to_string()).expect("lock")
688 }
689
690 fn zip_pkg(name: &str, r#type: &str) -> serde_json::Value {
691 json!({"name": name, "version": "1.0.0", "type": r#type,
692 "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}})
693 }
694
695 fn proj() -> std::path::PathBuf {
696 std::path::PathBuf::from("/nonexistent-vivacity-scope")
697 }
698
699 #[test]
700 fn preferred_install_auto_hands_over_for_a_dev_package() {
701 let dev = json!({"name": "a/b", "version": "dev-main", "type": "library",
707 "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}});
708 for preference in [
709 json!("auto"),
710 json!("source"),
711 json!({"*": "auto"}),
712 json!({"a/*": "auto"}),
713 json!({"other/*": "dist"}),
715 ] {
716 let lock = lock_with(json!([dev.clone()]));
717 let manifest = json!({"config": {"preferred-install": preference}});
718 assert!(
719 !analyze(&proj(), &lock, &manifest, true, true).is_native_ok(),
720 "{preference} with a dev package must hand over"
721 );
722 let stable = lock_with(json!([zip_pkg("a/b", "library")]));
724 let native = analyze(&proj(), &stable, &manifest, true, true).is_native_ok();
725 assert_eq!(
726 native,
727 preference != json!("source"),
728 "{preference} with a stable package"
729 );
730 }
731 }
732
733 #[test]
734 fn install_preference_patterns_match_like_pcre() {
735 for (pattern, name, want) in [
737 ("a/b", "a/b", true),
738 ("a/b", "a/bc", false),
739 ("A/B", "a/b", true),
740 ("*", "anything/at-all", true),
741 ("acme/*", "acme/thing", true),
742 ("acme/*", "acmex/thing", false),
743 ("*/thing", "acme/thing", true),
744 ("a*b", "a/xb", true),
745 ("a*b", "ab", true),
747 ("a**b", "a/b", true),
748 ("a.b/c", "axb/c", false),
750 ("a.b/c", "a.b/c", true),
751 ("acme/*-bundle", "acme/my-bundle", true),
752 ("acme/*-bundle", "acme/bundle", false),
753 ] {
754 assert_eq!(
755 pattern_matches(pattern, name),
756 want,
757 "pattern {pattern:?} against {name:?}"
758 );
759 }
760 }
761
762 #[test]
763 fn plain_library_is_native() {
764 let lock = lock_with(json!([zip_pkg("a/b", "library")]));
765 let r = analyze(&proj(), &lock, &json!({}), true, true);
766 assert!(r.is_native_ok());
767 assert!(r.skipped_plugins.is_empty());
768 assert_eq!(r.layout.expect("layout").rel("a/b"), Some("vendor/a/b"));
769 }
770
771 #[test]
772 fn emulated_and_benign_plugins_stay_native() {
773 let lock = lock_with(json!([
774 zip_pkg("symfony/runtime", "composer-plugin"),
775 zip_pkg("symfony/flex", "composer-plugin"),
776 ]));
777 let r = analyze(&proj(), &lock, &json!({}), true, true);
778 assert!(r.is_native_ok());
779 assert!(r.skipped_plugins.is_empty(), "{:?}", r.skipped_plugins);
782 }
783
784 #[test]
785 fn thanks_and_normalize_are_benign_at_install() {
786 let lock = lock_with(json!([
787 zip_pkg("symfony/thanks", "composer-plugin"),
788 zip_pkg("ergebnis/composer-normalize", "composer-plugin"),
789 ]));
790 let r = analyze(&proj(), &lock, &json!({}), true, true);
791 assert!(r.is_native_ok(), "{:?}", r.issues);
792 assert_eq!(
793 r.skipped_plugins,
794 vec!["symfony/thanks", "ergebnis/composer-normalize"]
795 );
796 }
797
798 #[test]
799 fn thanks_reminder_only_on_update_with_install() {
800 use ResolutionCommand::*;
801 let effect = |c, i| resolution_effect("symfony/thanks", c, i);
802 assert!(effect(Update, true).is_some());
803 assert!(effect(Update, false).is_none());
804 assert!(effect(Require, true).is_none());
805 assert!(effect(Remove, true).is_none());
806 assert!(resolution_effect("ergebnis/composer-normalize", Update, true).is_none());
807 }
808
809 #[test]
810 fn yii2_composer_is_emulated_except_with_yii2_dev() {
811 let allow = json!({"config": {"allow-plugins": {"yiisoft/yii2-composer": true}}});
812 let lock = lock_with(json!([
813 zip_pkg("yiisoft/yii2-composer", "composer-plugin"),
814 zip_pkg("yiisoft/yii2-gii", "yii2-extension"),
815 ]));
816 assert!(analyze(&proj(), &lock, &allow, true, true).is_native_ok());
817 let lock = lock_with(json!([
818 zip_pkg("yiisoft/yii2-composer", "composer-plugin"),
819 zip_pkg("yiisoft/yii2-dev", "yii2-extension"),
820 ]));
821 let r = analyze(&proj(), &lock, &allow, true, true);
822 assert!(!r.is_native_ok());
823 assert!(
824 r.issues[0].to_string().contains("yiisoft/yii2-dev"),
825 "{}",
826 r.issues[0]
827 );
828 assert!(analyze(&proj(), &lock, &allow, true, false).is_native_ok());
830 }
831
832 #[test]
833 fn license_check_native_unless_configured() {
834 let lock = lock_with(json!([zip_pkg(
835 "metasyntactical/composer-plugin-license-check",
836 "composer-plugin"
837 )]));
838 let manifest = |extra: serde_json::Value| json!({"config": {"allow-plugins": {"metasyntactical/composer-plugin-license-check": true}}, "extra": extra});
839 for extra in [
841 json!({}),
842 json!({"metasyntactical/composer-plugin-license-check": {}}),
843 json!({"metasyntactical/composer-plugin-license-check": {"allow-list": []}}),
844 json!({"metasyntactical/composer-plugin-license-check": {"allowed-packages": {"a/b": "why"}}}),
845 ] {
846 let r = analyze(&proj(), &lock, &manifest(extra.clone()), true, true);
847 assert!(
848 r.is_native_ok(),
849 "{extra} should stay native: {:?}",
850 r.issues
851 );
852 }
853 for key in ["allow-list", "deny-list"] {
854 let extra = json!({"metasyntactical/composer-plugin-license-check": {key: ["MIT"]}});
855 let r = analyze(&proj(), &lock, &manifest(extra), true, true);
856 assert!(
857 r.issues[0].to_string().contains("allow-list or deny-list"),
858 "{key}: {:?}",
859 r.issues
860 );
861 }
862 }
863
864 #[test]
865 fn bamarni_bin_native_unless_it_forwards() {
866 let lock = lock_with(json!([zip_pkg(
867 "bamarni/composer-bin-plugin",
868 "composer-plugin"
869 )]));
870 let allow = |extra: serde_json::Value| json!({"config": {"allow-plugins": {"bamarni/composer-bin-plugin": true}}, "extra": extra});
871 assert!(analyze(&proj(), &lock, &allow(json!({})), true, true).is_native_ok());
872 let r = analyze(
873 &proj(),
874 &lock,
875 &allow(json!({"bamarni-bin": {"forward-command": true}})),
876 true,
877 true,
878 );
879 assert!(
880 r.issues[0].to_string().contains("forward-command true"),
881 "{}",
882 r.issues[0]
883 );
884 let r = analyze(
885 &proj(),
886 &lock,
887 &allow(json!({"bamarni-bin": {"bin-links": "no"}})),
888 true,
889 true,
890 );
891 assert!(
892 r.issues[0].to_string().contains("boolean value"),
893 "{}",
894 r.issues[0]
895 );
896 }
897
898 #[test]
899 fn no_plugins_makes_every_plugin_a_plain_library() {
900 let lock = lock_with(json!([
901 zip_pkg("acme/mystery-plugin", "composer-plugin"),
902 zip_pkg("cweagans/composer-patches", "composer-plugin"),
903 zip_pkg("symfony/flex", "composer-plugin"),
904 ]));
905 let r = analyze(&proj(), &lock, &json!({}), true, false);
906 assert!(r.is_native_ok(), "{:?}", r.issues);
907 assert!(r.skipped_plugins.is_empty());
908 assert_eq!(
909 r.layout.expect("layout").rel("acme/mystery-plugin"),
910 Some("vendor/acme/mystery-plugin")
911 );
912 }
913
914 #[test]
915 fn unknown_or_layout_plugin_is_out_of_scope() {
916 let lock = lock_with(json!([
917 zip_pkg("acme/mystery-plugin", "composer-plugin"),
918 zip_pkg("cweagans/composer-patches", "composer-plugin"),
919 ]));
920 let r = analyze(&proj(), &lock, &json!({}), true, true);
921 assert_eq!(
922 r.issues,
923 vec![
924 ScopeIssue::UnknownPlugin("acme/mystery-plugin".into()),
925 ScopeIssue::LayoutPlugin("cweagans/composer-patches".into()),
926 ]
927 );
928 }
929
930 #[test]
931 fn installers_without_allow_plugins_and_sourceless_dist_are_out_of_scope() {
932 let lock = lock_with(json!([
933 {"name": "a/src-only", "version": "1.0.0", "type": "library",
934 "source": {"type": "git", "url": "https://g/x.git", "reference": "r"}},
935 {"name": "a/meta", "version": "1.0.0", "type": "metapackage"},
936 zip_pkg("composer/installers", "composer-plugin"),
937 ]));
938 let manifest = json!({"extra": {"installer-paths": {"web/modules/{$name}": []}}});
941 let r = analyze(&proj(), &lock, &manifest, true, true);
942 assert_eq!(r.issues.len(), 2, "{:?}", r.issues);
943 assert_eq!(r.issues[0], ScopeIssue::NoUsableDist("a/src-only".into()));
944 assert!(matches!(&r.issues[1], ScopeIssue::Layout(m) if m.contains("allow-plugins")));
945 }
946
947 #[test]
948 fn path_package_is_native_when_its_source_exists() {
949 let tmp = tempfile::tempdir().expect("tmp");
950 std::fs::create_dir_all(tmp.path().join("packages/here")).expect("mkdir");
951 let lock = lock_with(json!([
952 {"name": "a/here", "version": "dev-main", "type": "library",
953 "dist": {"type": "path", "url": "packages/here", "reference": "r"}},
954 {"name": "a/gone", "version": "dev-main", "type": "library",
955 "dist": {"type": "path", "url": "packages/gone", "reference": "r"}},
956 ]));
957 let r = analyze(tmp.path(), &lock, &json!({}), true, true);
958 let expected = if cfg!(unix) {
959 vec![ScopeIssue::NoUsableDist("a/gone".into())]
960 } else {
961 vec![
962 ScopeIssue::NoUsableDist("a/here".into()),
963 ScopeIssue::NoUsableDist("a/gone".into()),
964 ]
965 };
966 assert_eq!(r.issues, expected);
967 }
968
969 #[test]
970 fn unread_config_keys_are_scope_issues() {
971 let lock = lock_with(json!([zip_pkg("acme/thing", "library")]));
975 let manifest = json!({"config": {"vendor-dir": "lib/", "bin-dir": "vendor/bin",
976 "preferred-install": {"acme/*": "source", "*": "dist"}}});
977 let r = analyze(&proj(), &lock, &manifest, true, true);
978 assert_eq!(
979 r.issues,
980 vec![ScopeIssue::Config(
981 "preferred-install {\"acme/*\":\"source\",\"*\":\"dist\"}".into()
982 ),]
983 );
984 let lock = lock_with(json!([zip_pkg("a/b", "library")]));
987 let manifest = json!({"config": {"vendor-dir": "vendor",
988 "preferred-install": {"acme/*": "source", "*": "dist"}}});
989 assert!(analyze(&proj(), &lock, &manifest, true, true).is_native_ok());
990 let manifest = json!({"config": {"vendor-dir": "vendor", "preferred-install": "auto"}});
992 assert!(analyze(&proj(), &lock, &manifest, true, true).is_native_ok());
993 }
994
995 #[test]
996 fn no_dev_skips_dev_packages() {
997 let lock = Lock::parse(
998 &json!({
999 "packages": [zip_pkg("a/b", "library")],
1000 "packages-dev": [zip_pkg("acme/mystery-plugin", "composer-plugin")]
1001 })
1002 .to_string(),
1003 )
1004 .expect("lock");
1005 assert!(analyze(&proj(), &lock, &json!({}), false, true).is_native_ok());
1006 assert!(!analyze(&proj(), &lock, &json!({}), true, true).is_native_ok());
1007 }
1008}