1use crate::constraint::{Constraint, Op};
9use crate::package::{Link, LinkType, Links, Origin, Package};
10use crate::version::{group, normalize, regex};
11use pcre2::bytes::Regex;
12use serde_json::{Map, Value};
13use std::collections::BTreeMap;
14use std::process::Command;
15use std::sync::OnceLock;
16
17pub const COMPOSER_VERSION: &str = "2.10.3";
20pub const PLUGIN_API_VERSION: &str = "2.9.0";
21pub const RUNTIME_API_VERSION: &str = "2.2.2";
22
23const PROBE: &str = include_str!("../assets/platform-probe.php");
24
25#[derive(Debug, thiserror::Error)]
26#[error("{0}")]
27pub struct PlatformError(pub String);
28
29pub fn is_platform_package(name: &str) -> bool {
31 static RE: OnceLock<Regex> = OnceLock::new();
32 let re = regex(
33 &RE,
34 r"^(?:php(?:-64bit|-ipv6|-zts|-debug)?|hhvm|(?:ext|lib)-[a-z0-9](?:[_.-]?[a-z0-9]+)*|composer(?:-(?:plugin|runtime)-api)?)\z",
35 true,
36 );
37 re.is_match(name.as_bytes()).unwrap_or(false)
38}
39
40#[derive(Debug, Clone, serde::Deserialize)]
42struct Probed {
43 kind: String,
44 name: String,
45 version: String,
46 #[serde(default)]
47 description: Option<String>,
48 #[serde(default)]
49 replaces: Vec<String>,
50 #[serde(default)]
51 provides: Vec<String>,
52}
53
54pub fn ini_files(probed: &[Value]) -> Vec<String> {
58 if let Ok(env) = std::env::var("COMPOSER_ORIGINAL_INIS") {
59 let sep = if cfg!(windows) { ';' } else { ':' };
60 return env.split(sep).map(str::to_owned).collect();
61 }
62 let Some(entry) = probed
63 .iter()
64 .find(|v| v.get("kind").and_then(Value::as_str) == Some("ini"))
65 else {
66 return vec![String::new()];
67 };
68 let mut paths = vec![entry
69 .get("loaded")
70 .and_then(Value::as_str)
71 .unwrap_or("")
72 .to_owned()];
73 if let Some(scanned) = entry.get("scanned").and_then(Value::as_str) {
74 paths.extend(scanned.split(',').map(|s| s.trim().to_owned()));
75 }
76 paths
77}
78
79pub fn loaded_extensions(probed: &[Value]) -> std::collections::BTreeSet<String> {
82 probed
83 .iter()
84 .filter(|v| v.get("kind").and_then(Value::as_str) == Some("ext"))
85 .filter(|v| v.get("skipped").and_then(Value::as_bool) != Some(true))
86 .filter_map(|v| v.get("name").and_then(Value::as_str))
87 .map(|n| format!("ext-{}", n.to_lowercase()))
88 .collect()
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
95struct FileId {
96 path: String,
97 mtime_ns: Option<i128>,
98 size: Option<u64>,
99}
100
101impl FileId {
102 fn of(path: &str) -> FileId {
103 let meta = std::fs::metadata(path).ok();
104 let mtime_ns = meta.as_ref().and_then(|m| m.modified().ok()).and_then(|t| {
105 t.duration_since(std::time::UNIX_EPOCH)
106 .ok()
107 .map(|d| d.as_nanos() as i128)
108 });
109 FileId {
110 path: path.to_owned(),
111 mtime_ns,
112 size: meta.map(|m| m.len()),
113 }
114 }
115}
116
117const PROBE_ENV: &[&str] = &["PHPRC", "PHP_INI_SCAN_DIR", "XDEBUG_MODE", "XDEBUG_CONFIG"];
120
121#[derive(Debug, serde::Serialize, serde::Deserialize)]
124struct ProbeCache {
125 php: FileId,
126 inis: Vec<FileId>,
127 env: Vec<(String, Option<String>)>,
128 probed: Vec<Value>,
129}
130
131fn ini_dependencies(probed: &[Value]) -> Vec<FileId> {
135 let Some(entry) = probed
136 .iter()
137 .find(|v| v.get("kind").and_then(Value::as_str) == Some("ini"))
138 else {
139 return Vec::new();
140 };
141 let mut paths: Vec<String> = Vec::new();
142 for key in ["loaded", "scan_dir"] {
143 if let Some(p) = entry.get(key).and_then(Value::as_str) {
144 if !p.is_empty() {
145 paths.push(p.to_owned());
146 }
147 }
148 }
149 if let Some(scanned) = entry.get("scanned").and_then(Value::as_str) {
150 paths.extend(
151 scanned
152 .split(',')
153 .map(str::trim)
154 .filter(|s| !s.is_empty())
155 .map(str::to_owned),
156 );
157 }
158 paths.iter().map(|p| FileId::of(p)).collect()
159}
160
161fn probe_env() -> Vec<(String, Option<String>)> {
162 PROBE_ENV
163 .iter()
164 .map(|k| ((*k).to_owned(), std::env::var(k).ok()))
165 .collect()
166}
167
168fn locate_php(php: &str) -> Option<std::path::PathBuf> {
172 let candidates: Vec<std::path::PathBuf> = if php.contains('/') || php.contains('\\') {
173 vec![std::path::PathBuf::from(php)]
174 } else {
175 let exts: &[&str] = if cfg!(windows) {
176 &["", ".exe", ".bat", ".cmd"]
177 } else {
178 &[""]
179 };
180 std::env::var_os("PATH")
181 .map(|p| {
182 std::env::split_paths(&p)
183 .flat_map(|dir| exts.iter().map(move |e| dir.join(format!("{php}{e}"))))
184 .collect()
185 })
186 .unwrap_or_default()
187 };
188 candidates
189 .into_iter()
190 .find(|c| is_executable(c))
191 .and_then(|c| vivacity_core::pathutil::canonicalize(c).ok())
192}
193
194#[cfg(unix)]
195fn is_executable(path: &std::path::Path) -> bool {
196 use std::os::unix::fs::PermissionsExt;
197 std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
198}
199
200#[cfg(not(unix))]
201fn is_executable(path: &std::path::Path) -> bool {
202 std::fs::metadata(path).is_ok_and(|m| m.is_file())
203}
204
205fn probe_cache_file() -> std::path::PathBuf {
206 vivacity_core::platform::cache_dir().join("platform-probe.json")
207}
208
209pub fn probe() -> Result<Vec<Value>, PlatformError> {
216 let php = std::env::var("VIVACITY_PHP").unwrap_or_else(|_| "php".to_owned());
217 if std::env::var_os("VIVACITY_NO_PLATFORM_CACHE").is_some() {
218 return run_probe(&php);
219 }
220 let Some(php_path) = locate_php(&php) else {
221 return run_probe(&php);
222 };
223 let php_id = FileId::of(&php_path.to_string_lossy());
224 let env = probe_env();
225 let cache_file = probe_cache_file();
226 if let Ok(bytes) = std::fs::read(&cache_file) {
227 if let Ok(cached) = serde_json::from_slice::<ProbeCache>(&bytes) {
228 if cached.php == php_id
229 && cached.env == env
230 && cached.inis.iter().all(|f| FileId::of(&f.path) == *f)
231 {
232 return Ok(cached.probed);
233 }
234 }
235 }
236 let probed = run_probe(&php)?;
237 let cache = ProbeCache {
238 php: php_id,
239 inis: ini_dependencies(&probed),
240 env,
241 probed: probed.clone(),
242 };
243 if let Some(dir) = cache_file.parent() {
244 if std::fs::create_dir_all(dir).is_ok() {
245 if let Ok(json) = serde_json::to_vec(&cache) {
246 let tmp = cache_file.with_extension(format!("json.{}.tmp", std::process::id()));
247 if std::fs::write(&tmp, json).is_ok() && std::fs::rename(&tmp, &cache_file).is_err()
248 {
249 let _ = std::fs::remove_file(&tmp);
250 }
251 }
252 }
253 }
254 Ok(probed)
255}
256
257fn run_probe(php: &str) -> Result<Vec<Value>, PlatformError> {
259 let dir = tempfile::tempdir().map_err(|e| PlatformError(e.to_string()))?;
260 let script = dir.path().join("platform-probe.php");
261 std::fs::write(&script, PROBE).map_err(|e| PlatformError(e.to_string()))?;
262 let out = Command::new(php)
263 .arg(&script)
264 .output()
265 .map_err(|e| PlatformError(format!("cannot run {php}: {e}")))?;
266 if !out.status.success() {
267 return Err(PlatformError(format!(
268 "platform probe failed: {}",
269 String::from_utf8_lossy(&out.stderr)
270 )));
271 }
272 serde_json::from_slice(&out.stdout)
273 .map_err(|e| PlatformError(format!("platform probe output: {e}")))
274}
275
276struct Override {
277 name: String,
278 version: Option<String>,
279}
280
281pub fn platform_packages(
284 probed: &[Value],
285 overrides_cfg: &Map<String, Value>,
286) -> Result<Vec<Package>, PlatformError> {
287 let mut overrides: Vec<(String, Override)> = Vec::new();
289 for (name, version) in overrides_cfg {
290 let v = match version {
291 Value::String(s) => Some(s.clone()),
292 Value::Bool(false) => None,
293 other => {
294 return Err(PlatformError(format!(
295 "config.platform.{name} should be a string or false, but got {other}"
296 )))
297 }
298 };
299 if name == "php" && v.is_none() {
300 return Err(PlatformError(
301 "config.platform.php cannot be set to false as you cannot disable php entirely."
302 .into(),
303 ));
304 }
305 let key = name.to_lowercase();
306 let o = Override {
307 name: name.clone(),
308 version: v,
309 };
310 match overrides.iter_mut().find(|(k, _)| *k == key) {
311 Some(slot) => slot.1 = o,
312 None => overrides.push((key, o)),
313 }
314 }
315 let override_of = |name: &str| overrides.iter().find(|(k, _)| k == name).map(|(_, o)| o);
316
317 let mut packages: Vec<Package> = Vec::new();
318 let mut libraries: BTreeMap<String, bool> = BTreeMap::new();
319
320 let add_overridden = |packages: &mut Vec<Package>,
322 o: &Override,
323 name: Option<&str>|
324 -> Result<(), PlatformError> {
325 let Some(pretty) = &o.version else {
326 return Ok(());
327 };
328 let version = normalize(pretty, None).map_err(|e| PlatformError(e.0))?;
329 let mut p = Package::new(name.unwrap_or(&o.name), &version, pretty, Origin::Platform);
330 p.raw = serde_json::json!({"description": "Package overridden via config.platform", "extra": {"config.platform": true}});
331 packages.push(p);
332 Ok(())
333 };
334 for (_, o) in &overrides {
335 if !is_platform_package(&o.name) {
336 return Err(PlatformError(format!(
337 "Invalid platform package name in config.platform: {}",
338 o.name
339 )));
340 }
341 if o.version.is_some() {
342 add_overridden(&mut packages, o, None)?;
343 }
344 }
345 let note_actual = |packages: &mut [Package], actual: &Package| {
348 if let Some(over) = packages.iter_mut().find(|q| q.name == actual.name) {
349 let text = if actual.version == over.version {
350 "same as actual".to_owned()
351 } else {
352 format!("actual: {}", actual.pretty_version)
353 };
354 let description = over
355 .raw
356 .get("description")
357 .and_then(serde_json::Value::as_str)
358 .unwrap_or("")
359 .to_owned();
360 over.raw["description"] = serde_json::Value::String(format!("{description}, {text}"));
361 }
362 };
363 let add = |packages: &mut Vec<Package>, p: Package| -> Result<(), PlatformError> {
365 if let Some(o) = override_of(&p.name) {
366 if o.version.is_none() {
367 return Ok(()); }
369 note_actual(packages, &p);
370 return Ok(()); }
372 if let Some(php) = override_of("php") {
373 if p.name.starts_with("php-") {
374 add_overridden(packages, php, Some(&p.pretty_name))?;
375 note_actual(packages, &p);
376 return Ok(());
377 }
378 }
379 packages.push(p);
380 Ok(())
381 };
382 let simple = |name: &str, pretty: &str, description: &str| -> Result<Package, PlatformError> {
383 let version = normalize(pretty, None).map_err(|e| PlatformError(e.0))?;
384 let mut p = Package::new(name, &version, pretty, Origin::Platform);
385 p.raw = serde_json::json!({"description": description});
386 Ok(p)
387 };
388 add(
389 &mut packages,
390 simple("composer", COMPOSER_VERSION, "Composer package")?,
391 )?;
392 add(
393 &mut packages,
394 simple(
395 "composer-plugin-api",
396 PLUGIN_API_VERSION,
397 "The Composer Plugin API",
398 )?,
399 )?;
400 add(
401 &mut packages,
402 simple(
403 "composer-runtime-api",
404 RUNTIME_API_VERSION,
405 "The Composer Runtime API",
406 )?,
407 )?;
408
409 let entries: Vec<Probed> = probed
410 .iter()
411 .map(|v| {
412 serde_json::from_value(v.clone())
413 .map_err(|e| PlatformError(format!("probe entry: {e}")))
414 })
415 .collect::<Result<_, _>>()?;
416
417 static PHP_FALLBACK: OnceLock<Regex> = OnceLock::new();
418 static EXT_FALLBACK: OnceLock<Regex> = OnceLock::new();
419 for e in &entries {
420 match e.kind.as_str() {
421 "php" => {
422 let (version, pretty) = match normalize(&e.version, None) {
423 Ok(v) => (v, e.version.clone()),
424 Err(_) => {
425 let re = regex(&PHP_FALLBACK, r"^([^~+-]+).*$", false);
426 let pretty = match re.captures(e.version.as_bytes()) {
427 Ok(Some(caps)) => group(&caps, 1).to_owned(),
428 _ => e.version.clone(),
429 };
430 let v = normalize(&pretty, None).map_err(|x| PlatformError(x.0))?;
431 (v, pretty)
432 }
433 };
434 let mut p = Package::new(&e.name, &version, &pretty, Origin::Platform);
435 p.raw = serde_json::json!({"description": e.description});
436 add(&mut packages, p)?;
437 }
438 "ext" => {
439 let mut extra_description = String::new();
440 let (version, pretty) = match normalize(&e.version, None) {
441 Ok(v) => (v, e.version.clone()),
442 Err(_) => {
443 extra_description = format!(" (actual version: {})", e.version);
444 let re = regex(&EXT_FALLBACK, r"^(\d+\.\d+\.\d+(?:\.\d+)?)", false);
445 let pretty = match re.captures(e.version.as_bytes()) {
446 Ok(Some(caps)) => group(&caps, 1).to_owned(),
447 _ => "0".to_owned(),
448 };
449 let v = normalize(&pretty, None).map_err(|x| PlatformError(x.0))?;
450 (v, pretty)
451 }
452 };
453 let package_name = format!("ext-{}", e.name.to_lowercase().replace(' ', "-"));
454 let mut p = Package::new(&package_name, &version, &pretty, Origin::Platform);
455 p.package_type = "php-ext".to_owned();
456 p.raw = serde_json::json!({"description": format!("The {} PHP extension{extra_description}", e.name)});
457 if e.name == "uuid" {
458 p.replaces.insert(Link::new(
459 "ext-uuid",
460 "lib-uuid",
461 Constraint::new(Op::Eq, version.clone()),
462 &pretty,
463 LinkType::Replace,
464 ));
465 }
466 add(&mut packages, p)?;
467 }
468 "lib" => {
469 let Ok(version) = normalize(&e.version, None) else {
470 continue;
471 };
472 let lib_name = format!("lib-{}", e.name);
473 if !is_platform_package(&lib_name) || libraries.contains_key(&lib_name) {
474 continue;
475 }
476 libraries.insert(lib_name.clone(), true);
477 let description = e
478 .description
479 .clone()
480 .unwrap_or_else(|| format!("The {} library", e.name));
481 let mut p = Package::new(&lib_name, &version, &e.version, Origin::Platform);
482 p.raw = serde_json::json!({"description": description});
483 let mut replaces = Links::default();
484 for r in &e.replaces {
485 let r = r.to_lowercase();
486 replaces.insert(Link {
488 key: Some(r.clone()),
489 source: lib_name.clone(),
490 target: format!("lib-{r}"),
491 constraint: Constraint::new(Op::Eq, version.clone()),
492 pretty_constraint: e.version.clone(),
493 kind: LinkType::Replace,
494 });
495 }
496 let mut provides = Links::default();
497 for pr in &e.provides {
498 let pr = pr.to_lowercase();
499 provides.insert(Link {
500 key: Some(pr.clone()),
501 source: lib_name.clone(),
502 target: format!("lib-{pr}"),
503 constraint: Constraint::new(Op::Eq, version.clone()),
504 pretty_constraint: e.version.clone(),
505 kind: LinkType::Provide,
506 });
507 }
508 p.replaces = replaces;
509 p.provides = provides;
510 add(&mut packages, p)?;
511 }
512 _ => {}
513 }
514 }
515 Ok(packages)
516}
517
518pub fn check_install(
527 lock: &vivacity_core::lock::Lock,
528 platform: &[Package],
529 with_dev: bool,
530 ignored: &[String],
531) -> Vec<vivacity_core::platform::PlatformFailure> {
532 use vivacity_core::platform::{FailureReason, PlatformFailure};
533 let mut reqs: Vec<(String, String, Option<String>)> = Vec::new();
534 for (name, cons) in &lock.platform {
535 reqs.push((name.clone(), cons.clone(), None));
536 }
537 if with_dev {
538 for (name, cons) in &lock.platform_dev {
539 reqs.push((name.clone(), cons.clone(), None));
540 }
541 }
542 for p in lock.wanted_packages(with_dev) {
543 if let Some(require) = p.raw.get("require").and_then(Value::as_object) {
544 for (name, cons) in require {
545 let lname = name.to_ascii_lowercase();
546 if is_platform_package(&lname) {
547 if let Some(c) = cons.as_str() {
548 reqs.push((lname, c.to_owned(), Some(p.name().to_owned())));
549 }
550 }
551 }
552 }
553 }
554 let mut failures = Vec::new();
555 for (requirement, cons, required_by) in reqs {
556 if vivacity_core::platform::is_ignored(&requirement, ignored) {
557 continue;
558 }
559 let mut candidates: Vec<(String, String)> = Vec::new();
561 for p in platform {
562 if p.name == requirement {
563 candidates.push((p.version.clone(), p.pretty_version.clone()));
564 }
565 for link in p.provides.iter().chain(p.replaces.iter()) {
566 if link.target == requirement {
567 let v = match &link.constraint {
568 Constraint::Single {
569 op: Op::Eq,
570 version,
571 } => version.clone(),
572 _ => continue,
573 };
574 candidates.push((v, link.pretty_constraint.clone()));
575 }
576 }
577 }
578 if candidates.is_empty() {
579 failures.push(PlatformFailure {
580 requirement,
581 constraint: cons,
582 required_by,
583 reason: FailureReason::Missing,
584 });
585 continue;
586 }
587 let Ok(parsed) = crate::constraint::parse_constraints(&cons) else {
588 failures.push(PlatformFailure {
589 requirement,
590 constraint: cons,
591 required_by,
592 reason: FailureReason::Unsupported,
593 });
594 continue;
595 };
596 if !candidates
597 .iter()
598 .any(|(v, _)| parsed.constraint.matches_version(v))
599 {
600 failures.push(PlatformFailure {
601 requirement,
602 constraint: cons,
603 required_by,
604 reason: FailureReason::Mismatch {
605 installed: candidates[0].1.trim_start_matches("== ").to_owned(),
606 },
607 });
608 }
609 }
610 failures
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616
617 fn probed() -> Vec<Value> {
619 vec![
620 serde_json::json!({"kind": "php", "name": "php", "version": "8.2.5", "description": "The PHP interpreter"}),
621 serde_json::json!({"kind": "php", "name": "php-64bit", "version": "8.2.5", "description": "x"}),
622 serde_json::json!({"kind": "ext", "name": "mbstring", "version": "8.2.5"}),
623 serde_json::json!({"kind": "ext", "name": "intl", "version": "8.2.5"}),
624 ]
625 }
626
627 fn platform_with(overrides: Value) -> Vec<Package> {
628 let empty = Map::new();
629 let overrides = overrides.as_object().unwrap_or(&empty).clone();
630 platform_packages(&probed(), &overrides).expect("platform")
631 }
632
633 fn lock(platform: Value, packages: Value) -> vivacity_core::lock::Lock {
634 vivacity_core::lock::Lock::parse(
635 &serde_json::json!({ "packages": packages, "platform": platform }).to_string(),
636 )
637 .expect("lock")
638 }
639
640 #[test]
641 fn satisfied_lock_passes() {
642 let l = lock(
643 serde_json::json!({"php": ">=8.1", "ext-mbstring": "*"}),
644 serde_json::json!([{"name":"a/b","version":"1.0","require":{"php":"^8.0","ext-intl":"*"}}]),
645 );
646 assert!(check_install(&l, &platform_with(Value::Null), true, &[]).is_empty());
647 }
648
649 #[test]
650 fn reports_mismatch_and_missing() {
651 use vivacity_core::platform::FailureReason;
652 let l = lock(
653 serde_json::json!({"php": ">=8.3", "ext-gd": "*", "lib-icu": ">=70"}),
654 serde_json::json!([]),
655 );
656 let f = check_install(&l, &platform_with(Value::Null), true, &[]);
657 assert_eq!(f.len(), 3, "{f:?}");
658 assert_eq!(
659 f[0].reason,
660 FailureReason::Mismatch {
661 installed: "8.2.5".to_owned()
662 }
663 );
664 assert_eq!(f[1].reason, FailureReason::Missing);
665 assert_eq!(f[2].reason, FailureReason::Missing);
666 }
667
668 #[test]
669 fn package_requirements_are_checked_and_attributed() {
670 let l = lock(
671 serde_json::json!({}),
672 serde_json::json!([{"name":"a/b","version":"1.0","require":{"php":">=8.3","some/dep":"^1.0"}}]),
673 );
674 let f = check_install(&l, &platform_with(Value::Null), true, &[]);
675 assert_eq!(f.len(), 1);
676 assert_eq!(f[0].required_by.as_deref(), Some("a/b"));
677 assert_eq!(f[0].requirement, "php");
678 }
679
680 #[test]
681 fn ignore_patterns_work() {
682 let l = lock(
683 serde_json::json!({"php": ">=9.0", "ext-gd": "*"}),
684 serde_json::json!([]),
685 );
686 let p = platform_with(Value::Null);
687 assert_eq!(check_install(&l, &p, true, &[]).len(), 2);
688 assert!(check_install(&l, &p, true, &["*".to_owned()]).is_empty());
689 assert_eq!(check_install(&l, &p, true, &["ext-*".to_owned()]).len(), 1);
690 assert_eq!(check_install(&l, &p, true, &["php".to_owned()]).len(), 1);
691 assert_eq!(
692 check_install(&l, &p, true, &["ext-gd+".to_owned()]).len(),
693 1
694 );
695 }
696
697 #[test]
698 fn config_platform_overrides_apply_to_the_check() {
699 let l = lock(
700 serde_json::json!({"php": ">=8.3", "ext-gd": "*", "ext-intl": "*"}),
701 serde_json::json!([]),
702 );
703 let p = platform_with(
704 serde_json::json!({"php": "8.3.0", "ext-gd": "8.3.0", "ext-intl": false}),
705 );
706 let f = check_install(&l, &p, true, &[]);
707 assert_eq!(f.len(), 1, "{f:?}");
708 assert_eq!(f[0].requirement, "ext-intl");
709 }
710
711 #[test]
712 fn probe_cache_watches_every_ini_file_and_the_scan_dir() {
713 let dir = tempfile::tempdir().expect("tempdir");
714 let ini = dir.path().join("php.ini");
715 std::fs::write(&ini, "memory_limit=1G\n").expect("write");
716 let scan = dir.path().join("conf.d");
717 std::fs::create_dir(&scan).expect("mkdir");
718 let ext = scan.join("ext-mbstring.ini");
719 std::fs::write(&ext, "extension=mbstring\n").expect("write");
720 let probed = vec![serde_json::json!({
721 "kind": "ini", "name": "ini", "version": "",
722 "loaded": ini.to_string_lossy(),
723 "scanned": format!("{},\n{}", ext.to_string_lossy(), ext.to_string_lossy()),
724 "scan_dir": scan.to_string_lossy(),
725 })];
726 let deps = ini_dependencies(&probed);
727 let paths: Vec<&str> = deps.iter().map(|f| f.path.as_str()).collect();
728 assert_eq!(
729 paths,
730 vec![
731 ini.to_string_lossy().as_ref(),
732 scan.to_string_lossy().as_ref(),
733 ext.to_string_lossy().as_ref(),
734 ext.to_string_lossy().as_ref()
735 ]
736 );
737 assert!(deps
738 .iter()
739 .all(|f| f.mtime_ns.is_some() && f.size.is_some()));
740 assert!(deps.iter().all(|f| FileId::of(&f.path) == *f));
742 std::fs::write(&ext, "extension=mbstring\nextension=intl\n").expect("write");
744 assert!(deps.iter().any(|f| FileId::of(&f.path) != *f));
745 std::fs::remove_file(&ext).expect("rm");
746 assert_eq!(FileId::of(&ext.to_string_lossy()).mtime_ns, None);
747 let probed = vec![
750 serde_json::json!({"kind": "ini", "loaded": "", "scanned": null,
751 "scan_dir": scan.to_string_lossy()}),
752 ];
753 assert_eq!(ini_dependencies(&probed).len(), 1);
754 }
755
756 #[test]
757 fn locate_php_resolves_an_explicit_path_only_when_executable() {
758 let dir = tempfile::tempdir().expect("tempdir");
759 let plain = dir.path().join("php");
760 std::fs::write(&plain, "").expect("write");
761 #[cfg(unix)]
762 {
763 assert_eq!(locate_php(&plain.to_string_lossy()), None);
764 use std::os::unix::fs::PermissionsExt;
765 std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o755))
766 .expect("chmod");
767 }
768 assert!(locate_php(&plain.to_string_lossy()).is_some());
769 assert_eq!(
770 locate_php(&dir.path().join("missing").to_string_lossy()),
771 None
772 );
773 }
774
775 #[test]
776 fn platform_names() {
777 assert!(is_platform_package("php"));
778 assert!(is_platform_package("ext-mbstring"));
779 assert!(is_platform_package("lib-icu-cldr"));
780 assert!(is_platform_package("composer-plugin-api"));
781 assert!(!is_platform_package("php-http/discovery"));
782 assert!(!is_platform_package("ext-"));
783 }
784
785 #[test]
786 fn builds_from_a_probe_with_overrides() {
787 let probed = vec![
788 serde_json::json!({"kind": "php", "name": "php", "version": "8.5.10", "description": "The PHP interpreter"}),
789 serde_json::json!({"kind": "php", "name": "php-64bit", "version": "8.5.10", "description": "x"}),
790 serde_json::json!({"kind": "ext", "name": "Zend OPcache", "version": "8.5.10"}),
791 serde_json::json!({"kind": "ext", "name": "weird", "version": "not a version"}),
792 serde_json::json!({"kind": "lib", "name": "libsodium", "version": "1.0.20", "replaces": [], "provides": []}),
793 serde_json::json!({"kind": "lib", "name": "libsodium", "version": "1.0.20", "replaces": [], "provides": []}),
794 serde_json::json!({"kind": "lib", "name": "libxml", "version": "2.13.4", "description": "libxml library version", "replaces": [], "provides": ["dom-libxml"]}),
795 ];
796 let mut overrides = Map::new();
797 overrides.insert("php".into(), Value::String("8.2.0".into()));
798 overrides.insert("ext-weird".into(), Value::Bool(false));
799 let pk = platform_packages(&probed, &overrides).unwrap();
800 let names: Vec<&str> = pk.iter().map(|p| p.name.as_str()).collect();
801 assert_eq!(
802 names,
803 vec![
804 "php",
805 "composer",
806 "composer-plugin-api",
807 "composer-runtime-api",
808 "php-64bit",
809 "ext-zend-opcache",
810 "lib-libsodium",
811 "lib-libxml"
812 ]
813 );
814 assert_eq!(pk[0].version, "8.2.0.0");
815 assert_eq!(
816 pk[4].version, "8.2.0.0",
817 "php-64bit takes the overridden php version"
818 );
819 let link = pk[7].provides.get("dom-libxml").unwrap();
821 assert_eq!(link.target, "lib-dom-libxml");
822 assert_eq!(link.constraint.to_string(), "== 2.13.4.0");
823 assert!(pk[7].provides.get("lib-dom-libxml").is_none());
824 let mut overrides = Map::new();
826 overrides.insert("php".into(), Value::String("8.2.0".into()));
827 overrides.insert("ext-mbstring".into(), Value::String("1.0".into()));
828 overrides.insert("Ext-Json".into(), Value::String("2.0".into()));
829 let pk = platform_packages(&probed, &overrides).unwrap();
830 let names: Vec<&str> = pk.iter().take(3).map(|p| p.name.as_str()).collect();
831 assert_eq!(names, vec!["php", "ext-mbstring", "ext-json"]);
832 }
833}