1use crate::constraint::parse_constraints;
9use crate::loader;
10use crate::package::{Links, Origin, Package};
11use crate::version::{
12 self, group, normalize, regex, stability_rank, VersionError, STABILITIES_REGEX,
13};
14use pcre2::bytes::Regex;
15use serde_json::{Map, Value};
16use std::collections::BTreeMap;
17use std::path::Path;
18use std::sync::OnceLock;
19
20pub const DEFAULT_PRETTY_VERSION: &str = "1.0.0+no-version-set";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct RootAlias {
25 pub package: String,
26 pub version: String,
27 pub alias: String,
28 pub alias_normalized: String,
29}
30
31#[derive(Debug, Clone)]
32pub struct RootPackage {
33 pub package: Package,
34 pub branch_alias: Option<(String, String)>,
36 pub minimum_stability: String,
37 pub prefer_stable: bool,
38 pub stability_flags: BTreeMap<String, i32>,
40 pub aliases: Vec<RootAlias>,
41 pub references: BTreeMap<String, String>,
43 pub platform_overrides: Map<String, Value>,
45 pub manifest: Value,
46}
47
48#[derive(Debug, thiserror::Error)]
49#[error("{0}")]
50pub struct RootError(pub String);
51
52#[derive(Debug, Clone, Default)]
55pub struct RootPatch {
56 pub requirements: Vec<(String, String)>,
58 pub dev: bool,
59 pub removals: Vec<(bool, String)>,
61}
62
63impl RootPackage {
64 pub fn load(manifest: &Value, project_dir: &Path) -> Result<RootPackage, RootError> {
67 Self::load_with_version(
68 manifest,
69 vivacity_core::root_version::detect(manifest, project_dir),
70 )
71 }
72
73 pub fn load_with_version(
78 manifest: &Value,
79 rv: vivacity_core::root_version::RootVersion,
80 ) -> Result<RootPackage, RootError> {
81 let mut config = manifest
82 .as_object()
83 .cloned()
84 .ok_or_else(|| RootError("composer.json is not an object".into()))?;
85 if !config.contains_key("name") {
86 config.insert("name".into(), Value::String("__root__".into()));
87 }
88 let mut auto_versioned = false;
89 if !config.contains_key("version") {
90 if rv.pretty_version == vivacity_core::root_version::DEFAULT_PRETTY_VERSION {
91 config.insert("version".into(), Value::String("1.0.0".into()));
92 auto_versioned = true;
93 } else {
94 config.insert("version".into(), Value::String(rv.pretty_version.clone()));
95 config.insert(
96 "version_normalized".into(),
97 Value::String(rv.version.clone()),
98 );
99 if let Some(commit) = rv.reference {
100 let r = serde_json::json!({"type": "", "url": "", "reference": commit});
101 config.insert("source".into(), r.clone());
102 config.insert("dist".into(), r);
103 }
104 }
105 }
106 let value = Value::Object(config.clone());
107 let (mut package, alias) =
108 loader::load(&value, Origin::Root, false).map_err(|e| RootError(e.0))?;
109 if auto_versioned {
110 package.pretty_version = DEFAULT_PRETTY_VERSION.to_owned();
111 }
112 let minimum_stability = match config.get("minimum-stability").and_then(Value::as_str) {
113 Some(s) => normalize_stability(s)?,
114 None => "stable".to_owned(),
115 };
116 let mut aliases = Vec::new();
117 let mut stability_flags = BTreeMap::new();
118 let mut references = BTreeMap::new();
119 for links in [&package.requires, &package.dev_requires] {
120 let map: Vec<(String, String)> = links
121 .iter()
122 .map(|l| {
125 let pretty = if l.pretty_constraint == "self.version" {
126 if auto_versioned {
129 "1.0.0".to_owned()
130 } else {
131 package.pretty_version.clone()
132 }
133 } else {
134 l.pretty_constraint.clone()
135 };
136 (l.target.clone(), pretty)
137 })
138 .collect();
139 extract_aliases(&map, &mut aliases)?;
140 extract_stability_flags(&map, &minimum_stability, &mut stability_flags);
141 extract_references(&map, &mut references);
142 if map.iter().any(|(n, _)| *n == package.name) {
143 return Err(RootError(format!(
144 "Root package '{}' cannot require itself in its composer.json\nDid you accidentally name your root package after an external package?",
145 package.pretty_name
146 )));
147 }
148 }
149 let prefer_stable = config
150 .get("prefer-stable")
151 .map(|v| match v {
152 Value::Bool(b) => *b,
153 Value::Number(n) => n.as_f64() != Some(0.0),
154 Value::String(s) => !(s.is_empty() || s == "0"),
155 Value::Null => false,
156 _ => true,
157 })
158 .unwrap_or(false);
159 let platform_overrides = config
160 .get("config")
161 .and_then(|c| c.get("platform"))
162 .and_then(Value::as_object)
163 .cloned()
164 .unwrap_or_default();
165 Ok(RootPackage {
166 package,
167 branch_alias: alias,
168 minimum_stability,
169 prefer_stable,
170 stability_flags,
171 aliases,
172 references,
173 platform_overrides,
174 manifest: manifest.clone(),
175 })
176 }
177
178 pub fn apply_patch(&mut self, patch: &RootPatch) -> Result<(), RootError> {
186 if !patch.requirements.is_empty() {
187 let map: Map<String, Value> = patch
188 .requirements
189 .iter()
190 .map(|(n, c)| (n.clone(), Value::String(c.clone())))
191 .collect();
192 let kind = if patch.dev {
193 crate::package::LinkType::DevRequire
194 } else {
195 crate::package::LinkType::Require
196 };
197 let new_links = loader::parse_links(
198 &self.package.name,
199 &self.package.pretty_version,
200 kind,
201 Some(&Value::Object(map)),
202 false,
203 )
204 .map_err(|e| RootError(e.0))?;
205 let (own_links, other_links) = if kind == crate::package::LinkType::DevRequire {
206 (&mut self.package.dev_requires, &mut self.package.requires)
207 } else {
208 (&mut self.package.requires, &mut self.package.dev_requires)
209 };
210 for l in new_links.iter() {
211 own_links.insert(l.clone());
212 }
213 for (name, _) in &patch.requirements {
214 other_links.remove(name);
215 }
216 let pairs: Vec<(String, String)> = patch.requirements.clone();
217 extract_references(&pairs, &mut self.references);
218 extract_stability_flags(&pairs, &self.minimum_stability, &mut self.stability_flags);
219 }
220 for (dev, name) in &patch.removals {
223 if *dev {
224 self.package.dev_requires.remove(name);
225 } else {
226 self.package.requires.remove(name);
227 }
228 }
229 Ok(())
230 }
231
232 pub fn all_requires(&self) -> Links {
233 let mut out = self.package.requires.clone();
234 for l in self.package.dev_requires.iter() {
235 out.insert(l.clone());
236 }
237 out
238 }
239}
240
241pub fn manifest_error(manifest: &Value) -> Option<String> {
268 use crate::lockfile::package_naming_error;
269 let config = manifest.as_object()?;
270 let name = config.get("name").and_then(Value::as_str);
279 if let Some(name) = name {
280 if let Some(err) = package_naming_error(name, false) {
281 return Some(format!("Your package name {err}"));
282 }
283 }
284 if let Some(name) = name {
288 for link_type in ["require", "require-dev"] {
289 let Some(links) = config.get(link_type).and_then(Value::as_object) else {
290 continue;
291 };
292 if links.keys().any(|k| k.to_ascii_lowercase() == name) {
293 return Some(format!(
294 "Root package '{name}' cannot require itself in its composer.json\nDid you accidentally name your root package after an external package?"
295 ));
296 }
297 }
298 }
299 for link_type in LINK_TYPES {
302 let Some(links) = config.get(*link_type).and_then(Value::as_object) else {
303 continue;
304 };
305 for target in links.keys() {
306 if let Some(err) = package_naming_error(target, true) {
307 return Some(format!("{link_type}.{err}"));
308 }
309 }
310 }
311 None
312}
313
314const LINK_TYPES: &[&str] = &["require", "conflict", "provide", "replace", "require-dev"];
316
317pub fn undetected_version_warning(manifest: &Value, project_dir: &Path) -> Option<String> {
332 let env_version = vivacity_core::root_version::root_version_from_env().is_some();
333 undetected_version_warning_with(manifest, project_dir, env_version)
334}
335
336fn undetected_version_warning_with(
337 manifest: &Value,
338 project_dir: &Path,
339 env_version: bool,
340) -> Option<String> {
341 let config = manifest.as_object()?;
342 let name = config.get("name")?.as_str()?;
343 if crate::lockfile::package_naming_error(name, false).is_some() {
344 return None;
345 }
346 if config.contains_key("version") || env_version {
347 return None;
348 }
349 if config.get("type").and_then(Value::as_str) == Some("project") {
351 return None;
352 }
353 if vivacity_core::root_version::version_guessable(project_dir) {
354 return None;
355 }
356 Some(format!(
357 "Composer could not detect the root package ({name}) version, defaulting to '1.0.0'. See https://getcomposer.org/root-version"
358 ))
359}
360
361pub fn normalize_stability(s: &str) -> Result<String, RootError> {
362 let lower = s.to_lowercase();
363 match lower.as_str() {
364 "stable" | "beta" | "alpha" | "dev" => Ok(lower),
365 "rc" => Ok("RC".to_owned()),
366 _ => Err(RootError(format!(
367 "Invalid stability string \"{s}\", expected one of stable, RC, beta, alpha or dev"
368 ))),
369 }
370}
371
372fn extract_aliases(
374 requires: &[(String, String)],
375 aliases: &mut Vec<RootAlias>,
376) -> Result<(), RootError> {
377 static RE: OnceLock<Regex> = OnceLock::new();
378 let re = regex(
379 &RE,
380 r"(?:^|\| *|, *)([^,\s#|]+)(?:#[^ ]+)? +as +([^,\s|]+)(?:$| *\|| *,)",
381 false,
382 );
383 for (name, req) in requires {
384 if let Ok(Some(caps)) = re.captures(req.as_bytes()) {
385 let v = group(&caps, 1);
386 let a = group(&caps, 2);
387 aliases.push(RootAlias {
388 package: name.to_lowercase(),
389 version: normalize(v, Some(req)).map_err(|e: VersionError| RootError(e.0))?,
390 alias: a.to_owned(),
391 alias_normalized: normalize(a, Some(req))
392 .map_err(|e: VersionError| RootError(e.0))?,
393 });
394 } else if req.contains(" as ") {
395 return Err(RootError(format!(
396 "Invalid alias definition in \"{name}\": \"{req}\". Aliases should be in the form \"exact-version as other-exact-version\"."
397 )));
398 }
399 }
400 Ok(())
401}
402
403fn split_constraints(req: &str) -> Vec<String> {
405 static OR: OnceLock<Regex> = OnceLock::new();
406 static AND: OnceLock<Regex> = OnceLock::new();
407 let or = regex(&OR, r"\s*\|\|?\s*", false);
408 let and = regex(
409 &AND,
410 r"(?<!^|as|[=>< ,]) *(?<!-)[, ](?!-) *(?!,|as|$)",
411 false,
412 );
413 let mut out = Vec::new();
414 for part in split(or, req.trim()) {
415 out.extend(split(and, &part));
416 }
417 out
418}
419
420fn split(re: &Regex, subject: &str) -> Vec<String> {
421 let bytes = subject.as_bytes();
422 let mut out = Vec::new();
423 let mut last = 0;
424 for m in re.find_iter(bytes).flatten() {
425 if m.start() == m.end() && m.start() == last && last == bytes.len() {
426 break;
427 }
428 out.push(subject[last..m.start()].to_owned());
429 last = m.end();
430 }
431 out.push(subject[last..].to_owned());
432 out
433}
434
435pub fn merge_plugin_stability_flags(
442 flags: &mut BTreeMap<String, i32>,
443 minimum_stability: &str,
444 requires: &[(String, String)],
445) {
446 static AT: OnceLock<Regex> = OnceLock::new();
447 static AS: OnceLock<Regex> = OnceLock::new();
448 let at = regex(&AT, &format!("^[^@]*?@({STABILITIES_REGEX})$"), true);
449 let as_re = regex(&AS, r"^([^,\s@]+) as .+$", false);
450 let minimum = stability_rank(minimum_stability);
451 for (req_name, pretty) in requires {
452 let name = req_name.to_lowercase();
453 let mut explicit: Option<i32> = None;
454 for c in split_constraints(pretty) {
455 if let Ok(Some(caps)) = at.captures(c.as_bytes()) {
456 let Ok(stab) = normalize_stability(group(&caps, 1)) else {
457 continue;
458 };
459 let rank = stability_rank(&stab);
460 explicit = Some(explicit.map_or(rank, |e| e.max(rank)));
461 }
462 }
463 let stability = match explicit {
464 Some(e) => Some(e),
465 None => {
466 let v = match as_re.captures(pretty.as_bytes()) {
468 Ok(Some(caps)) => group(&caps, 1).to_owned(),
469 _ => pretty.clone(),
470 };
471 let rank = stability_rank(version::parse_stability(&v));
472 if rank == stability_rank("stable") || minimum > rank {
473 None
474 } else {
475 Some(rank)
476 }
477 }
478 };
479 if let Some(st) = stability {
480 let current = flags.get(&name).copied();
481 flags.insert(name, current.map_or(st, |c| c.max(st)));
482 }
483 }
484}
485
486pub fn extract_stability_flags(
488 requires: &[(String, String)],
489 minimum_stability: &str,
490 flags: &mut BTreeMap<String, i32>,
491) {
492 static AT: OnceLock<Regex> = OnceLock::new();
493 static AS: OnceLock<Regex> = OnceLock::new();
494 static PLAIN: OnceLock<Regex> = OnceLock::new();
495 let at = regex(&AT, &format!("^[^@]*?@({STABILITIES_REGEX})$"), true);
496 let as_re = regex(&AS, r"^([^,\s@]+) as .+$", false);
497 let plain = regex(&PLAIN, r"^[^,\s@]+$", false);
498 let minimum = stability_rank(minimum_stability);
499 for (req_name, req) in requires {
500 let constraints = split_constraints(req);
501 let mut matched = false;
502 for c in &constraints {
503 if let Ok(Some(caps)) = at.captures(c.as_bytes()) {
504 let name = req_name.to_lowercase();
505 let Ok(stab) = normalize_stability(group(&caps, 1)) else {
506 continue;
507 };
508 let rank = stability_rank(&stab);
509 if flags.get(&name).is_some_and(|f| *f > rank) {
510 continue;
511 }
512 flags.insert(name, rank);
513 matched = true;
514 }
515 }
516 if matched {
517 continue;
518 }
519 for c in &constraints {
520 let stripped = match as_re.captures(c.as_bytes()) {
521 Ok(Some(caps)) => group(&caps, 1).to_owned(),
522 _ => c.clone(),
523 };
524 if plain.is_match(stripped.as_bytes()).unwrap_or(false) {
525 let stability = version::parse_stability(&stripped);
526 if stability != "stable" {
527 let name = req_name.to_lowercase();
528 let rank = stability_rank(stability);
529 if flags.get(&name).is_some_and(|f| *f > rank) || minimum > rank {
530 continue;
531 }
532 flags.insert(name, rank);
533 }
534 }
535 }
536 }
537}
538
539pub fn extract_references(
541 requires: &[(String, String)],
542 references: &mut BTreeMap<String, String>,
543) {
544 static AS: OnceLock<Regex> = OnceLock::new();
545 static REF: OnceLock<Regex> = OnceLock::new();
546 let as_re = regex(&AS, r"^([^,\s@]+) as .+$", false);
547 let re = regex(&REF, r"^[^,\s@]+?#([a-f0-9]+)$", false);
548 for (name, req) in requires {
549 let stripped = match as_re.captures(req.as_bytes()) {
550 Ok(Some(caps)) => group(&caps, 1).to_owned(),
551 _ => req.clone(),
552 };
553 if let Ok(Some(caps)) = re.captures(stripped.as_bytes()) {
554 if version::parse_stability(&stripped) == "dev" {
555 references.insert(name.to_lowercase(), group(&caps, 1).to_owned());
556 }
557 }
558 }
559}
560
561pub fn root_constraint(pretty: &str) -> Result<crate::constraint::Constraint, VersionError> {
563 Ok(parse_constraints(pretty)?.constraint)
564}
565
566#[cfg(test)]
567mod tests {
568 #[test]
569 fn undetected_version_warning_conditions() {
570 use super::undetected_version_warning_with as warning;
571 use serde_json::json;
572 let tmp = tempfile::tempdir().expect("tmp");
573 let p = tmp.path();
574 let git = |args: &[&str]| {
575 std::process::Command::new("git")
576 .args(args)
577 .current_dir(p)
578 .env("GIT_AUTHOR_NAME", "t")
579 .env("GIT_AUTHOR_EMAIL", "t@t")
580 .env("GIT_COMMITTER_NAME", "t")
581 .env("GIT_COMMITTER_EMAIL", "t@t")
582 .env("GIT_CONFIG_GLOBAL", "/dev/null")
583 .env("GIT_CONFIG_NOSYSTEM", "1")
584 .output()
585 .expect("git")
586 };
587 if git(&["rev-parse", "--git-dir"]).status.success() {
589 eprintln!("temporary directory inside a git repository: skipped");
590 return;
591 }
592 let lib = json!({"name": "acme/lib"});
593 let line = "Composer could not detect the root package (acme/lib) version, defaulting to '1.0.0'. See https://getcomposer.org/root-version";
594 assert_eq!(warning(&lib, p, false).as_deref(), Some(line));
595 assert_eq!(
596 warning(&json!({"name": "acme/lib", "type": "library"}), p, false).as_deref(),
597 Some(line)
598 );
599 assert!(warning(&json!({"name": "acme/lib", "version": null}), p, false).is_none());
602 assert!(warning(&json!({"name": "acme/lib", "type": "Project"}), p, false).is_some());
604 assert!(warning(&json!({}), p, false).is_none());
607 assert!(warning(&json!({"name": null}), p, false).is_none());
608 assert!(warning(&json!({"name": "con/thing"}), p, false).is_none());
609 assert!(warning(&json!({"name": "acme/lib", "type": "project"}), p, false).is_none());
610 assert!(warning(&json!({"name": "acme/lib", "version": "2.0.0"}), p, false).is_none());
611 assert!(warning(&lib, p, true).is_none());
612 assert!(git(&["init", "-q", "-b", "main"]).status.success());
614 assert!(warning(&lib, p, false).is_some());
615 std::fs::write(p.join("a.txt"), "a").expect("write");
617 assert!(git(&["add", "."]).status.success());
618 assert!(git(&["commit", "-q", "-m", "one"]).status.success());
619 assert!(warning(&lib, p, false).is_none());
620 assert!(git(&["checkout", "-q", "--detach", "HEAD"])
622 .status
623 .success());
624 assert!(warning(&lib, p, false).is_none());
625 std::fs::write(p.join("b.txt"), "b").expect("write");
627 assert!(git(&["add", "."]).status.success());
628 assert!(git(&["commit", "-q", "-m", "two"]).status.success());
629 assert!(warning(&lib, p, false).is_some());
630 assert!(git(&["tag", "v1.0.0"]).status.success());
632 assert!(warning(&lib, p, false).is_none());
633 }
634
635 #[test]
636 fn merge_plugin_flags_follow_the_plugin() {
637 use super::merge_plugin_stability_flags as flags_of;
638 use std::collections::BTreeMap;
639 let req = |pairs: &[(&str, &str)]| -> Vec<(String, String)> {
640 pairs
641 .iter()
642 .map(|(n, c)| ((*n).to_owned(), (*c).to_owned()))
643 .collect()
644 };
645 let mut f = BTreeMap::new();
648 flags_of(
649 &mut f,
650 "stable",
651 &req(&[("Acme/Lib", "^1.0@beta || ^2.0@RC")]),
652 );
653 assert_eq!(f.get("acme/lib"), Some(&10));
654 let mut f = BTreeMap::new();
658 flags_of(
659 &mut f,
660 "stable",
661 &req(&[("a/b", "dev-main || ^1.0"), ("c/d", "^1.0")]),
662 );
663 assert_eq!(f.get("a/b"), Some(&20));
664 assert!(!f.contains_key("c/d"));
665 let mut f = BTreeMap::new();
666 flags_of(&mut f, "dev", &req(&[("a/b", "1.0.0-beta")]));
667 assert!(f.is_empty(), "beta is more stable than the dev minimum");
668 let mut f: BTreeMap<String, i32> = [("a/b".to_owned(), 20)].into();
671 flags_of(
672 &mut f,
673 "stable",
674 &req(&[("a/b", "^1.0@beta"), ("a/b", "^1.0")]),
675 );
676 assert_eq!(f.get("a/b"), Some(&20));
677 let mut f = BTreeMap::new();
679 flags_of(&mut f, "stable", &req(&[("a/b", "dev-main as 1.0.0")]));
680 assert_eq!(f.get("a/b"), Some(&20));
681 }
682
683 use super::*;
684 use serde_json::json;
685
686 #[test]
687 fn extracts_flags_aliases_references() {
688 let reqs = vec![
689 ("a/b".to_owned(), "^1.0@beta".to_owned()),
690 ("c/d".to_owned(), "dev-main as 1.0.x-dev".to_owned()),
691 ("e/f".to_owned(), "dev-main#abcdef".to_owned()),
692 ("g/h".to_owned(), "1.x-dev || ^2.0".to_owned()),
693 ("i/j".to_owned(), "^1.0".to_owned()),
694 ];
695 let mut flags = BTreeMap::new();
696 extract_stability_flags(&reqs, "stable", &mut flags);
697 assert_eq!(flags.get("a/b"), Some(&10));
698 assert_eq!(flags.get("c/d"), Some(&20));
699 assert_eq!(flags.get("e/f"), Some(&20));
700 assert_eq!(flags.get("g/h"), Some(&20));
701 assert_eq!(flags.get("i/j"), None);
702 let mut aliases = Vec::new();
703 extract_aliases(&reqs, &mut aliases).unwrap();
704 assert_eq!(aliases.len(), 1);
705 assert_eq!(aliases[0].alias_normalized, "1.0.9999999.9999999-dev");
706 let mut refs = BTreeMap::new();
707 extract_references(&reqs, &mut refs);
708 assert_eq!(refs.get("e/f").map(String::as_str), Some("abcdef"));
709 }
710
711 #[test]
712 fn loads_root_without_git() {
713 let m = json!({"name": "acme/app", "require": {"php": "^8.1", "monolog/monolog": "^3"}, "minimum-stability": "RC", "prefer-stable": true});
714 let r = RootPackage::load(&m, Path::new("/nonexistent-vivacity")).unwrap();
715 assert_eq!(r.package.pretty_version, DEFAULT_PRETTY_VERSION);
716 assert_eq!(r.package.version, "1.0.0.0");
717 assert_eq!(r.minimum_stability, "RC");
718 assert!(r.prefer_stable);
719 assert_eq!(r.package.requires.len(), 2);
720 }
721
722 #[test]
723 fn manifest_error_follows_the_loader() {
724 use serde_json::json;
725 assert_eq!(
727 manifest_error(&json!({
728 "name": "acme/app",
729 "require": {"php": ">=8.1", "ext-json": "*", "psr/log": "^3", "a-b/c--d": "*"},
730 "require-dev": {"phpunit/phpunit": "^11"},
731 })),
732 None
733 );
734 assert_eq!(
736 manifest_error(&json!({"name": "acme/thing.json"})).as_deref(),
737 Some("Your package name acme/thing.json is invalid, package names can not end in .json, consider renaming it or perhaps using a -json suffix instead.")
738 );
739 assert!(manifest_error(&json!({"name": "con/thing"})).is_some());
740 assert_eq!(
745 manifest_error(&json!({"name": "acme/ThingBaz"})).as_deref(),
746 Some("Your package name acme/ThingBaz is invalid, it should not contain uppercase characters. We suggest using acme/thing-baz instead.")
747 );
748 assert!(manifest_error(&json!({"name": "acme"}))
749 .is_some_and(|e| e
750 .starts_with("Your package name acme is invalid, it should have a vendor name")));
751 assert!(manifest_error(&json!({"name": "acme/app\n"})).is_some());
754 assert_eq!(manifest_error(&json!({"require": {"a/b": "*"}})), None);
756 for section in ["require", "require-dev"] {
758 let m = json!({"name": "acme/app", section: {"acme/app": "*"}});
759 assert!(
760 manifest_error(&m).is_some_and(|e| e
761 .starts_with("Root package 'acme/app' cannot require itself")
762 && e.contains("\nDid you accidentally")),
763 "{section}"
764 );
765 }
766 for section in ["require", "conflict", "provide", "replace", "require-dev"] {
768 let m = json!({"name": "acme/app", section: {"Acme/Thing": "^1"}});
769 assert_eq!(
770 manifest_error(&m).as_deref(),
771 Some(&*format!("{section}.Acme/Thing is invalid, it should not contain uppercase characters. Please use acme/thing instead.")),
772 "{section}"
773 );
774 }
775 let all = json!({
778 "name": "acme/thing.json",
779 "require": {"acme/thing.json": "*", "Bad/Name": "*"},
780 "conflict": {"Other/Name": "*"},
781 });
782 assert!(manifest_error(&all).is_some_and(|e| e.starts_with("Your package name")));
783 let links = json!({"require": {"Bad/Name": "*"}, "conflict": {"Other/Name": "*"}});
784 assert!(manifest_error(&links).is_some_and(|e| e.starts_with("require.Bad/Name")));
785 }
786}