aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart/src/commands/check_platform_reqs.rs885
-rw-r--r--crates/mozart/src/commands/show.rs51
-rw-r--r--crates/mozart/src/lib.rs1
-rw-r--r--crates/mozart/src/platform.rs351
4 files changed, 1237 insertions, 51 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);
+ }
}
diff --git a/crates/mozart/src/commands/show.rs b/crates/mozart/src/commands/show.rs
index ab013e3..498d170 100644
--- a/crates/mozart/src/commands/show.rs
+++ b/crates/mozart/src/commands/show.rs
@@ -1229,7 +1229,7 @@ fn show_platform(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> {
let mut platform_packages: Vec<(String, String, String)> = Vec::new(); // (name, version, source)
// Try to detect PHP from the system
- let php_version = detect_php_version();
+ let php_version = crate::platform::detect_php_version();
// Load platform requirements from lock file if available
let lock_path = working_dir.join("composer.lock");
@@ -1264,7 +1264,7 @@ fn show_platform(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> {
}
// Detect PHP extensions if PHP is available
- let extensions = detect_php_extensions();
+ let extensions = crate::platform::detect_php_extensions();
for ext in &extensions {
let ext_name = format!("ext-{ext}");
if !platform_packages.iter().any(|(n, _, _)| *n == ext_name) {
@@ -1331,53 +1331,6 @@ fn show_platform(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> {
Ok(())
}
-/// Try to detect the installed PHP version by running `php --version`.
-fn detect_php_version() -> Option<String> {
- let output = std::process::Command::new("php")
- .arg("--version")
- .output()
- .ok()?;
-
- if !output.status.success() {
- return None;
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- // Parse "PHP 8.2.1 (cli) ..." → "8.2.1"
- let first_line = stdout.lines().next()?;
- let parts: Vec<&str> = first_line.split_whitespace().collect();
- if parts.len() >= 2 && parts[0] == "PHP" {
- Some(parts[1].to_string())
- } else {
- None
- }
-}
-
-/// Try to detect PHP extensions by running `php -m`.
-fn detect_php_extensions() -> Vec<String> {
- let output = match std::process::Command::new("php").arg("-m").output() {
- Ok(o) => o,
- Err(_) => return vec![],
- };
-
- if !output.status.success() {
- return vec![];
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- stdout
- .lines()
- .filter(|line| {
- let l = line.trim();
- !l.is_empty()
- && !l.starts_with('[')
- && l.chars()
- .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
- })
- .map(|l| l.trim().to_lowercase())
- .collect()
-}
-
// ─── Available mode ─────────────────────────────────────────────────────────
fn show_available(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> {
diff --git a/crates/mozart/src/lib.rs b/crates/mozart/src/lib.rs
index 954ef0e..2451a4a 100644
--- a/crates/mozart/src/lib.rs
+++ b/crates/mozart/src/lib.rs
@@ -9,6 +9,7 @@ pub mod lockfile;
pub mod package;
pub mod packagist;
pub mod php_scanner;
+pub mod platform;
pub mod resolver;
pub mod validation;
pub mod version;
diff --git a/crates/mozart/src/platform.rs b/crates/mozart/src/platform.rs
new file mode 100644
index 0000000..c1f187f
--- /dev/null
+++ b/crates/mozart/src/platform.rs
@@ -0,0 +1,351 @@
+// Shared platform detection module.
+//
+// Provides detection of the PHP environment (version, extensions, capabilities)
+// and helpers for identifying platform package names (php, ext-*, lib-*, etc.).
+
+// ─── Data structures ─────────────────────────────────────────────────────────
+
+/// A detected platform package with its name and version.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PlatformPackage {
+ pub name: String,
+ pub version: String,
+}
+
+// ─── Classification ──────────────────────────────────────────────────────────
+
+/// Returns true if the package name is a Composer platform package.
+///
+/// Platform packages include: php, php-*, ext-*, lib-*, composer,
+/// composer-plugin-api, composer-runtime-api.
+pub fn is_platform_package(name: &str) -> bool {
+ let lower = name.to_lowercase();
+ lower == "php"
+ || lower.starts_with("php-")
+ || lower.starts_with("ext-")
+ || lower.starts_with("lib-")
+ || lower == "composer"
+ || lower == "composer-plugin-api"
+ || lower == "composer-runtime-api"
+}
+
+// ─── Detection ───────────────────────────────────────────────────────────────
+
+/// Detect all platform packages by running a single PHP invocation.
+///
+/// Returns an empty vec if PHP is not found or not executable.
+pub fn detect_platform() -> Vec<PlatformPackage> {
+ let php_script = concat!(
+ "echo 'PHP_VERSION:' . PHP_VERSION . PHP_EOL;",
+ "echo 'PHP_INT_SIZE:' . PHP_INT_SIZE . PHP_EOL;",
+ "echo 'PHP_DEBUG:' . (PHP_DEBUG ? '1' : '0') . PHP_EOL;",
+ "echo 'PHP_ZTS:' . (defined('PHP_ZTS') && PHP_ZTS ? '1' : '0') . PHP_EOL;",
+ "echo 'IPV6:' . ((defined('AF_INET6') || @inet_pton('::') !== false) ? '1' : '0') . PHP_EOL;",
+ "echo 'EXTENSIONS:' . PHP_EOL;",
+ "foreach(get_loaded_extensions() as $e) { echo $e . ':' . (phpversion($e) ?: '0') . PHP_EOL; }"
+ );
+
+ let output = match std::process::Command::new("php")
+ .arg("-r")
+ .arg(php_script)
+ .output()
+ {
+ Ok(o) => o,
+ Err(_) => return vec![],
+ };
+
+ if !output.status.success() {
+ return vec![];
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ parse_platform_info(&stdout)
+}
+
+/// Parse the output of the PHP platform detection script.
+///
+/// Exposed for testing purposes.
+pub fn parse_platform_info(output: &str) -> Vec<PlatformPackage> {
+ let mut packages: Vec<PlatformPackage> = Vec::new();
+
+ let mut php_version = String::new();
+ let mut int_size: u8 = 0;
+ let mut php_debug = false;
+ let mut php_zts = false;
+ let mut php_ipv6 = false;
+ let mut in_extensions = false;
+
+ for line in output.lines() {
+ let line = line.trim();
+ if line.is_empty() {
+ continue;
+ }
+
+ if let Some(v) = line.strip_prefix("PHP_VERSION:") {
+ php_version = v.to_string();
+ continue;
+ }
+ if let Some(v) = line.strip_prefix("PHP_INT_SIZE:") {
+ int_size = v.parse().unwrap_or(0);
+ continue;
+ }
+ if let Some(v) = line.strip_prefix("PHP_DEBUG:") {
+ php_debug = v == "1";
+ continue;
+ }
+ if let Some(v) = line.strip_prefix("PHP_ZTS:") {
+ php_zts = v == "1";
+ continue;
+ }
+ if let Some(v) = line.strip_prefix("IPV6:") {
+ php_ipv6 = v == "1";
+ continue;
+ }
+ if line == "EXTENSIONS:" {
+ in_extensions = true;
+ continue;
+ }
+
+ if in_extensions {
+ // Format: ExtensionName:version
+ if let Some(colon_pos) = line.find(':') {
+ let ext_name = line[..colon_pos].trim().to_lowercase();
+ let ext_version = line[colon_pos + 1..].trim();
+ // Normalize: if version is "0", "false", or empty, use the PHP version
+ let version =
+ if ext_version.is_empty() || ext_version == "0" || ext_version == "false" {
+ if php_version.is_empty() {
+ "0.0.0".to_string()
+ } else {
+ php_version.clone()
+ }
+ } else {
+ ext_version.to_string()
+ };
+ packages.push(PlatformPackage {
+ name: format!("ext-{ext_name}"),
+ version,
+ });
+ }
+ }
+ }
+
+ // Build the base php entry first (so it's easy to find)
+ if !php_version.is_empty() {
+ let mut result: Vec<PlatformPackage> = Vec::new();
+
+ result.push(PlatformPackage {
+ name: "php".to_string(),
+ version: php_version.clone(),
+ });
+
+ if int_size == 8 {
+ result.push(PlatformPackage {
+ name: "php-64bit".to_string(),
+ version: php_version.clone(),
+ });
+ }
+
+ if php_debug {
+ result.push(PlatformPackage {
+ name: "php-debug".to_string(),
+ version: php_version.clone(),
+ });
+ }
+
+ if php_zts {
+ result.push(PlatformPackage {
+ name: "php-zts".to_string(),
+ version: php_version.clone(),
+ });
+ }
+
+ if php_ipv6 {
+ result.push(PlatformPackage {
+ name: "php-ipv6".to_string(),
+ version: php_version.clone(),
+ });
+ }
+
+ result.extend(packages);
+ result
+ } else {
+ packages
+ }
+}
+
+/// Try to detect the installed PHP version by running `php --version`.
+pub fn detect_php_version() -> Option<String> {
+ let output = std::process::Command::new("php")
+ .arg("--version")
+ .output()
+ .ok()?;
+
+ if !output.status.success() {
+ return None;
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ // Parse "PHP 8.2.1 (cli) ..." → "8.2.1"
+ let first_line = stdout.lines().next()?;
+ let parts: Vec<&str> = first_line.split_whitespace().collect();
+ if parts.len() >= 2 && parts[0] == "PHP" {
+ Some(parts[1].to_string())
+ } else {
+ None
+ }
+}
+
+/// Try to detect PHP extensions by running `php -m`.
+pub fn detect_php_extensions() -> Vec<String> {
+ let output = match std::process::Command::new("php").arg("-m").output() {
+ Ok(o) => o,
+ Err(_) => return vec![],
+ };
+
+ if !output.status.success() {
+ return vec![];
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ stdout
+ .lines()
+ .filter(|line| {
+ let l = line.trim();
+ !l.is_empty()
+ && !l.starts_with('[')
+ && l.chars()
+ .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
+ })
+ .map(|l| l.trim().to_lowercase())
+ .collect()
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_is_platform_package_php() {
+ assert!(is_platform_package("php"));
+ assert!(is_platform_package("PHP"));
+ }
+
+ #[test]
+ fn test_is_platform_package_php_variants() {
+ assert!(is_platform_package("php-64bit"));
+ assert!(is_platform_package("php-debug"));
+ assert!(is_platform_package("php-zts"));
+ assert!(is_platform_package("php-ipv6"));
+ }
+
+ #[test]
+ fn test_is_platform_package_ext() {
+ assert!(is_platform_package("ext-json"));
+ assert!(is_platform_package("ext-mbstring"));
+ assert!(is_platform_package("ext-ctype"));
+ }
+
+ #[test]
+ fn test_is_platform_package_lib() {
+ assert!(is_platform_package("lib-pcre"));
+ assert!(is_platform_package("lib-curl"));
+ }
+
+ #[test]
+ fn test_is_platform_package_composer() {
+ assert!(is_platform_package("composer"));
+ assert!(is_platform_package("composer-plugin-api"));
+ assert!(is_platform_package("composer-runtime-api"));
+ }
+
+ #[test]
+ fn test_is_platform_package_not_platform() {
+ assert!(!is_platform_package("monolog/monolog"));
+ assert!(!is_platform_package("psr/log"));
+ assert!(!is_platform_package("symfony/console"));
+ assert!(!is_platform_package("vendor/package"));
+ }
+
+ #[test]
+ fn test_parse_platform_info_basic() {
+ let output = "PHP_VERSION:8.2.1\nPHP_INT_SIZE:8\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:1\nEXTENSIONS:\njson:8.2.1\nctype:8.2.1\n";
+ let packages = parse_platform_info(output);
+
+ let php = packages.iter().find(|p| p.name == "php");
+ assert!(php.is_some());
+ assert_eq!(php.unwrap().version, "8.2.1");
+
+ let php64 = packages.iter().find(|p| p.name == "php-64bit");
+ assert!(php64.is_some(), "PHP_INT_SIZE=8 should produce php-64bit");
+
+ let ipv6 = packages.iter().find(|p| p.name == "php-ipv6");
+ assert!(ipv6.is_some());
+
+ let ext_json = packages.iter().find(|p| p.name == "ext-json");
+ assert!(ext_json.is_some());
+ assert_eq!(ext_json.unwrap().version, "8.2.1");
+
+ let ext_ctype = packages.iter().find(|p| p.name == "ext-ctype");
+ assert!(ext_ctype.is_some());
+ }
+
+ #[test]
+ fn test_parse_platform_info_no_debug_no_zts() {
+ let output =
+ "PHP_VERSION:8.1.0\nPHP_INT_SIZE:4\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:0\nEXTENSIONS:\n";
+ let packages = parse_platform_info(output);
+
+ assert!(packages.iter().any(|p| p.name == "php"));
+ assert!(!packages.iter().any(|p| p.name == "php-64bit"));
+ assert!(!packages.iter().any(|p| p.name == "php-debug"));
+ assert!(!packages.iter().any(|p| p.name == "php-zts"));
+ assert!(!packages.iter().any(|p| p.name == "php-ipv6"));
+ }
+
+ #[test]
+ fn test_parse_platform_info_debug_and_zts() {
+ let output =
+ "PHP_VERSION:8.3.0\nPHP_INT_SIZE:8\nPHP_DEBUG:1\nPHP_ZTS:1\nIPV6:0\nEXTENSIONS:\n";
+ let packages = parse_platform_info(output);
+
+ assert!(packages.iter().any(|p| p.name == "php-debug"));
+ assert!(packages.iter().any(|p| p.name == "php-zts"));
+ }
+
+ #[test]
+ fn test_parse_platform_info_extension_version_zero() {
+ // Extensions returning version "0" should fall back to PHP version
+ let output = "PHP_VERSION:8.2.5\nPHP_INT_SIZE:8\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:0\nEXTENSIONS:\nCore:0\n";
+ let packages = parse_platform_info(output);
+
+ let ext_core = packages.iter().find(|p| p.name == "ext-core");
+ assert!(ext_core.is_some());
+ assert_eq!(
+ ext_core.unwrap().version,
+ "8.2.5",
+ "version '0' should fall back to PHP version"
+ );
+ }
+
+ #[test]
+ fn test_parse_platform_info_no_php() {
+ // If PHP_VERSION is missing, only extensions are returned
+ let output = "EXTENSIONS:\njson:1.7\n";
+ let packages = parse_platform_info(output);
+
+ assert!(!packages.iter().any(|p| p.name == "php"));
+ assert!(packages.iter().any(|p| p.name == "ext-json"));
+ }
+
+ #[test]
+ fn test_parse_platform_info_extension_names_lowercased() {
+ let output = "PHP_VERSION:8.0.0\nPHP_INT_SIZE:8\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:0\nEXTENSIONS:\nJSON:8.0.0\nMbstring:8.0.0\n";
+ let packages = parse_platform_info(output);
+
+ assert!(packages.iter().any(|p| p.name == "ext-json"));
+ assert!(packages.iter().any(|p| p.name == "ext-mbstring"));
+ }
+}