diff options
Diffstat (limited to 'crates/mozart/src/platform.rs')
| -rw-r--r-- | crates/mozart/src/platform.rs | 351 |
1 files changed, 351 insertions, 0 deletions
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")); + } +} |
