use clap::Args; use mozart_core::console::{Console, Verbosity}; use std::collections::BTreeMap; use std::path::Path; #[derive(Args)] pub struct CheckPlatformReqsArgs { /// Disables checking of require-dev packages requirements #[arg(long)] pub no_dev: bool, /// Check packages from the lock file #[arg(long)] pub lock: bool, /// Output format (text, json) #[arg(short, long)] pub format: Option, } /// 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)>, } pub async fn execute( args: &CheckPlatformReqsArgs, cli: &super::Cli, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = cli.working_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, console)?; if requirements.is_empty() { // No platform requirements to check if format == "json" { console.write_stdout( &serde_json::to_string_pretty(&serde_json::json!([]))?, Verbosity::Normal, ); } return Ok(()); } // Detect real platform let platform = mozart_core::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, console)?, _ => render_text(&results, console), } if exit_code != 0 { return Err(mozart_core::exit_code::bail_silent(exit_code)); } Ok(()) } /// 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, console: &mozart_core::console::Console, ) -> anyhow::Result>> { let mut requirements: BTreeMap> = BTreeMap::new(); let dev_text = if args.no_dev { "non-dev " } else { "" }; // 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."); } console.info(&format!( "Checking {}platform requirements using the lock file", dev_text )); collect_from_lock(&lock_path, args.no_dev, &mut requirements)?; } else if installed_path.exists() { // Default: read from installed.json let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; if installed.packages.is_empty() { // Fall through to lock file with a warning console.write( &format!( "{}", mozart_core::console::warning(&format!( "No vendor dir present, checking {}platform requirements from the lock file", dev_text )) ), mozart_core::console::Verbosity::Normal, ); if !lock_path.exists() { anyhow::bail!( "No installed packages found. Run `mozart install` or `mozart update` first." ); } collect_from_lock(&lock_path, args.no_dev, &mut requirements)?; } else { console.info(&format!( "Checking {}platform requirements for packages in the vendor dir", dev_text )); collect_from_installed_data(&installed, args.no_dev, &mut requirements); } } else if lock_path.exists() { // Fallback: read from lock file console.write( &format!( "{}", mozart_core::console::warning(&format!( "No vendor dir present, checking {}platform requirements from the lock file", dev_text )) ), mozart_core::console::Verbosity::Normal, ); 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 = mozart_core::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>, ) -> anyhow::Result<()> { let lock = mozart_registry::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_data( installed: &mozart_registry::installed::InstalledPackages, no_dev: bool, requirements: &mut BTreeMap>, ) { let dev_names: indexmap::IndexSet = 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 mozart_core::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, }); } } } } } fn add_platform_requirements_from_map( require: &std::collections::BTreeMap, provider: &str, requirements: &mut BTreeMap>, ) { for (name, constraint) in require { let name_lower = name.to_lowercase(); if mozart_core::platform::is_platform_package(&name_lower) { requirements .entry(name_lower) .or_default() .push(PlatformRequirement { provider: provider.to_string(), constraint: constraint.clone(), }); } } } fn check_requirements( requirements: &BTreeMap>, platform: &[mozart_core::platform::PlatformPackage], ) -> Vec { let mut results: Vec = Vec::new(); for (name, reqs) in requirements { // 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 mozart_semver::Version::parse(&detected.version) { Ok(v) => v, Err(_) => { // Unparseable version → treat as 0.0.0 mozart_semver::Version::parse("0.0.0").unwrap() } }; let mut failed_req: Option<(String, String)> = None; for req in reqs { let constraint = match mozart_semver::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 } fn render_text(results: &[CheckResult], console: &Console) { 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!("{: { console.write_stdout( &format!( "{} {} {}", mozart_core::console::info(&padded_name), mozart_core::console::comment(&padded_version), mozart_core::console::info("success"), ), Verbosity::Normal, ); } CheckStatus::Failed => { let (constraint, provider) = result .failed_requirement .as_ref() .map(|(c, p)| (c.as_str(), p.as_str())) .unwrap_or(("", "")); console.write_stdout( &format!( "{} {} {} requires {} ({})", mozart_core::console::comment(&padded_name), mozart_core::console::comment(&padded_version), mozart_core::console::error("failed"), provider, constraint, ), Verbosity::Normal, ); } CheckStatus::Missing => { let (constraint, provider) = result .failed_requirement .as_ref() .map(|(c, p)| (c.as_str(), p.as_str())) .unwrap_or(("*", "")); console.write_stdout( &format!( "{} {} {} requires {} ({})", mozart_core::console::comment(&padded_name), mozart_core::console::comment(&padded_version), mozart_core::console::error("missing"), provider, constraint, ), Verbosity::Normal, ); } } } } fn render_json(results: &[CheckResult], console: &Console) -> anyhow::Result<()> { let json_results: Vec = 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(); console.write_stdout( &serde_json::to_string_pretty(&json_results)?, Verbosity::Normal, ); Ok(()) } #[cfg(test)] mod tests { use super::*; use mozart_core::platform::PlatformPackage; use std::collections::BTreeMap; use tempfile::tempdir; fn test_console() -> mozart_core::console::Console { mozart_core::console::Console::new(0, true, false, true, true) } fn make_platform(entries: &[(&str, &str)]) -> Vec { entries .iter() .map(|(name, version)| PlatformPackage { name: name.to_string(), version: version.to_string(), }) .collect() } fn make_requirements( entries: &[(&str, &str, &str)], ) -> BTreeMap> { let mut map: BTreeMap> = 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)], dev_packages: &[(&str, BTreeMap)], ) { let make_pkg = |name: &str, require: BTreeMap| { serde_json::json!({ "name": name, "version": "1.0.0", "require": require, }) }; let pkgs_json: Vec = packages .iter() .map(|(name, req)| make_pkg(name, req.clone())) .collect(); let dev_pkgs_json: Vec = 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] fn test_is_platform_package() { assert!(mozart_core::platform::is_platform_package("php")); assert!(mozart_core::platform::is_platform_package("ext-json")); assert!(mozart_core::platform::is_platform_package("ext-mbstring")); assert!(mozart_core::platform::is_platform_package("lib-pcre")); assert!(mozart_core::platform::is_platform_package("php-64bit")); assert!(mozart_core::platform::is_platform_package( "composer-plugin-api" )); assert!(mozart_core::platform::is_platform_package( "composer-runtime-api" )); assert!(!mozart_core::platform::is_platform_package( "monolog/monolog" )); assert!(!mozart_core::platform::is_platform_package("psr/log")); assert!(!mozart_core::platform::is_platform_package( "symfony/console" )); } #[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 console = test_console(); let reqs = collect_requirements(working_dir, &args, &console).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] 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)], ); let console = test_console(); // 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, &console).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, &console).unwrap(); assert!(reqs_with_dev.contains_key("php")); assert!( reqs_with_dev.contains_key("ext-xdebug"), "dev requirement should be included" ); } #[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 console = test_console(); let reqs = collect_requirements(working_dir, &args, &console).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] 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] 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] 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] 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] 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] fn test_output_json_format() { let results = [ 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 = 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] fn test_lib_packages_always_missing() { // lib-pcre present in platform with satisfying version → Success let requirements = make_requirements(&[("lib-pcre", ">=10.0", "vendor/pkg")]); let platform = make_platform(&[("php", "8.2.1"), ("lib-pcre", "10.42")]); let results = check_requirements(&requirements, &platform); assert_eq!(results.len(), 1); assert_eq!( results[0].status, CheckStatus::Success, "lib-pcre should succeed when platform has it at a satisfying version" ); } #[test] fn test_composer_api_packages_missing() { // composer-plugin-api and composer-runtime-api present in platform → Success 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"), ("composer-plugin-api", "2.6.0"), ("composer-runtime-api", "2.2.2"), ]); let results = check_requirements(&requirements, &platform); assert_eq!(results.len(), 2); for r in &results { assert_eq!( r.status, CheckStatus::Success, "{} should succeed when platform has it at a satisfying version", r.name ); } } #[test] fn test_lib_package_constraint_not_satisfied() { // lib-pcre is in platform but constraint does NOT match → Failed let requirements = make_requirements(&[("lib-pcre", ">=11.0", "vendor/pkg")]); let platform = make_platform(&[("lib-pcre", "10.42")]); let results = check_requirements(&requirements, &platform); assert_eq!(results.len(), 1); assert_eq!( results[0].status, CheckStatus::Failed, "lib-pcre should fail when detected version does not satisfy constraint" ); assert_eq!(results[0].version, "10.42"); } #[test] fn test_lib_package_not_in_platform() { // lib-pcre is NOT in platform data at all → Missing let requirements = make_requirements(&[("lib-pcre", "*", "vendor/pkg")]); let platform = make_platform(&[("php", "8.2.1")]); // lib-pcre absent let results = check_requirements(&requirements, &platform); assert_eq!(results.len(), 1); assert_eq!( results[0].status, CheckStatus::Missing, "lib-pcre should be missing when not in platform data" ); assert_eq!(results[0].version, "n/a"); } #[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); } }