1use crate::layout::Layout;
11use crate::lock::{DistKind, Lock, LockPackage};
12use serde_json::Value;
13use std::path::Path;
14
15pub const EMULATED_PLUGINS: &[&str] = &[
22 "symfony/runtime",
23 "composer/installers",
24 "pestphp/pest-plugin",
25 "dealerdirect/phpcodesniffer-composer-installer",
26 "phpstan/extension-installer",
27 "rector/extension-installer",
28 "wikimedia/composer-merge-plugin",
29 "composer/package-versions-deprecated",
30 "symfony/flex",
41 "yiisoft/yii2-composer",
42 "codeception/c3",
43 "bamarni/composer-bin-plugin",
44 "roots/wordpress-core-installer",
45 "mnsami/composer-custom-directory-installer",
46];
47
48pub const LICENSE_CHECK_PLUGIN: &str = "metasyntactical/composer-plugin-license-check";
62
63pub fn license_check_configured(root_manifest: &Value) -> bool {
67 let Some(cfg) = root_manifest
68 .get("extra")
69 .and_then(|e| e.get(LICENSE_CHECK_PLUGIN))
70 else {
71 return false;
72 };
73 ["allow-list", "deny-list"].iter().any(|k| {
74 cfg.get(*k).and_then(Value::as_array).is_some_and(|l| {
75 l.iter()
76 .any(|v| !matches!(v, Value::Null | Value::Bool(false)))
77 })
78 })
79}
80
81pub const BENIGN_PLUGINS: &[&str] = &[
82 "php-http/discovery",
83 "drupal/core-project-message",
86 "drupal/core-recipe-unpack",
89 LICENSE_CHECK_PLUGIN,
90 "ibexa/post-install",
93 "symfony/thanks",
98 "ergebnis/composer-normalize",
101];
102
103pub const LAYOUT_PLUGINS: &[&str] = &[
106 "cweagans/composer-patches",
107 "oomphinc/composer-installers-extender",
108];
109
110pub const RESOLUTION_INERT: &[&str] = &[
118 "composer/installers",
119 "symfony/runtime",
120 "pestphp/pest-plugin",
121 "dealerdirect/phpcodesniffer-composer-installer",
122 "phpstan/extension-installer",
123 "rector/extension-installer",
124 "composer/package-versions-deprecated",
125 "ergebnis/composer-normalize",
126 "codeception/c3",
129 "bamarni/composer-bin-plugin",
133 "roots/wordpress-core-installer",
135 "metasyntactical/composer-plugin-license-check",
138 "ibexa/post-install",
139 "php-http/discovery",
143 "drupal/core-project-message",
144 "cweagans/composer-patches",
147 "mlocati/composer-patcher",
148 "oomphinc/composer-installers-extender",
149 "mnsami/composer-custom-directory-installer",
150];
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum ResolutionCommand {
155 Update,
156 Require,
157 Remove,
158}
159
160impl ResolutionCommand {
161 pub fn name(self) -> &'static str {
162 match self {
163 ResolutionCommand::Update => "update",
164 ResolutionCommand::Require => "require",
165 ResolutionCommand::Remove => "remove",
166 }
167 }
168}
169
170pub fn resolution_effect(
175 plugin: &str,
176 command: ResolutionCommand,
177 with_install: bool,
178) -> Option<String> {
179 if RESOLUTION_INERT.contains(&plugin) {
180 return None;
181 }
182 Some(match plugin {
183 "symfony/flex" => {
190 if command == ResolutionCommand::Require {
191 "resolves Flex aliases and applies recipes on require (not emulated)".to_owned()
192 } else if with_install && command == ResolutionCommand::Remove {
193 "applies recipes to the packages it installs (not emulated; --no-install resolves natively)".to_owned()
194 } else {
195 return None;
200 }
201 }
202 "symfony/thanks" => {
210 if command == ResolutionCommand::Update && with_install {
211 "prints a reminder after a package update, after querying GitHub (not emulated; --no-install resolves natively)".to_owned()
212 } else {
213 return None;
214 }
215 }
216 "yiisoft/yii2-composer" => {
220 if with_install {
221 "prints yiisoft/yii2's upgrade notes after an update with install (not emulated; --no-install resolves natively)".to_owned()
222 } else {
223 return None;
224 }
225 }
226 "wikimedia/composer-merge-plugin" => return None,
232 "drupal/core-recipe-unpack" => {
235 if command == ResolutionCommand::Require {
236 "unpacks recipes into composer.json on require (not emulated)".to_owned()
237 } else {
238 return None;
239 }
240 }
241 _ => "is not on vivacity's known-plugin list".to_owned(),
242 })
243}
244
245pub fn resolution_issues(
250 project_dir: &Path,
251 root_manifest: &Value,
252 command: ResolutionCommand,
253 plugins_enabled: bool,
254 with_install: bool,
255) -> Vec<ScopeIssue> {
256 let mut issues = Vec::new();
257 for name in active_plugins(project_dir, root_manifest, plugins_enabled) {
258 if let Some(effect) = resolution_effect(&name, command, with_install) {
259 issues.push(ScopeIssue::ResolutionPlugin(name, effect));
260 }
261 }
262 issues
263}
264
265pub fn active_plugins(
269 project_dir: &Path,
270 root_manifest: &Value,
271 plugins_enabled: bool,
272) -> Vec<String> {
273 if !plugins_enabled {
274 return Vec::new();
275 }
276 let mut names: Vec<String> = Vec::new();
277 let mut collect = |packages: Vec<Value>| {
278 for p in packages {
279 if p.get("type").and_then(Value::as_str) == Some("composer-plugin") {
280 if let Some(n) = p.get("name").and_then(Value::as_str) {
281 if !names.iter().any(|x| x == n) {
282 names.push(n.to_owned());
283 }
284 }
285 }
286 }
287 };
288 collect(crate::layout::installed_packages_of(
289 project_dir,
290 root_manifest,
291 ));
292 if let Some(home) = crate::fetch::composer_home() {
293 collect(crate::layout::installed_packages_at(
294 &home.join("vendor").join("composer"),
295 ));
296 }
297 names.retain(|name| {
298 matches!(
299 crate::layout::plugin_allowed(root_manifest, name),
300 crate::layout::PluginVerdict::Allowed
301 )
302 });
303 names
304}
305
306#[derive(Debug, PartialEq, Eq)]
307pub enum ScopeIssue {
308 UnknownPlugin(String),
310 ResolutionPlugin(String, String),
313 LayoutPlugin(String),
315 Layout(String),
318 NoUsableDist(String),
320 Config(String),
324 MergePlugin(String),
329}
330
331impl std::fmt::Display for ScopeIssue {
332 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333 match self {
334 ScopeIssue::UnknownPlugin(p) => {
335 write!(f, "plugin {p} is not on vivacity's known-plugin list")
336 }
337 ScopeIssue::LayoutPlugin(p) => {
338 write!(f, "plugin {p} changes the install layout (not emulated)")
339 }
340 ScopeIssue::ResolutionPlugin(p, what) => write!(f, "plugin {p} {what}"),
341 ScopeIssue::Layout(why) => write!(f, "{why}"),
342 ScopeIssue::NoUsableDist(p) => {
343 write!(
344 f,
345 "package {p} has no usable dist (no zip or tar dist, no path source)"
346 )
347 }
348 ScopeIssue::Config(why) => write!(f, "config {why} is not supported natively"),
349 ScopeIssue::MergePlugin(why) => write!(f, "wikimedia/composer-merge-plugin: {why}"),
350 }
351 }
352}
353
354#[derive(Debug, Default)]
355pub struct ScopeReport {
356 pub issues: Vec<ScopeIssue>,
358 pub skipped_plugins: Vec<String>,
360 pub layout: Option<Layout>,
362}
363
364impl ScopeReport {
365 pub fn is_native_ok(&self) -> bool {
366 self.issues.is_empty()
367 }
368}
369
370pub fn analyze(
373 project_dir: &Path,
374 lock: &Lock,
375 root_manifest: &Value,
376 with_dev: bool,
377 plugins_enabled: bool,
378) -> ScopeReport {
379 let mut report = ScopeReport::default();
380
381 for p in lock.wanted_packages(with_dev) {
382 classify_package(project_dir, p, plugins_enabled, &mut report);
383 }
384 if plugins_enabled
387 && matches!(
388 crate::layout::plugin_allowed(root_manifest, crate::yii2_composer::PLUGIN_NAME),
389 crate::layout::PluginVerdict::Allowed
390 )
391 && lock
392 .wanted_packages(with_dev)
393 .any(|p| p.name() == crate::yii2_composer::PLUGIN_NAME)
394 && lock
395 .wanted_packages(with_dev)
396 .any(|p| p.name() == crate::yii2_composer::YII2_DEV)
397 {
398 report.issues.push(ScopeIssue::UnknownPlugin(format!(
399 "{} with {} (the Yii.php shims are not emulated); it",
400 crate::yii2_composer::PLUGIN_NAME,
401 crate::yii2_composer::YII2_DEV
402 )));
403 }
404 if plugins_enabled
407 && license_check_configured(root_manifest)
408 && matches!(
409 crate::layout::plugin_allowed(root_manifest, LICENSE_CHECK_PLUGIN),
410 crate::layout::PluginVerdict::Allowed
411 )
412 && lock
413 .wanted_packages(with_dev)
414 .any(|p| p.name() == LICENSE_CHECK_PLUGIN)
415 {
416 report.issues.push(ScopeIssue::UnknownPlugin(format!(
417 "{LICENSE_CHECK_PLUGIN} with an allow-list or deny-list in `extra` (it stops the install on a denied licence); it"
418 )));
419 }
420 if plugins_enabled
424 && matches!(
425 crate::layout::plugin_allowed(root_manifest, crate::bamarni_bin::PLUGIN_NAME),
426 crate::layout::PluginVerdict::Allowed
427 )
428 && lock
429 .wanted_packages(with_dev)
430 .any(|p| p.name() == crate::bamarni_bin::PLUGIN_NAME)
431 {
432 match crate::bamarni_bin::config(root_manifest.get("extra")) {
433 Ok(cfg) if cfg.forward_command => report.issues.push(ScopeIssue::UnknownPlugin(
434 format!(
435 "{} with extra.bamarni-bin.forward-command true (forwards the command to vendor-bin/*, not emulated); it",
436 crate::bamarni_bin::PLUGIN_NAME
437 ),
438 )),
439 Ok(_) => {}
440 Err(msg) => report.issues.push(ScopeIssue::Config(format!(
441 "extra.bamarni-bin ({msg})"
442 ))),
443 }
444 }
445 report.issues.extend(
446 config_issues(root_manifest, lock, with_dev)
447 .into_iter()
448 .map(ScopeIssue::Config),
449 );
450 match Layout::resolve(project_dir, lock, root_manifest, with_dev, plugins_enabled) {
451 Ok(layout) => report.layout = Some(layout),
452 Err(issues) => report
453 .issues
454 .extend(issues.into_iter().map(ScopeIssue::Layout)),
455 }
456 report
457}
458
459pub fn plugin_issues(lock: &Lock, with_dev: bool) -> Vec<ScopeIssue> {
463 let mut report = ScopeReport::default();
464 for p in lock.wanted_packages(with_dev) {
465 classify_plugin(p, &mut report);
466 }
467 report.issues
468}
469
470pub fn effective_preferred_install(
499 root_manifest: &Value,
500 prefer_source: bool,
501 prefer_dist: bool,
502 prefer_install: Option<&str>,
503) -> Result<Option<Value>, String> {
504 if prefer_install.is_none() && !prefer_source && !prefer_dist {
505 return Ok(None);
506 }
507 let config = root_manifest
508 .get("config")
509 .and_then(|c| c.get("preferred-install"))
510 .cloned()
511 .or_else(|| crate::layout::global_config_value("preferred-install"));
512 let mut flag_source = prefer_source;
513 let mut flag_dist = prefer_dist;
514 let mut cleared = false;
515 if let Some(value) = prefer_install {
516 if prefer_source {
517 return Err(
518 "--prefer-source can not be used together with --prefer-install".to_owned(),
519 );
520 }
521 if prefer_dist {
522 return Err("--prefer-dist can not be used together with --prefer-install".to_owned());
523 }
524 match value {
525 "dist" => flag_dist = true,
526 "source" => flag_source = true,
527 "auto" => cleared = true,
528 other => {
529 return Err(format!(
530 "--prefer-install accepts one of \"dist\", \"source\" or \"auto\", got {other}"
531 ))
532 }
533 }
534 }
535 if flag_source {
536 return Ok(Some(Value::String("source".to_owned())));
537 }
538 if flag_dist {
539 return Ok(Some(Value::String("dist".to_owned())));
540 }
541 if cleared {
542 return Ok(Some(match config {
545 Some(Value::Object(m)) => Value::Object(m),
546 _ => Value::String("auto".to_owned()),
547 }));
548 }
549 Ok(None)
550}
551
552fn prefers_source(preference: &Value, name: &str, is_dev: bool) -> bool {
562 match preference {
563 Value::String(s) => match s.as_str() {
564 "dist" => false,
565 "source" => true,
566 _ => is_dev,
567 },
568 Value::Object(map) => {
569 for (pattern, pref) in map {
570 if pattern_matches(pattern, name) {
571 return match pref.as_str() {
572 Some("dist") => false,
573 Some("auto") => is_dev,
574 _ => true,
575 };
576 }
577 }
578 is_dev
579 }
580 _ => is_dev,
581 }
582}
583
584fn pattern_matches(pattern: &str, name: &str) -> bool {
590 let name = name.to_ascii_lowercase();
591 let pieces: Vec<String> = pattern
592 .to_ascii_lowercase()
593 .split('*')
594 .map(str::to_owned)
595 .collect();
596 if pieces.len() == 1 {
597 return name == pieces[0];
598 }
599 let first = &pieces[0];
600 let last = &pieces[pieces.len() - 1];
601 if !name.starts_with(first.as_str()) || !name.ends_with(last.as_str()) {
602 return false;
603 }
604 if name.len() < first.len() + last.len() {
606 return false;
607 }
608 let mut rest = &name[first.len()..name.len() - last.len()];
609 for piece in &pieces[1..pieces.len() - 1] {
610 match rest.find(piece.as_str()) {
611 Some(pos) => rest = &rest[pos + piece.len()..],
612 None => return false,
613 }
614 }
615 true
616}
617
618fn is_dev_version(version: &str) -> bool {
620 version.starts_with("dev-") || version.ends_with("-dev")
621}
622
623pub fn config_issues(root_manifest: &Value, lock: &Lock, with_dev: bool) -> Vec<String> {
624 let value = |key: &str| -> Option<Value> {
625 root_manifest
626 .get("config")
627 .and_then(|c| c.get(key))
628 .cloned()
629 .or_else(|| crate::layout::global_config_value(key))
630 };
631 let mut out = Vec::new();
632 if let Some(v) = value("preferred-install") {
633 if lock
634 .wanted_packages(with_dev)
635 .any(|p| prefers_source(&v, p.name(), is_dev_version(p.version())))
636 {
637 out.push(format!("preferred-install {v}"));
638 }
639 }
640 out
641}
642
643fn classify_package(
644 project_dir: &Path,
645 p: &LockPackage,
646 plugins_enabled: bool,
647 report: &mut ScopeReport,
648) {
649 if plugins_enabled {
653 classify_plugin(p, report);
654 }
655 if p.is_metapackage() {
656 return;
657 }
658 let usable = match p.dist_kind() {
659 DistKind::Zip | DistKind::Tar => true,
660 DistKind::Path => cfg!(unix) && p.dist_url().is_some_and(|u| project_dir.join(u).is_dir()),
664 DistKind::Other | DistKind::Missing => false,
665 };
666 if !usable {
667 report
668 .issues
669 .push(ScopeIssue::NoUsableDist(p.name().to_owned()));
670 }
671}
672
673fn classify_plugin(p: &LockPackage, report: &mut ScopeReport) {
674 if p.package_type() != "composer-plugin" {
675 return;
676 }
677 let name = p.name().to_owned();
678 if EMULATED_PLUGINS.contains(&name.as_str()) {
679 } else if BENIGN_PLUGINS.contains(&name.as_str()) {
681 report.skipped_plugins.push(name);
682 } else if LAYOUT_PLUGINS.contains(&name.as_str()) {
683 report.issues.push(ScopeIssue::LayoutPlugin(name));
684 } else {
685 report.issues.push(ScopeIssue::UnknownPlugin(name));
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692 use crate::lock::Lock;
693 use serde_json::json;
694
695 fn lock_with(packages: serde_json::Value) -> Lock {
696 Lock::parse(&json!({ "packages": packages, "packages-dev": [] }).to_string()).expect("lock")
697 }
698
699 fn zip_pkg(name: &str, r#type: &str) -> serde_json::Value {
700 json!({"name": name, "version": "1.0.0", "type": r#type,
701 "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}})
702 }
703
704 fn proj() -> std::path::PathBuf {
705 std::path::PathBuf::from("/nonexistent-vivacity-scope")
706 }
707
708 #[test]
709 fn preferred_install_auto_hands_over_for_a_dev_package() {
710 let dev = json!({"name": "a/b", "version": "dev-main", "type": "library",
716 "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}});
717 for preference in [
718 json!("auto"),
719 json!("source"),
720 json!({"*": "auto"}),
721 json!({"a/*": "auto"}),
722 json!({"other/*": "dist"}),
724 ] {
725 let lock = lock_with(json!([dev.clone()]));
726 let manifest = json!({"config": {"preferred-install": preference}});
727 assert!(
728 !analyze(&proj(), &lock, &manifest, true, true).is_native_ok(),
729 "{preference} with a dev package must hand over"
730 );
731 let stable = lock_with(json!([zip_pkg("a/b", "library")]));
733 let native = analyze(&proj(), &stable, &manifest, true, true).is_native_ok();
734 assert_eq!(
735 native,
736 preference != json!("source"),
737 "{preference} with a stable package"
738 );
739 }
740 }
741
742 #[test]
743 fn install_preference_patterns_match_like_pcre() {
744 for (pattern, name, want) in [
746 ("a/b", "a/b", true),
747 ("a/b", "a/bc", false),
748 ("A/B", "a/b", true),
749 ("*", "anything/at-all", true),
750 ("acme/*", "acme/thing", true),
751 ("acme/*", "acmex/thing", false),
752 ("*/thing", "acme/thing", true),
753 ("a*b", "a/xb", true),
754 ("a*b", "ab", true),
756 ("a**b", "a/b", true),
757 ("a.b/c", "axb/c", false),
759 ("a.b/c", "a.b/c", true),
760 ("acme/*-bundle", "acme/my-bundle", true),
761 ("acme/*-bundle", "acme/bundle", false),
762 ] {
763 assert_eq!(
764 pattern_matches(pattern, name),
765 want,
766 "pattern {pattern:?} against {name:?}"
767 );
768 }
769 }
770
771 #[test]
772 fn plain_library_is_native() {
773 let lock = lock_with(json!([zip_pkg("a/b", "library")]));
774 let r = analyze(&proj(), &lock, &json!({}), true, true);
775 assert!(r.is_native_ok());
776 assert!(r.skipped_plugins.is_empty());
777 assert_eq!(r.layout.expect("layout").rel("a/b"), Some("vendor/a/b"));
778 }
779
780 #[test]
781 fn emulated_and_benign_plugins_stay_native() {
782 let lock = lock_with(json!([
783 zip_pkg("symfony/runtime", "composer-plugin"),
784 zip_pkg("symfony/flex", "composer-plugin"),
785 ]));
786 let r = analyze(&proj(), &lock, &json!({}), true, true);
787 assert!(r.is_native_ok());
788 assert!(r.skipped_plugins.is_empty(), "{:?}", r.skipped_plugins);
791 }
792
793 #[test]
794 fn thanks_and_normalize_are_benign_at_install() {
795 let lock = lock_with(json!([
796 zip_pkg("symfony/thanks", "composer-plugin"),
797 zip_pkg("ergebnis/composer-normalize", "composer-plugin"),
798 ]));
799 let r = analyze(&proj(), &lock, &json!({}), true, true);
800 assert!(r.is_native_ok(), "{:?}", r.issues);
801 assert_eq!(
802 r.skipped_plugins,
803 vec!["symfony/thanks", "ergebnis/composer-normalize"]
804 );
805 }
806
807 #[test]
808 fn thanks_reminder_only_on_update_with_install() {
809 use ResolutionCommand::*;
810 let effect = |c, i| resolution_effect("symfony/thanks", c, i);
811 assert!(effect(Update, true).is_some());
812 assert!(effect(Update, false).is_none());
813 assert!(effect(Require, true).is_none());
814 assert!(effect(Remove, true).is_none());
815 assert!(resolution_effect("ergebnis/composer-normalize", Update, true).is_none());
816 }
817
818 #[test]
819 fn yii2_composer_is_emulated_except_with_yii2_dev() {
820 let allow = json!({"config": {"allow-plugins": {"yiisoft/yii2-composer": true}}});
821 let lock = lock_with(json!([
822 zip_pkg("yiisoft/yii2-composer", "composer-plugin"),
823 zip_pkg("yiisoft/yii2-gii", "yii2-extension"),
824 ]));
825 assert!(analyze(&proj(), &lock, &allow, true, true).is_native_ok());
826 let lock = lock_with(json!([
827 zip_pkg("yiisoft/yii2-composer", "composer-plugin"),
828 zip_pkg("yiisoft/yii2-dev", "yii2-extension"),
829 ]));
830 let r = analyze(&proj(), &lock, &allow, true, true);
831 assert!(!r.is_native_ok());
832 assert!(
833 r.issues[0].to_string().contains("yiisoft/yii2-dev"),
834 "{}",
835 r.issues[0]
836 );
837 assert!(analyze(&proj(), &lock, &allow, true, false).is_native_ok());
839 }
840
841 #[test]
842 fn license_check_native_unless_configured() {
843 let lock = lock_with(json!([zip_pkg(
844 "metasyntactical/composer-plugin-license-check",
845 "composer-plugin"
846 )]));
847 let manifest = |extra: serde_json::Value| json!({"config": {"allow-plugins": {"metasyntactical/composer-plugin-license-check": true}}, "extra": extra});
848 for extra in [
850 json!({}),
851 json!({"metasyntactical/composer-plugin-license-check": {}}),
852 json!({"metasyntactical/composer-plugin-license-check": {"allow-list": []}}),
853 json!({"metasyntactical/composer-plugin-license-check": {"allowed-packages": {"a/b": "why"}}}),
854 ] {
855 let r = analyze(&proj(), &lock, &manifest(extra.clone()), true, true);
856 assert!(
857 r.is_native_ok(),
858 "{extra} should stay native: {:?}",
859 r.issues
860 );
861 }
862 for key in ["allow-list", "deny-list"] {
863 let extra = json!({"metasyntactical/composer-plugin-license-check": {key: ["MIT"]}});
864 let r = analyze(&proj(), &lock, &manifest(extra), true, true);
865 assert!(
866 r.issues[0].to_string().contains("allow-list or deny-list"),
867 "{key}: {:?}",
868 r.issues
869 );
870 }
871 }
872
873 #[test]
874 fn bamarni_bin_native_unless_it_forwards() {
875 let lock = lock_with(json!([zip_pkg(
876 "bamarni/composer-bin-plugin",
877 "composer-plugin"
878 )]));
879 let allow = |extra: serde_json::Value| json!({"config": {"allow-plugins": {"bamarni/composer-bin-plugin": true}}, "extra": extra});
880 assert!(analyze(&proj(), &lock, &allow(json!({})), true, true).is_native_ok());
881 let r = analyze(
882 &proj(),
883 &lock,
884 &allow(json!({"bamarni-bin": {"forward-command": true}})),
885 true,
886 true,
887 );
888 assert!(
889 r.issues[0].to_string().contains("forward-command true"),
890 "{}",
891 r.issues[0]
892 );
893 let r = analyze(
894 &proj(),
895 &lock,
896 &allow(json!({"bamarni-bin": {"bin-links": "no"}})),
897 true,
898 true,
899 );
900 assert!(
901 r.issues[0].to_string().contains("boolean value"),
902 "{}",
903 r.issues[0]
904 );
905 }
906
907 #[test]
908 fn no_plugins_makes_every_plugin_a_plain_library() {
909 let lock = lock_with(json!([
910 zip_pkg("acme/mystery-plugin", "composer-plugin"),
911 zip_pkg("cweagans/composer-patches", "composer-plugin"),
912 zip_pkg("symfony/flex", "composer-plugin"),
913 ]));
914 let r = analyze(&proj(), &lock, &json!({}), true, false);
915 assert!(r.is_native_ok(), "{:?}", r.issues);
916 assert!(r.skipped_plugins.is_empty());
917 assert_eq!(
918 r.layout.expect("layout").rel("acme/mystery-plugin"),
919 Some("vendor/acme/mystery-plugin")
920 );
921 }
922
923 #[test]
924 fn unknown_or_layout_plugin_is_out_of_scope() {
925 let lock = lock_with(json!([
926 zip_pkg("acme/mystery-plugin", "composer-plugin"),
927 zip_pkg("cweagans/composer-patches", "composer-plugin"),
928 ]));
929 let r = analyze(&proj(), &lock, &json!({}), true, true);
930 assert_eq!(
931 r.issues,
932 vec![
933 ScopeIssue::UnknownPlugin("acme/mystery-plugin".into()),
934 ScopeIssue::LayoutPlugin("cweagans/composer-patches".into()),
935 ]
936 );
937 }
938
939 #[test]
940 fn installers_without_allow_plugins_and_sourceless_dist_are_out_of_scope() {
941 let lock = lock_with(json!([
942 {"name": "a/src-only", "version": "1.0.0", "type": "library",
943 "source": {"type": "git", "url": "https://g/x.git", "reference": "r"}},
944 {"name": "a/meta", "version": "1.0.0", "type": "metapackage"},
945 zip_pkg("composer/installers", "composer-plugin"),
946 ]));
947 let manifest = json!({"extra": {"installer-paths": {"web/modules/{$name}": []}}});
950 let r = analyze(&proj(), &lock, &manifest, true, true);
951 assert_eq!(r.issues.len(), 2, "{:?}", r.issues);
952 assert_eq!(r.issues[0], ScopeIssue::NoUsableDist("a/src-only".into()));
953 assert!(matches!(&r.issues[1], ScopeIssue::Layout(m) if m.contains("allow-plugins")));
954 }
955
956 #[test]
957 fn path_package_is_native_when_its_source_exists() {
958 let tmp = tempfile::tempdir().expect("tmp");
959 std::fs::create_dir_all(tmp.path().join("packages/here")).expect("mkdir");
960 let lock = lock_with(json!([
961 {"name": "a/here", "version": "dev-main", "type": "library",
962 "dist": {"type": "path", "url": "packages/here", "reference": "r"}},
963 {"name": "a/gone", "version": "dev-main", "type": "library",
964 "dist": {"type": "path", "url": "packages/gone", "reference": "r"}},
965 ]));
966 let r = analyze(tmp.path(), &lock, &json!({}), true, true);
967 let expected = if cfg!(unix) {
968 vec![ScopeIssue::NoUsableDist("a/gone".into())]
969 } else {
970 vec![
971 ScopeIssue::NoUsableDist("a/here".into()),
972 ScopeIssue::NoUsableDist("a/gone".into()),
973 ]
974 };
975 assert_eq!(r.issues, expected);
976 }
977
978 #[test]
979 fn unread_config_keys_are_scope_issues() {
980 let lock = lock_with(json!([zip_pkg("acme/thing", "library")]));
984 let manifest = json!({"config": {"vendor-dir": "lib/", "bin-dir": "vendor/bin",
985 "preferred-install": {"acme/*": "source", "*": "dist"}}});
986 let r = analyze(&proj(), &lock, &manifest, true, true);
987 assert_eq!(
988 r.issues,
989 vec![ScopeIssue::Config(
990 "preferred-install {\"acme/*\":\"source\",\"*\":\"dist\"}".into()
991 ),]
992 );
993 let lock = lock_with(json!([zip_pkg("a/b", "library")]));
996 let manifest = json!({"config": {"vendor-dir": "vendor",
997 "preferred-install": {"acme/*": "source", "*": "dist"}}});
998 assert!(analyze(&proj(), &lock, &manifest, true, true).is_native_ok());
999 let manifest = json!({"config": {"vendor-dir": "vendor", "preferred-install": "auto"}});
1001 assert!(analyze(&proj(), &lock, &manifest, true, true).is_native_ok());
1002 }
1003
1004 #[test]
1005 fn no_dev_skips_dev_packages() {
1006 let lock = Lock::parse(
1007 &json!({
1008 "packages": [zip_pkg("a/b", "library")],
1009 "packages-dev": [zip_pkg("acme/mystery-plugin", "composer-plugin")]
1010 })
1011 .to_string(),
1012 )
1013 .expect("lock");
1014 assert!(analyze(&proj(), &lock, &json!({}), false, true).is_native_ok());
1015 assert!(!analyze(&proj(), &lock, &json!({}), true, true).is_native_ok());
1016 }
1017}