aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart/src/commands/check_platform_reqs.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/mozart/src/commands/check_platform_reqs.rs')
-rw-r--r--crates/mozart/src/commands/check_platform_reqs.rs885
1 files changed, 883 insertions, 2 deletions
diff --git a/crates/mozart/src/commands/check_platform_reqs.rs b/crates/mozart/src/commands/check_platform_reqs.rs
index d0e6122..358df4a 100644
--- a/crates/mozart/src/commands/check_platform_reqs.rs
+++ b/crates/mozart/src/commands/check_platform_reqs.rs
@@ -1,4 +1,6 @@
use clap::Args;
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
#[derive(Args)]
pub struct CheckPlatformReqsArgs {
@@ -15,6 +17,885 @@ pub struct CheckPlatformReqsArgs {
pub format: Option<String>,
}
-pub fn execute(_args: &CheckPlatformReqsArgs, _cli: &super::Cli) -> anyhow::Result<()> {
- todo!()
+// ─── Data structures ─────────────────────────────────────────────────────────
+
+/// A single platform requirement collected from a package.
+#[derive(Debug, Clone)]
+struct PlatformRequirement {
+ /// Package that declares the requirement (e.g. "vendor/pkg" or "root")
+ provider: String,
+ /// The constraint string (e.g. ">=8.1", "^8.2", "*")
+ constraint: String,
+}
+
+/// The outcome of checking one platform package against all its requirements.
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum CheckStatus {
+ /// All constraints satisfied.
+ Success,
+ /// Platform package detected but at least one constraint failed.
+ Failed,
+ /// Platform package not detected at all.
+ Missing,
+}
+
+/// Result of checking a single platform requirement name.
+#[derive(Debug, Clone)]
+struct CheckResult {
+ name: String,
+ /// Detected version, or "n/a" if missing.
+ version: String,
+ status: CheckStatus,
+ /// The first failed constraint and its provider.
+ failed_requirement: Option<(String, String)>,
+}
+
+// ─── Main entry point ────────────────────────────────────────────────────────
+
+pub fn execute(args: &CheckPlatformReqsArgs, cli: &super::Cli) -> anyhow::Result<()> {
+ let working_dir = match &cli.working_dir {
+ Some(dir) => PathBuf::from(dir),
+ None => std::env::current_dir()?,
+ };
+
+ // Validate format
+ let format = args.format.as_deref().unwrap_or("text");
+ if format != "text" && format != "json" {
+ anyhow::bail!(
+ "Invalid format \"{}\". Supported formats: text, json",
+ format
+ );
+ }
+
+ // Require composer.json
+ let composer_json_path = working_dir.join("composer.json");
+ if !composer_json_path.exists() {
+ anyhow::bail!("No composer.json found in {}", working_dir.display());
+ }
+
+ // Collect platform requirements from all packages + root
+ let requirements = collect_requirements(&working_dir, args)?;
+
+ if requirements.is_empty() {
+ // No platform requirements to check
+ if format == "json" {
+ println!("{}", serde_json::to_string_pretty(&serde_json::json!([]))?);
+ }
+ return Ok(());
+ }
+
+ // Detect real platform
+ let platform = crate::platform::detect_platform();
+
+ // Check requirements against detected platform
+ let results = check_requirements(&requirements, &platform);
+
+ // Determine exit code
+ let exit_code = determine_exit_code(&results);
+
+ // Render output
+ match format {
+ "json" => render_json(&results)?,
+ _ => render_text(&results),
+ }
+
+ if exit_code != 0 {
+ std::process::exit(exit_code);
+ }
+
+ Ok(())
+}
+
+// ─── Requirement collection ──────────────────────────────────────────────────
+
+/// Collect platform requirements from all packages (lock/installed) plus root.
+///
+/// Returns a map of platform-package-name → list of requirements.
+fn collect_requirements(
+ working_dir: &Path,
+ args: &CheckPlatformReqsArgs,
+) -> anyhow::Result<BTreeMap<String, Vec<PlatformRequirement>>> {
+ let mut requirements: BTreeMap<String, Vec<PlatformRequirement>> = BTreeMap::new();
+
+ // Determine package source
+ let lock_path = working_dir.join("composer.lock");
+ let vendor_dir = working_dir.join("vendor");
+ let installed_path = vendor_dir.join("composer/installed.json");
+
+ if args.lock {
+ // --lock: read from composer.lock
+ if !lock_path.exists() {
+ anyhow::bail!("No composer.lock found. Run `mozart install` or `mozart update` first.");
+ }
+ collect_from_lock(&lock_path, args.no_dev, &mut requirements)?;
+ } else if installed_path.exists() {
+ // Default: read from installed.json
+ collect_from_installed(&vendor_dir, args.no_dev, &mut requirements)?;
+ } else if lock_path.exists() {
+ // Fallback: read from lock file
+ collect_from_lock(&lock_path, args.no_dev, &mut requirements)?;
+ } else {
+ anyhow::bail!(
+ "No installed packages found. Run `mozart install` or `mozart update` first."
+ );
+ }
+
+ // Always include root composer.json requirements
+ let composer_json_path = working_dir.join("composer.json");
+ let root = crate::package::read_from_file(&composer_json_path)?;
+
+ add_platform_requirements_from_map(&root.require, "root", &mut requirements);
+ if !args.no_dev {
+ add_platform_requirements_from_map(&root.require_dev, "root", &mut requirements);
+ }
+
+ Ok(requirements)
+}
+
+fn collect_from_lock(
+ lock_path: &Path,
+ no_dev: bool,
+ requirements: &mut BTreeMap<String, Vec<PlatformRequirement>>,
+) -> anyhow::Result<()> {
+ let lock = crate::lockfile::LockFile::read_from_file(lock_path)?;
+
+ for pkg in &lock.packages {
+ add_platform_requirements_from_map(&pkg.require, &pkg.name, requirements);
+ }
+
+ if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev {
+ for pkg in pkgs_dev {
+ add_platform_requirements_from_map(&pkg.require, &pkg.name, requirements);
+ }
+ }
+
+ Ok(())
+}
+
+fn collect_from_installed(
+ vendor_dir: &Path,
+ no_dev: bool,
+ requirements: &mut BTreeMap<String, Vec<PlatformRequirement>>,
+) -> anyhow::Result<()> {
+ let installed = crate::installed::InstalledPackages::read(vendor_dir)?;
+
+ let dev_names: std::collections::HashSet<String> = installed
+ .dev_package_names
+ .iter()
+ .map(|n| n.to_lowercase())
+ .collect();
+
+ for pkg in &installed.packages {
+ if no_dev && dev_names.contains(&pkg.name.to_lowercase()) {
+ continue;
+ }
+
+ // Extract require from extra_fields
+ if let Some(require_val) = pkg.extra_fields.get("require")
+ && let Some(require_obj) = require_val.as_object()
+ {
+ for (dep_name, dep_constraint_val) in require_obj {
+ let dep_lower = dep_name.to_lowercase();
+ if crate::platform::is_platform_package(&dep_lower) {
+ let constraint = dep_constraint_val.as_str().unwrap_or("*").to_string();
+ requirements
+ .entry(dep_lower)
+ .or_default()
+ .push(PlatformRequirement {
+ provider: pkg.name.clone(),
+ constraint,
+ });
+ }
+ }
+ }
+ }
+
+ Ok(())
+}
+
+fn add_platform_requirements_from_map(
+ require: &std::collections::BTreeMap<String, String>,
+ provider: &str,
+ requirements: &mut BTreeMap<String, Vec<PlatformRequirement>>,
+) {
+ for (name, constraint) in require {
+ let name_lower = name.to_lowercase();
+ if crate::platform::is_platform_package(&name_lower) {
+ requirements
+ .entry(name_lower)
+ .or_default()
+ .push(PlatformRequirement {
+ provider: provider.to_string(),
+ constraint: constraint.clone(),
+ });
+ }
+ }
+}
+
+// ─── Requirement checking ────────────────────────────────────────────────────
+
+fn check_requirements(
+ requirements: &BTreeMap<String, Vec<PlatformRequirement>>,
+ platform: &[crate::platform::PlatformPackage],
+) -> Vec<CheckResult> {
+ let mut results: Vec<CheckResult> = Vec::new();
+
+ for (name, reqs) in requirements {
+ // lib-* and composer-plugin-api / composer-runtime-api → always missing
+ if name.starts_with("lib-")
+ || name == "composer-plugin-api"
+ || name == "composer-runtime-api"
+ {
+ // Find first constraining requirement for reporting
+ let failed_req = reqs
+ .first()
+ .map(|r| (r.constraint.clone(), r.provider.clone()));
+ results.push(CheckResult {
+ name: name.clone(),
+ version: "n/a".to_string(),
+ status: CheckStatus::Missing,
+ failed_requirement: failed_req,
+ });
+ continue;
+ }
+
+ // Look up in detected platform
+ match platform.iter().find(|p| p.name == *name) {
+ None => {
+ // Not detected → missing
+ let failed_req = reqs
+ .first()
+ .map(|r| (r.constraint.clone(), r.provider.clone()));
+ results.push(CheckResult {
+ name: name.clone(),
+ version: "n/a".to_string(),
+ status: CheckStatus::Missing,
+ failed_requirement: failed_req,
+ });
+ }
+ Some(detected) => {
+ // Check all constraints
+ let detected_version = match crate::constraint::Version::parse(&detected.version) {
+ Ok(v) => v,
+ Err(_) => {
+ // Unparseable version → treat as 0.0.0
+ crate::constraint::Version::parse("0.0.0").unwrap()
+ }
+ };
+
+ let mut failed_req: Option<(String, String)> = None;
+ for req in reqs {
+ let constraint =
+ match crate::constraint::VersionConstraint::parse(&req.constraint) {
+ Ok(c) => c,
+ Err(_) => continue, // skip unparseable constraints
+ };
+ if !constraint.matches(&detected_version) {
+ failed_req = Some((req.constraint.clone(), req.provider.clone()));
+ break;
+ }
+ }
+
+ let status = if failed_req.is_some() {
+ CheckStatus::Failed
+ } else {
+ CheckStatus::Success
+ };
+
+ results.push(CheckResult {
+ name: name.clone(),
+ version: detected.version.clone(),
+ status,
+ failed_requirement: failed_req,
+ });
+ }
+ }
+ }
+
+ results
+}
+
+fn determine_exit_code(results: &[CheckResult]) -> i32 {
+ let mut code = 0;
+ for result in results {
+ match result.status {
+ CheckStatus::Failed if code < 1 => code = 1,
+ CheckStatus::Missing => code = 2,
+ _ => {}
+ }
+ }
+ code
+}
+
+// ─── Rendering ───────────────────────────────────────────────────────────────
+
+fn render_text(results: &[CheckResult]) {
+ if results.is_empty() {
+ return;
+ }
+
+ // Compute column widths
+ let name_width = results.iter().map(|r| r.name.len()).max().unwrap_or(0);
+ let version_width = results.iter().map(|r| r.version.len()).max().unwrap_or(0);
+
+ for result in results {
+ // Pad the raw strings first, then apply color so ANSI escape codes
+ // don't interfere with column alignment.
+ let padded_name = format!("{:<nw$}", result.name, nw = name_width);
+ let padded_version = format!("{:<vw$}", result.version, vw = version_width);
+
+ match result.status {
+ CheckStatus::Success => {
+ println!(
+ "{} {} {}",
+ crate::console::info(&padded_name),
+ crate::console::comment(&padded_version),
+ crate::console::info("success"),
+ );
+ }
+ CheckStatus::Failed => {
+ let (constraint, provider) = result
+ .failed_requirement
+ .as_ref()
+ .map(|(c, p)| (c.as_str(), p.as_str()))
+ .unwrap_or(("", ""));
+ println!(
+ "{} {} {} requires {} ({})",
+ crate::console::comment(&padded_name),
+ crate::console::comment(&padded_version),
+ crate::console::error("failed"),
+ provider,
+ constraint,
+ );
+ }
+ CheckStatus::Missing => {
+ let (constraint, provider) = result
+ .failed_requirement
+ .as_ref()
+ .map(|(c, p)| (c.as_str(), p.as_str()))
+ .unwrap_or(("*", ""));
+ println!(
+ "{} {} {} requires {} ({})",
+ crate::console::comment(&padded_name),
+ crate::console::comment(&padded_version),
+ crate::console::error("missing"),
+ provider,
+ constraint,
+ );
+ }
+ }
+ }
+}
+
+fn render_json(results: &[CheckResult]) -> anyhow::Result<()> {
+ let json_results: Vec<serde_json::Value> = results
+ .iter()
+ .map(|r| {
+ let status_str = match r.status {
+ CheckStatus::Success => "success",
+ CheckStatus::Failed => "failed",
+ CheckStatus::Missing => "missing",
+ };
+ let (failed_constraint, failed_provider) = match &r.failed_requirement {
+ Some((c, p)) => (
+ serde_json::Value::String(c.clone()),
+ serde_json::Value::String(p.clone()),
+ ),
+ None => (serde_json::Value::Null, serde_json::Value::Null),
+ };
+ serde_json::json!({
+ "name": r.name,
+ "version": r.version,
+ "status": status_str,
+ "failed_requirement": failed_constraint,
+ "provider": failed_provider,
+ })
+ })
+ .collect();
+
+ println!("{}", serde_json::to_string_pretty(&json_results)?);
+ Ok(())
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::platform::PlatformPackage;
+ use std::collections::BTreeMap;
+ use tempfile::tempdir;
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ fn make_platform(entries: &[(&str, &str)]) -> Vec<PlatformPackage> {
+ entries
+ .iter()
+ .map(|(name, version)| PlatformPackage {
+ name: name.to_string(),
+ version: version.to_string(),
+ })
+ .collect()
+ }
+
+ fn make_requirements(
+ entries: &[(&str, &str, &str)],
+ ) -> BTreeMap<String, Vec<PlatformRequirement>> {
+ let mut map: BTreeMap<String, Vec<PlatformRequirement>> = BTreeMap::new();
+ for (name, constraint, provider) in entries {
+ map.entry(name.to_string())
+ .or_default()
+ .push(PlatformRequirement {
+ provider: provider.to_string(),
+ constraint: constraint.to_string(),
+ });
+ }
+ map
+ }
+
+ fn write_lock(
+ path: &Path,
+ packages: &[(&str, BTreeMap<String, String>)],
+ dev_packages: &[(&str, BTreeMap<String, String>)],
+ ) {
+ let make_pkg = |name: &str, require: BTreeMap<String, String>| {
+ serde_json::json!({
+ "name": name,
+ "version": "1.0.0",
+ "require": require,
+ })
+ };
+
+ let pkgs_json: Vec<serde_json::Value> = packages
+ .iter()
+ .map(|(name, req)| make_pkg(name, req.clone()))
+ .collect();
+ let dev_pkgs_json: Vec<serde_json::Value> = dev_packages
+ .iter()
+ .map(|(name, req)| make_pkg(name, req.clone()))
+ .collect();
+
+ let lock_json = serde_json::json!({
+ "_readme": ["This file locks the dependencies"],
+ "content-hash": "abc123",
+ "packages": pkgs_json,
+ "packages-dev": dev_pkgs_json,
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": {},
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {},
+ "platform-dev": {},
+ "plugin-api-version": "2.6.0",
+ });
+
+ std::fs::write(path, serde_json::to_string_pretty(&lock_json).unwrap()).unwrap();
+ }
+
+ // ── test_is_platform_package ──────────────────────────────────────────────
+
+ #[test]
+ fn test_is_platform_package() {
+ assert!(crate::platform::is_platform_package("php"));
+ assert!(crate::platform::is_platform_package("ext-json"));
+ assert!(crate::platform::is_platform_package("ext-mbstring"));
+ assert!(crate::platform::is_platform_package("lib-pcre"));
+ assert!(crate::platform::is_platform_package("php-64bit"));
+ assert!(crate::platform::is_platform_package("composer-plugin-api"));
+ assert!(crate::platform::is_platform_package("composer-runtime-api"));
+
+ assert!(!crate::platform::is_platform_package("monolog/monolog"));
+ assert!(!crate::platform::is_platform_package("psr/log"));
+ assert!(!crate::platform::is_platform_package("symfony/console"));
+ }
+
+ // ── test_collect_requirements_from_lock ──────────────────────────────────
+
+ #[test]
+ fn test_collect_requirements_from_lock() {
+ let dir = tempdir().unwrap();
+ let working_dir = dir.path();
+
+ std::fs::write(
+ working_dir.join("composer.json"),
+ r#"{"name": "test/project", "require": {}}"#,
+ )
+ .unwrap();
+
+ let mut pkg_require = BTreeMap::new();
+ pkg_require.insert("php".to_string(), ">=8.1".to_string());
+ pkg_require.insert("ext-json".to_string(), "*".to_string());
+ pkg_require.insert("monolog/monolog".to_string(), "^3.0".to_string()); // not platform
+
+ write_lock(
+ &working_dir.join("composer.lock"),
+ &[("vendor/pkg", pkg_require)],
+ &[],
+ );
+
+ let args = CheckPlatformReqsArgs {
+ no_dev: false,
+ lock: true,
+ format: None,
+ };
+
+ let reqs = collect_requirements(working_dir, &args).unwrap();
+
+ assert!(reqs.contains_key("php"), "php should be in requirements");
+ assert!(
+ reqs.contains_key("ext-json"),
+ "ext-json should be in requirements"
+ );
+ assert!(
+ !reqs.contains_key("monolog/monolog"),
+ "monolog should not be in requirements"
+ );
+
+ let php_reqs = &reqs["php"];
+ assert_eq!(php_reqs.len(), 1);
+ assert_eq!(php_reqs[0].constraint, ">=8.1");
+ assert_eq!(php_reqs[0].provider, "vendor/pkg");
+ }
+
+ // ── test_collect_requirements_no_dev ─────────────────────────────────────
+
+ #[test]
+ fn test_collect_requirements_no_dev() {
+ let dir = tempdir().unwrap();
+ let working_dir = dir.path();
+
+ std::fs::write(
+ working_dir.join("composer.json"),
+ r#"{"name": "test/project", "require": {}}"#,
+ )
+ .unwrap();
+
+ let mut prod_require = BTreeMap::new();
+ prod_require.insert("php".to_string(), ">=8.0".to_string());
+
+ let mut dev_require = BTreeMap::new();
+ dev_require.insert("ext-xdebug".to_string(), "*".to_string());
+
+ write_lock(
+ &working_dir.join("composer.lock"),
+ &[("vendor/prod", prod_require)],
+ &[("vendor/devpkg", dev_require)],
+ );
+
+ // With --no-dev
+ let args_no_dev = CheckPlatformReqsArgs {
+ no_dev: true,
+ lock: true,
+ format: None,
+ };
+ let reqs_no_dev = collect_requirements(working_dir, &args_no_dev).unwrap();
+ assert!(reqs_no_dev.contains_key("php"));
+ assert!(
+ !reqs_no_dev.contains_key("ext-xdebug"),
+ "dev requirement should be excluded"
+ );
+
+ // Without --no-dev
+ let args_with_dev = CheckPlatformReqsArgs {
+ no_dev: false,
+ lock: true,
+ format: None,
+ };
+ let reqs_with_dev = collect_requirements(working_dir, &args_with_dev).unwrap();
+ assert!(reqs_with_dev.contains_key("php"));
+ assert!(
+ reqs_with_dev.contains_key("ext-xdebug"),
+ "dev requirement should be included"
+ );
+ }
+
+ // ── test_collect_requirements_includes_root ───────────────────────────────
+
+ #[test]
+ fn test_collect_requirements_includes_root() {
+ let dir = tempdir().unwrap();
+ let working_dir = dir.path();
+
+ std::fs::write(
+ working_dir.join("composer.json"),
+ r#"{"name": "test/project", "require": {"php": ">=8.2", "ext-ctype": "*"}}"#,
+ )
+ .unwrap();
+
+ write_lock(&working_dir.join("composer.lock"), &[], &[]);
+
+ let args = CheckPlatformReqsArgs {
+ no_dev: false,
+ lock: true,
+ format: None,
+ };
+
+ let reqs = collect_requirements(working_dir, &args).unwrap();
+
+ assert!(
+ reqs.contains_key("php"),
+ "root php requirement should be included"
+ );
+ assert!(
+ reqs.contains_key("ext-ctype"),
+ "root ext-ctype requirement should be included"
+ );
+
+ // The provider should be "root"
+ let php_reqs = &reqs["php"];
+ assert!(
+ php_reqs
+ .iter()
+ .any(|r| r.provider == "root" && r.constraint == ">=8.2")
+ );
+ }
+
+ // ── test_check_requirements_all_pass ─────────────────────────────────────
+
+ #[test]
+ fn test_check_requirements_all_pass() {
+ let requirements =
+ make_requirements(&[("php", ">=8.1", "root"), ("ext-json", "*", "vendor/pkg")]);
+ let platform = make_platform(&[("php", "8.2.1"), ("ext-json", "8.2.1")]);
+
+ let results = check_requirements(&requirements, &platform);
+ assert_eq!(results.len(), 2);
+ for r in &results {
+ assert_eq!(
+ r.status,
+ CheckStatus::Success,
+ "all should pass for {}",
+ r.name
+ );
+ }
+ assert_eq!(determine_exit_code(&results), 0);
+ }
+
+ // ── test_check_requirements_version_mismatch ─────────────────────────────
+
+ #[test]
+ fn test_check_requirements_version_mismatch() {
+ let requirements = make_requirements(&[("php", ">=8.2", "vendor/pkg")]);
+ let platform = make_platform(&[("php", "8.1.0")]);
+
+ let results = check_requirements(&requirements, &platform);
+ assert_eq!(results.len(), 1);
+ assert_eq!(results[0].status, CheckStatus::Failed);
+ assert_eq!(results[0].version, "8.1.0");
+ assert!(results[0].failed_requirement.is_some());
+ assert_eq!(determine_exit_code(&results), 1);
+ }
+
+ // ── test_check_requirements_missing ──────────────────────────────────────
+
+ #[test]
+ fn test_check_requirements_missing() {
+ let requirements = make_requirements(&[("ext-foobar", "*", "vendor/pkg")]);
+ let platform = make_platform(&[("php", "8.2.1")]); // ext-foobar not present
+
+ let results = check_requirements(&requirements, &platform);
+ assert_eq!(results.len(), 1);
+ assert_eq!(results[0].status, CheckStatus::Missing);
+ assert_eq!(results[0].version, "n/a");
+ assert_eq!(determine_exit_code(&results), 2);
+ }
+
+ // ── test_check_requirements_mixed ────────────────────────────────────────
+
+ #[test]
+ fn test_check_requirements_mixed() {
+ let requirements = make_requirements(&[
+ ("php", ">=8.1", "root"), // success
+ ("ext-json", ">=7.0", "root"), // success (version satisfied)
+ ("ext-foobar", "*", "vendor/a"), // missing
+ ]);
+ let platform = make_platform(&[("php", "8.2.1"), ("ext-json", "8.2.1")]);
+
+ let results = check_requirements(&requirements, &platform);
+
+ let php_result = results.iter().find(|r| r.name == "php").unwrap();
+ assert_eq!(php_result.status, CheckStatus::Success);
+
+ let json_result = results.iter().find(|r| r.name == "ext-json").unwrap();
+ assert_eq!(json_result.status, CheckStatus::Success);
+
+ let foobar_result = results.iter().find(|r| r.name == "ext-foobar").unwrap();
+ assert_eq!(foobar_result.status, CheckStatus::Missing);
+
+ // Exit code should be 2 (missing wins over failed which wins over success)
+ assert_eq!(determine_exit_code(&results), 2);
+ }
+
+ // ── test_check_requirements_multiple_constraints ──────────────────────────
+
+ #[test]
+ fn test_check_requirements_multiple_constraints() {
+ // Two packages both require php, one with a tighter constraint
+ let requirements = make_requirements(&[
+ ("php", ">=8.0", "vendor/a"),
+ ("php", ">=8.2", "vendor/b"), // tighter
+ ]);
+ let platform = make_platform(&[("php", "8.1.0")]); // satisfies >=8.0 but not >=8.2
+
+ let results = check_requirements(&requirements, &platform);
+ assert_eq!(results.len(), 1);
+ // The second constraint fails
+ assert_eq!(results[0].status, CheckStatus::Failed);
+ let (failed_constraint, failed_provider) = results[0].failed_requirement.as_ref().unwrap();
+ assert_eq!(failed_constraint, ">=8.2");
+ assert_eq!(failed_provider, "vendor/b");
+ }
+
+ // ── test_output_json_format ───────────────────────────────────────────────
+
+ #[test]
+ fn test_output_json_format() {
+ let results = vec![
+ CheckResult {
+ name: "php".to_string(),
+ version: "8.2.1".to_string(),
+ status: CheckStatus::Success,
+ failed_requirement: None,
+ },
+ CheckResult {
+ name: "ext-foobar".to_string(),
+ version: "n/a".to_string(),
+ status: CheckStatus::Missing,
+ failed_requirement: Some(("*".to_string(), "vendor/pkg".to_string())),
+ },
+ ];
+
+ // Capture output by writing to a string
+ let json_results: Vec<serde_json::Value> = results
+ .iter()
+ .map(|r| {
+ let status_str = match r.status {
+ CheckStatus::Success => "success",
+ CheckStatus::Failed => "failed",
+ CheckStatus::Missing => "missing",
+ };
+ let (failed_constraint, failed_provider) = match &r.failed_requirement {
+ Some((c, p)) => (
+ serde_json::Value::String(c.clone()),
+ serde_json::Value::String(p.clone()),
+ ),
+ None => (serde_json::Value::Null, serde_json::Value::Null),
+ };
+ serde_json::json!({
+ "name": r.name,
+ "version": r.version,
+ "status": status_str,
+ "failed_requirement": failed_constraint,
+ "provider": failed_provider,
+ })
+ })
+ .collect();
+
+ assert_eq!(json_results[0]["name"], "php");
+ assert_eq!(json_results[0]["version"], "8.2.1");
+ assert_eq!(json_results[0]["status"], "success");
+ assert_eq!(
+ json_results[0]["failed_requirement"],
+ serde_json::Value::Null
+ );
+
+ assert_eq!(json_results[1]["name"], "ext-foobar");
+ assert_eq!(json_results[1]["version"], "n/a");
+ assert_eq!(json_results[1]["status"], "missing");
+ assert_eq!(json_results[1]["failed_requirement"], "*");
+ assert_eq!(json_results[1]["provider"], "vendor/pkg");
+ }
+
+ // ── test_lib_packages_always_missing ─────────────────────────────────────
+
+ #[test]
+ fn test_lib_packages_always_missing() {
+ let requirements = make_requirements(&[("lib-pcre", "*", "vendor/pkg")]);
+ let platform = make_platform(&[("php", "8.2.1"), ("ext-pcre", "8.2.1")]);
+
+ let results = check_requirements(&requirements, &platform);
+ assert_eq!(results.len(), 1);
+ assert_eq!(
+ results[0].status,
+ CheckStatus::Missing,
+ "lib-* should always be missing"
+ );
+ }
+
+ // ── test_composer_api_packages_missing ───────────────────────────────────
+
+ #[test]
+ fn test_composer_api_packages_missing() {
+ let requirements = make_requirements(&[
+ ("composer-plugin-api", "^2.0", "vendor/plugin"),
+ ("composer-runtime-api", "^2.0", "vendor/plugin"),
+ ]);
+ let platform = make_platform(&[("php", "8.2.1")]);
+
+ let results = check_requirements(&requirements, &platform);
+ assert_eq!(results.len(), 2);
+ for r in &results {
+ assert_eq!(
+ r.status,
+ CheckStatus::Missing,
+ "{} should always be missing",
+ r.name
+ );
+ }
+ }
+
+ // ── test_determine_exit_code ──────────────────────────────────────────────
+
+ #[test]
+ fn test_determine_exit_code_all_success() {
+ let results = vec![CheckResult {
+ name: "php".to_string(),
+ version: "8.2.1".to_string(),
+ status: CheckStatus::Success,
+ failed_requirement: None,
+ }];
+ assert_eq!(determine_exit_code(&results), 0);
+ }
+
+ #[test]
+ fn test_determine_exit_code_failed() {
+ let results = vec![CheckResult {
+ name: "php".to_string(),
+ version: "8.1.0".to_string(),
+ status: CheckStatus::Failed,
+ failed_requirement: Some((">=8.2".to_string(), "root".to_string())),
+ }];
+ assert_eq!(determine_exit_code(&results), 1);
+ }
+
+ #[test]
+ fn test_determine_exit_code_missing() {
+ let results = vec![CheckResult {
+ name: "ext-foobar".to_string(),
+ version: "n/a".to_string(),
+ status: CheckStatus::Missing,
+ failed_requirement: Some(("*".to_string(), "vendor/pkg".to_string())),
+ }];
+ assert_eq!(determine_exit_code(&results), 2);
+ }
+
+ #[test]
+ fn test_determine_exit_code_missing_beats_failed() {
+ let results = vec![
+ CheckResult {
+ name: "php".to_string(),
+ version: "8.1.0".to_string(),
+ status: CheckStatus::Failed,
+ failed_requirement: Some((">=8.2".to_string(), "root".to_string())),
+ },
+ CheckResult {
+ name: "ext-foobar".to_string(),
+ version: "n/a".to_string(),
+ status: CheckStatus::Missing,
+ failed_requirement: Some(("*".to_string(), "vendor/pkg".to_string())),
+ },
+ ];
+ assert_eq!(determine_exit_code(&results), 2);
+ }
}