use clap::Args; use mozart_core::console::Console; use mozart_core::console_writeln; use mozart_core::console_writeln_error; use mozart_core::installer::{InstalledCandidate, InstalledRepoLite}; 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, value_parser = ["text", "json"], default_value = "text")] pub format: String, } /// One `require` link, mirroring `Composer\Package\Link`. /// /// Composer's `Link` carries the requiring package's name (`source`), the /// target package name, the link description (`requires` / `provides` / /// `replaces` / etc.), and a `Constraint` object plus its pretty-printed /// form. `check-platform-reqs` only ever produces "requires" links, but the /// `description` field is kept for parity with the JSON shape that exposes /// `link->getDescription()` to consumers. #[derive(Debug, Clone)] struct Link { source: String, target: String, description: &'static str, constraint: String, pretty_constraint: String, } #[derive(Debug, Clone, PartialEq, Eq)] enum Status { Success, Failed, Missing, } /// Mirrors PHP's per-row tuple /// `[$platformPackage, $version, $link, $status, $provider]`. #[derive(Debug, Clone)] struct CheckRow { platform_package: String, version: String, link: Option, status: Status, provider: String, } pub async fn execute( args: &CheckPlatformReqsArgs, cli: &super::Cli, console: &Console, ) -> anyhow::Result<()> { let working_dir = cli.working_dir()?; let composer_json_path = working_dir.join("composer.json"); if !composer_json_path.exists() { anyhow::bail!("No composer.json found in {}", working_dir.display()); } let format = args.format.as_str(); let dev_text = if args.no_dev { "non-dev " } else { "" }; let lock_path = working_dir.join("composer.lock"); let vendor_dir = working_dir.join("vendor"); let installed_path = vendor_dir.join("composer/installed.json"); let mut installed_repo = InstalledRepoLite::new(); let mut requires: BTreeMap> = BTreeMap::new(); if args.lock { if !lock_path.exists() { anyhow::bail!("No composer.lock found. Run `mozart install` or `mozart update` first."); } console_writeln_error!( console, "Checking {}platform requirements using the lock file", dev_text, ); load_lock(&lock_path, args.no_dev, &mut installed_repo, &mut requires)?; } else { let installed_packages_present = installed_path.exists() && !mozart_core::repository::installed::InstalledPackages::read(&vendor_dir)? .packages .is_empty(); if installed_packages_present { let installed = mozart_core::repository::installed::InstalledPackages::read(&vendor_dir)?; console_writeln_error!( console, "Checking {}platform requirements for packages in the vendor dir", dev_text, ); load_installed(&installed, args.no_dev, &mut installed_repo, &mut requires); } else { console_writeln_error!( console, "No vendor dir present, checking {}platform requirements from the lock file", dev_text, ); if lock_path.exists() { load_lock(&lock_path, args.no_dev, &mut installed_repo, &mut requires)?; } // No lock either → proceed with the root package only; final output // will reflect just the root's requires (possibly empty). } } // RootPackageRepository — Composer's `getDevRequires()` is appended to // `$requires` directly, then `$installedRepo->getPackages()` walks the // root via the `RootPackageRepository` and adds `getRequires()` (which is // the root's `require`, NOT `require-dev`). let root = mozart_core::package::read_from_file(&composer_json_path)?; if !args.no_dev { for (target, constraint) in &root.require_dev { push_platform_link(&mut requires, &root.name, target, constraint); } } for (target, constraint) in &root.require { push_platform_link(&mut requires, &root.name, target, constraint); } add_root_as_candidate(&root, &mut installed_repo); // PlatformRepository([], []) — empty overrides means "use the real // platform". Mirrors Composer's bypass of `config.platform`. for pkg in mozart_core::platform::detect_platform() { installed_repo.add_candidate(InstalledCandidate { name: pkg.name.to_lowercase(), pretty_name: pkg.name, version: pkg.version.clone(), pretty_version: pkg.version, provides: BTreeMap::new(), replaces: BTreeMap::new(), }); } let mut results: Vec = Vec::new(); let mut exit_code: i32 = 0; 'requirement: for (require_lc, links) in &requires { if !mozart_core::platform::is_platform_package(require_lc) { continue; } let candidates = installed_repo.find_with_replacers_and_providers(require_lc); if candidates.is_empty() { results.push(CheckRow { platform_package: require_lc.clone(), version: "n/a".to_string(), link: links.first().cloned(), status: Status::Missing, provider: String::new(), }); exit_code = exit_code.max(2); continue; } let mut req_results: Vec = Vec::new(); 'candidate: for candidate in &candidates { let direct = candidate.name == *require_lc; let (candidate_constraint_str, candidate_pretty) = if direct { ( format!("={}", candidate.version), candidate.pretty_version.clone(), ) } else { let cs = candidate .provides .get(require_lc) .or_else(|| candidate.replaces.get(require_lc)) .cloned() .unwrap_or_else(|| "*".to_string()); (cs.clone(), cs) }; let candidate_constraint = match mozart_semver::VersionConstraint::parse(&candidate_constraint_str) { Ok(c) => c, Err(_) => { mozart_semver::VersionConstraint::Single(mozart_semver::Constraint::Any) } }; let display_name = if direct { candidate.pretty_name.clone() } else { require_lc.clone() }; let provider = if direct { String::new() } else { format!("provided by {}", candidate.pretty_name) }; for link in links { let link_constraint = match mozart_semver::VersionConstraint::parse(&link.constraint) { Ok(c) => c, Err(_) => continue, // skip unparseable user input }; if !link_constraint.intersects(&candidate_constraint) { req_results.push(CheckRow { platform_package: display_name.clone(), version: candidate_pretty.clone(), link: Some(link.clone()), status: Status::Failed, provider: provider.clone(), }); continue 'candidate; } } // Every link's constraint intersects the candidate's — success. results.push(CheckRow { platform_package: display_name, version: candidate_pretty, link: None, status: Status::Success, provider, }); continue 'requirement; } // No candidate satisfied every link. results.extend(req_results); exit_code = exit_code.max(1); } print_table(&results, format, console)?; if exit_code != 0 { return Err(mozart_core::exit_code::bail_silent(exit_code)); } Ok(()) } fn load_lock( lock_path: &Path, no_dev: bool, repo: &mut InstalledRepoLite, requires: &mut BTreeMap>, ) -> anyhow::Result<()> { let lock = mozart_core::repository::lockfile::LockFile::read_from_file(lock_path)?; let mut all: Vec<&mozart_core::repository::lockfile::LockedPackage> = lock.packages.iter().collect(); if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev { all.extend(pkgs_dev.iter()); } for pkg in all { repo.add_candidate(InstalledCandidate { name: pkg.name.to_lowercase(), pretty_name: pkg.name.clone(), version: pkg.version.clone(), pretty_version: pkg.version.clone(), provides: pkg.provide.clone(), replaces: pkg.replace.clone(), }); for (target, constraint) in &pkg.require { push_platform_link(requires, &pkg.name, target, constraint); } } Ok(()) } fn load_installed( installed: &mozart_core::repository::installed::InstalledPackages, no_dev: bool, repo: &mut InstalledRepoLite, requires: &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; } let provides = string_map_from_extra(&pkg.extra_fields, "provide"); let replaces = string_map_from_extra(&pkg.extra_fields, "replace"); repo.add_candidate(InstalledCandidate { name: pkg.name.to_lowercase(), pretty_name: pkg.name.clone(), version: pkg.version.clone(), pretty_version: pkg.version.clone(), provides, replaces, }); if let Some(require_val) = pkg.extra_fields.get("require") && let Some(require_obj) = require_val.as_object() { for (target, constraint_val) in require_obj { let constraint = constraint_val.as_str().unwrap_or("*").to_string(); push_platform_link(requires, &pkg.name, target, &constraint); } } } } fn add_root_as_candidate( root: &mozart_core::package::RawPackageData, repo: &mut InstalledRepoLite, ) { let version = root.version.clone().unwrap_or_else(|| "1.0.0".to_string()); repo.add_candidate(InstalledCandidate { name: root.name.to_lowercase(), pretty_name: root.name.clone(), version: version.clone(), pretty_version: version, provides: root.provide.clone(), replaces: root.replace.clone(), }); } fn string_map_from_extra( extra: &BTreeMap, key: &str, ) -> BTreeMap { let mut out: BTreeMap = BTreeMap::new(); if let Some(val) = extra.get(key) && let Some(obj) = val.as_object() { for (k, v) in obj { if let Some(s) = v.as_str() { out.insert(k.clone(), s.to_string()); } } } out } fn push_platform_link( requires: &mut BTreeMap>, source: &str, target: &str, constraint: &str, ) { let target_lc = target.to_lowercase(); if !mozart_core::platform::is_platform_package(&target_lc) { return; } requires.entry(target_lc.clone()).or_default().push(Link { source: source.to_string(), target: target_lc, description: "requires", constraint: constraint.to_string(), pretty_constraint: constraint.to_string(), }); } fn print_table(results: &[CheckRow], format: &str, console: &Console) -> anyhow::Result<()> { if format == "json" { let rows: Vec = results .iter() .map(|r| { let status_str = match r.status { Status::Success => "success", Status::Failed => "failed", Status::Missing => "missing", }; let failed_requirement = r.link.as_ref().map(|l| { serde_json::json!({ "source": l.source, "type": l.description, "target": l.target, "constraint": l.pretty_constraint, }) }); let provider = if r.provider.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(r.provider.clone()) }; serde_json::json!({ "name": r.platform_package, "version": r.version, "status": status_str, "failed_requirement": failed_requirement, "provider": provider, }) }) .collect(); console_writeln!(console, "{}", &serde_json::to_string_pretty(&rows)?); return Ok(()); } if results.is_empty() { return Ok(()); } // Mozart renders a padded fixed-column variant of Symfony's // renderTable. Byte-for-byte parity with `composer check-platform-reqs` // is deferred to a workspace-wide UI follow-up (see plan §6.3). let name_width = results .iter() .map(|r| r.platform_package.len()) .max() .unwrap_or(0); let version_width = results.iter().map(|r| r.version.len()).max().unwrap_or(0); for r in results { let padded_name = format!("{: { console_writeln!( console, "{padded_name} {padded_version} {link_text} success{provider_suffix}", ); } Status::Failed => { console_writeln!( console, "{padded_name} {padded_version} {link_text} failed{provider_suffix}", ); } Status::Missing => { console_writeln!( console, "{padded_name} {padded_version} {link_text} missing{provider_suffix}", ); } } } Ok(()) } #[cfg(test)] mod tests { use super::*; use std::collections::BTreeMap; use tempfile::tempdir; fn test_console() -> Console { Console::new(0, true, false, true, true) } fn write_lock( path: &Path, packages: &[(&str, BTreeMap)], dev_packages: &[(&str, BTreeMap)], ) { write_lock_with(path, packages, dev_packages, &[]); } fn write_lock_with( path: &Path, packages: &[(&str, BTreeMap)], dev_packages: &[(&str, BTreeMap)], provides: &[(&str, BTreeMap, BTreeMap)], // (name, provide, replace) ) { let make_pkg = |name: &str, require: BTreeMap, provide: BTreeMap, replace: BTreeMap| { serde_json::json!({ "name": name, "version": "1.0.0", "require": require, "provide": provide, "replace": replace, }) }; let mut pkgs_json: Vec = packages .iter() .map(|(name, req)| make_pkg(name, req.clone(), BTreeMap::new(), BTreeMap::new())) .collect(); for (name, prov, repl) in provides { pkgs_json.push(make_pkg(name, BTreeMap::new(), prov.clone(), repl.clone())); } let dev_pkgs_json: Vec = dev_packages .iter() .map(|(name, req)| make_pkg(name, req.clone(), BTreeMap::new(), BTreeMap::new())) .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_load_lock_collects_platform_requires() { let dir = tempdir().unwrap(); let lock_path = dir.path().join("composer.lock"); 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(&lock_path, &[("vendor/pkg", pkg_require)], &[]); let mut repo = InstalledRepoLite::new(); let mut requires: BTreeMap> = BTreeMap::new(); load_lock(&lock_path, false, &mut repo, &mut requires).unwrap(); assert!(requires.contains_key("php")); assert!(requires.contains_key("ext-json")); assert!(!requires.contains_key("monolog/monolog")); let php_links = &requires["php"]; assert_eq!(php_links.len(), 1); assert_eq!(php_links[0].constraint, ">=8.1"); assert_eq!(php_links[0].source, "vendor/pkg"); } #[test] fn test_load_lock_no_dev_skips_dev_packages() { let dir = tempdir().unwrap(); let lock_path = dir.path().join("composer.lock"); 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( &lock_path, &[("vendor/prod", prod_require)], &[("vendor/devpkg", dev_require)], ); let mut repo = InstalledRepoLite::new(); let mut requires: BTreeMap> = BTreeMap::new(); load_lock(&lock_path, true, &mut repo, &mut requires).unwrap(); assert!(requires.contains_key("php")); assert!(!requires.contains_key("ext-xdebug")); let mut repo2 = InstalledRepoLite::new(); let mut requires2: BTreeMap> = BTreeMap::new(); load_lock(&lock_path, false, &mut repo2, &mut requires2).unwrap(); assert!(requires2.contains_key("ext-xdebug")); } #[test] fn test_provider_candidate_satisfies_require() { // symfony/polyfill-mbstring provides ext-mbstring at "*". // A package that requires ext-mbstring "^1.0" should succeed via the // provider — even when ext-mbstring is not detected on the platform. let mut repo = InstalledRepoLite::new(); repo.add_candidate(InstalledCandidate { name: "vendor/pkg".into(), pretty_name: "vendor/pkg".into(), version: "1.0.0".into(), pretty_version: "1.0.0".into(), provides: BTreeMap::new(), replaces: BTreeMap::new(), }); let mut polyfill_provides = BTreeMap::new(); polyfill_provides.insert("ext-mbstring".to_string(), "*".to_string()); repo.add_candidate(InstalledCandidate { name: "symfony/polyfill-mbstring".into(), pretty_name: "symfony/polyfill-mbstring".into(), version: "1.30.0".into(), pretty_version: "1.30.0".into(), provides: polyfill_provides, replaces: BTreeMap::new(), }); let candidates = repo.find_with_replacers_and_providers("ext-mbstring"); assert_eq!(candidates.len(), 1); assert_eq!(candidates[0].name, "symfony/polyfill-mbstring"); // Constraint check: the provide constraint "*" intersects "^1.0". let cand = mozart_semver::VersionConstraint::parse("*").unwrap(); let req = mozart_semver::VersionConstraint::parse("^1.0").unwrap(); assert!(req.intersects(&cand)); } #[test] fn test_replacer_candidate_satisfies_require() { let mut replaces = BTreeMap::new(); replaces.insert("ext-mbstring".to_string(), "1.0".to_string()); let mut repo = InstalledRepoLite::new(); repo.add_candidate(InstalledCandidate { name: "vendor/legacy-replacement".into(), pretty_name: "vendor/legacy-replacement".into(), version: "2.0.0".into(), pretty_version: "2.0.0".into(), provides: BTreeMap::new(), replaces, }); let candidates = repo.find_with_replacers_and_providers("ext-mbstring"); assert_eq!(candidates.len(), 1); assert_eq!(candidates[0].name, "vendor/legacy-replacement"); let cand = mozart_semver::VersionConstraint::parse("1.0").unwrap(); let req = mozart_semver::VersionConstraint::parse("^1.0").unwrap(); assert!(req.intersects(&cand)); } #[test] fn test_json_failed_requirement_is_object_with_four_keys() { let row = CheckRow { platform_package: "php".to_string(), version: "8.1.0".to_string(), link: Some(Link { source: "vendor/pkg".to_string(), target: "php".to_string(), description: "requires", constraint: ">=8.2".to_string(), pretty_constraint: ">=8.2".to_string(), }), status: Status::Failed, provider: String::new(), }; let console = test_console(); // Capture by rendering through serde directly (the print_table writer // goes to stdout via a macro — keep the assertion on the JSON shape). print_table(&[row.clone()], "json", &console).unwrap(); // Reproduce the same shape and assert key invariants. let value = serde_json::json!({ "name": row.platform_package, "version": row.version, "status": "failed", "failed_requirement": { "source": row.link.as_ref().unwrap().source, "type": row.link.as_ref().unwrap().description, "target": row.link.as_ref().unwrap().target, "constraint": row.link.as_ref().unwrap().pretty_constraint, }, "provider": serde_json::Value::Null, }); let obj = value["failed_requirement"].as_object().unwrap(); assert_eq!(obj.len(), 4); assert!(obj.contains_key("source")); assert!(obj.contains_key("type")); assert!(obj.contains_key("target")); assert!(obj.contains_key("constraint")); } #[test] fn test_json_provider_string_for_indirect_candidate() { let row = CheckRow { platform_package: "ext-mbstring".to_string(), version: "*".to_string(), link: None, status: Status::Success, provider: "provided by symfony/polyfill-mbstring".to_string(), }; let value = serde_json::json!({ "name": row.platform_package, "version": row.version, "status": "success", "failed_requirement": serde_json::Value::Null, "provider": row.provider, }); assert_eq!(value["provider"], "provided by symfony/polyfill-mbstring"); assert_eq!(value["failed_requirement"], serde_json::Value::Null); } #[test] fn test_json_status_strips_tags() { // Status emits plain "success" / "failed" / "missing" — never the // `` tag wrapper. Composer's PHP printTable explicitly // calls strip_tags(); ours never wraps in the first place. for (status, expected) in [ (Status::Success, "success"), (Status::Failed, "failed"), (Status::Missing, "missing"), ] { let row = CheckRow { platform_package: "ext-x".into(), version: "1.0".into(), link: None, status, provider: String::new(), }; let s = match row.status { Status::Success => "success", Status::Failed => "failed", Status::Missing => "missing", }; assert_eq!(s, expected); } } }