use crate::core::hash::{self, CommentStyle};
use crate::core::template_versions as tv;
use crate::e2e::config::E2eConfig;
use crate::e2e::fixture::FixtureGroup;
fn php_autoload_section(pkg_namespace: &str, autoload_target: &str) -> String {
format!(
r#"
"autoload": {{
"psr-4": {{
"{}\\": "{}"
}}
}},"#,
pkg_namespace.replace('\\', "\\\\"),
autoload_target
)
}
pub(super) fn render_composer_json(
e2e_pkg_name: &str,
e2e_autoload_ns: &str,
_extension_name: &str,
pkg_namespace: &str,
autoload_target: &str,
_pkg_version: &str,
dep_mode: crate::e2e::config::DependencyMode,
) -> String {
let (require_section, autoload_section) = match dep_mode {
crate::e2e::config::DependencyMode::Registry => {
let require = format!(
r#" "require": {{
"php": ">=8.2"
}},
"require-dev": {{
"phpunit/phpunit": "{phpunit}",
"guzzlehttp/guzzle": "{guzzle}"
}},"#,
phpunit = tv::packagist::PHPUNIT,
guzzle = tv::packagist::GUZZLE,
);
(require, php_autoload_section(pkg_namespace, autoload_target))
}
crate::e2e::config::DependencyMode::Local => {
let require = format!(
r#" "require-dev": {{
"phpunit/phpunit": "{phpunit}",
"guzzlehttp/guzzle": "{guzzle}"
}},"#,
phpunit = tv::packagist::PHPUNIT,
guzzle = tv::packagist::GUZZLE,
);
(require, php_autoload_section(pkg_namespace, autoload_target))
}
};
crate::e2e::template_env::render(
"php/composer.json.jinja",
minijinja::context! {
e2e_pkg_name => e2e_pkg_name,
e2e_autoload_ns => e2e_autoload_ns,
require_section => require_section,
autoload_section => autoload_section,
},
)
}
pub(super) fn strip_version_constraint(version: &str) -> &str {
version.trim_start_matches(['^', '~', '>', '<', '='])
}
pub(super) fn render_install_sh(pkg_name: &str, extension_name: &str, pkg_version: &str) -> String {
let clean_version = strip_version_constraint(pkg_version);
let quote = crate::core::config::shell::quote_word;
let pkg_name = quote(pkg_name);
let extension_name = quote(extension_name);
let clean_version = quote(clean_version);
let pie_version = crate::core::template_versions::github_release::PIE_VERSION;
let pie_sha256 = crate::core::template_versions::github_release::PIE_PHAR_SHA256;
let header = hash::e2e_header(CommentStyle::Hash);
format!(
r#"#!/usr/bin/env bash
{header}# Installs the configured extension via PIE before `composer install` runs.
# Requires `php` on PATH; always downloads and checksum-verifies its own PIE.
# Version is alef-injected at generate time so the script is self-contained.
set -euo pipefail
# Version override: pass as $1 to test an arbitrary tag; defaults to the
# alef-pinned version from `[crates.e2e.registry.packages.php].version`.
PKG_NAME={pkg_name}
EXTENSION_NAME={extension_name}
PINNED_VERSION={clean_version}
VERSION="${{1:-$PINNED_VERSION}}"
# PIE itself is pinned to an exact release and its PHAR is checksum-verified before it is
# ever executed. This used to fetch the floating newest-release asset, so whatever upstream
# published at run time was executed unverified, and no past run could be reproduced.
# PIE_VERSION and PIE_PHAR_SHA256 are one pair in alef's template_versions registry and must
# always be bumped together.
PIE_VERSION={pie_version}
PIE_PHAR_SHA256={pie_sha256}
# The downloaded, digest-verified PHAR is the ONLY PIE this script ever runs. There is
# deliberately no "an installed `pie` already looks new enough, reuse it" branch: that
# branch made the pin and the digest apply only on machines that happened to have no PIE,
# so an arbitrary preinstalled binary ran unverified everywhere else and two machines
# executed two different PIEs. PATH is never consulted to choose the interpreter.
#
# The PHAR lands in an alef-owned, version-scoped cache directory rather than
# `~/.local/bin`, so an unconditional install cannot clobber a PIE the developer installed
# themselves. That directory is prepended to PATH so anything downstream that shells `pie`
# by name also gets the verified PHAR and not a preinstalled one further down PATH.
pie_dir="${{HOME}}/.cache/alef/pie/$PIE_VERSION"
mkdir -p "$pie_dir"
pie_tmp="$(mktemp "${{TMPDIR:-/tmp}}/pie.phar.XXXXXX")"
trap 'rm -f "$pie_tmp"' EXIT
# stderr is NOT discarded: the previous `2>/dev/null` hid the actual reason (404, TLS,
# proxy) behind a generic message every time this failed in CI.
curl -fL --output "$pie_tmp" "https://github.com/php/pie/releases/download/$PIE_VERSION/pie.phar" || {{
echo "::error::Failed to download PIE $PIE_VERSION from GitHub; ensure network access." >&2
exit 1
}}
if command -v sha256sum >/dev/null 2>&1; then
pie_actual_sha256="$(sha256sum "$pie_tmp" | awk '{{print $1}}')"
elif command -v shasum >/dev/null 2>&1; then
pie_actual_sha256="$(shasum -a 256 "$pie_tmp" | awk '{{print $1}}')"
else
# Hard failure, not a skip: a verification step that silently passes when its tool is
# missing verifies nothing, and every supported platform ships one of these two.
echo "::error::No sha256 tool (sha256sum or shasum) found; refusing to run an unverified PIE PHAR." >&2
exit 1
fi
if [[ "$pie_actual_sha256" != "$PIE_PHAR_SHA256" ]]; then
echo "::error::PIE $PIE_VERSION checksum mismatch: expected $PIE_PHAR_SHA256, got $pie_actual_sha256" >&2
exit 1
fi
install -m 0755 "$pie_tmp" "$pie_dir/pie"
PIE="$pie_dir/pie"
export PATH="$pie_dir:$PATH"
# Install the extension binary into the running PHP's extension dir.
# Always run PIE — an existence-only skip leaves a stale .so from a prior rc
# (different ABI / missing symbols) in $EXT_DIR, which then fails the verification
# step below. PIE itself is idempotent: re-installing overwrites the existing
# binary cleanly. The php.ini-append guard below prevents duplicate `extension=`
# lines so the verification step doesn't trip on "Module already loaded".
EXT_DIR="$(php -r 'echo ini_get("extension_dir");')"
# PIE's `install` has no `--version` option (it parses `--version`/`-V` as
# "print PIE's own version" and exits without installing). The target version is
# part of the package coordinate: `vendor/package:constraint`.
"$PIE" install "$PKG_NAME:$VERSION" --skip-enable-extension
# Verify the .so/.dylib/.dll exists after install (or was already present).
test -f "$EXT_DIR/$EXTENSION_NAME.so" || test -f "$EXT_DIR/$EXTENSION_NAME.dylib" || test -f "$EXT_DIR/$EXTENSION_NAME.dll"
# Enable the extension in php.ini (PIE with --skip-enable-extension doesn't do this automatically).
# Find the loaded php.ini, check if already enabled, and append if missing.
PHP_INI="$(php --ini 2>&1 | grep -m1 'Loaded Configuration File:' | awk '{{print $NF}}')"
if [[ -z "$PHP_INI" ]]; then
echo "::warning::Could not locate php.ini; extension may not be auto-loaded by default" >&2
else
if [[ ! -f "$PHP_INI" ]]; then
echo "::warning::php.ini at $PHP_INI not found; extension may not be auto-loaded by default" >&2
else
# Guard against duplicate: check if extension line already exists (uncommented).
if ! grep -Fqx "extension=$EXTENSION_NAME" "$PHP_INI"; then
printf 'extension=%s\n' "$EXTENSION_NAME" >>"$PHP_INI"
fi
fi
fi
# Export the installed extension path for downstream test runners (composer test).
# install.sh and run_tests.php run as separate processes (the canonical registry
# runner invokes `bash install.sh`, then `composer test`, in two different shells),
# so this export never reaches run_tests.php on its own -- run_tests.php recomputes
# the same path by default from `ini_get('extension_dir')` and only treats this
# variable as an explicit override. PHP extensions are `.so` on every platform PIE
# targets, including Darwin: there is no `.dylib` variant to export here.
export PIE_INSTALLED_EXTENSION_PATH="$EXT_DIR/$EXTENSION_NAME.so"
# Verify the extension loads. Use `extension_loaded()` via `php -r` instead of
# parsing `php -m` output: `php -m` is fragile when an extension is enabled via
# both php.ini *and* a conf.d drop-in (e.g. when a prior PIE install left a
# conf.d entry behind), because PHP prints "Module ... is already loaded" to
# stderr and the test harness 2>&1 capture treats it as fatal. `extension_loaded`
# checks runtime state directly and is unaffected by load source or stderr noise.
if ALEF_EXTENSION_NAME="$EXTENSION_NAME" php -r 'exit(extension_loaded(getenv("ALEF_EXTENSION_NAME")) ? 0 : 1);' 2>/dev/null; then
echo "$EXTENSION_NAME extension loaded via php.ini"
elif ALEF_EXTENSION_NAME="$EXTENSION_NAME" php -d "extension=$EXTENSION_NAME" -r 'exit(extension_loaded(getenv("ALEF_EXTENSION_NAME")) ? 0 : 1);' 2>/dev/null; then
echo "$EXTENSION_NAME extension loaded via -d flag"
else
echo "::error::$EXTENSION_NAME extension failed to load after PIE install" >&2
exit 1
fi
echo "$EXTENSION_NAME extension installed and loaded"
"#
)
}
pub(super) fn render_phpunit_xml() -> String {
crate::e2e::template_env::render("php/phpunit.xml.jinja", minijinja::context! {})
}
#[allow(dead_code)]
pub(super) fn render_app_harness(e2e_config: &E2eConfig, groups: &[FixtureGroup], pkg_path: &str) -> String {
use serde_json::json;
let mut fixtures_map = serde_json::Map::new();
for group in groups {
for fixture in &group.fixtures {
if fixture.http.is_none() {
continue;
}
let http_data = fixture.http.as_ref().unwrap();
let fixture_json = json!({
"http": {
"handler": {
"route": &http_data.handler.route,
"method": &http_data.handler.method,
"body_schema": http_data.handler.body_schema.clone(),
},
"request": {
"path": &http_data.request.path,
},
"expected_response": {
"status_code": http_data.expected_response.status_code,
"body": &http_data.expected_response.body,
"headers": &http_data.expected_response.headers,
}
}
});
fixtures_map.insert(fixture.id.clone(), fixture_json);
}
}
let fixtures_json = serde_json::to_string(&fixtures_map).unwrap_or_default();
let imports = &e2e_config.harness.imports;
let app_class = e2e_config.harness.app_class_for_lang("php");
let register_route_method = e2e_config
.harness
.register_method_idiomatic("php")
.unwrap_or_else(|| "route".to_string());
let body_schema_setter = &e2e_config.harness.body_schema_setter;
let method_enum = &e2e_config.harness.method_enum;
let run_method = e2e_config.harness.run_method_for_lang("php");
let host = &e2e_config.harness.host;
let port = e2e_config.harness.port;
let header = hash::e2e_header(CommentStyle::DoubleSlash);
let route_builder_import = if !imports.is_empty() {
let module_name = &imports[0];
module_name
.split('_')
.map(|p| {
let mut chars = p.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.join("\\")
+ "\\Php"
} else {
"App\\Php".to_string()
};
let method_enum_import = route_builder_import.clone();
let ctx = minijinja::context! {
header => header,
imports => imports,
app_class => app_class.as_deref().unwrap_or("App"),
route_builder_import => route_builder_import,
route_builder_class => "RouteBuilder",
register_route_method => register_route_method.as_str(),
route_builder_schema_setter => body_schema_setter.as_deref().unwrap_or("request_schema_json"),
method_enum_import => method_enum_import,
method_enum_class => method_enum.as_deref().unwrap_or("Method"),
run_method => run_method.as_deref().unwrap_or("run"),
response_body_field => e2e_config.harness.response_body_field.as_str(),
host => host,
port => port,
pkg_path => pkg_path,
fixtures_json => fixtures_json,
};
crate::e2e::template_env::render("php/app_harness.php.jinja", ctx)
}
fn render_env_setup_block(e2e_config: &E2eConfig) -> String {
if e2e_config.env.is_empty() {
return String::new();
}
let lines = e2e_config
.env
.iter()
.map(|(k, v)| {
format!(
"if (getenv('{}') === false) {{\n putenv('{}={}');\n $_ENV['{}'] = '{}';\n $_SERVER['{}'] = '{}';\n}}",
k, k, v, k, v, k, v
)
})
.collect::<Vec<_>>()
.join("\n\n");
format!("{}\n\n", lines)
}
pub(super) struct BootstrapOptions<'a> {
pub(super) e2e_config: &'a E2eConfig,
pub(super) pkg_path: &'a str,
pub(super) has_mock_server_fixtures: bool,
pub(super) has_file_fixtures: bool,
pub(super) test_documents_path: &'a str,
pub(super) uses_server_harness: bool,
pub(super) harness_host: &'a str,
pub(super) harness_port: u16,
}
pub(super) fn render_bootstrap(options: BootstrapOptions<'_>) -> String {
let BootstrapOptions {
e2e_config,
pkg_path,
has_mock_server_fixtures,
has_file_fixtures,
test_documents_path,
uses_server_harness,
harness_host,
harness_port,
} = options;
let header = hash::e2e_header(CommentStyle::DoubleSlash);
let env_setup = render_env_setup_block(e2e_config);
crate::e2e::template_env::render(
"php/bootstrap.php.jinja",
minijinja::context! {
header => header,
env_setup => env_setup,
pkg_path => pkg_path,
has_mock_server_fixtures => has_mock_server_fixtures,
has_file_fixtures => has_file_fixtures,
test_documents_path => test_documents_path,
uses_server_harness => uses_server_harness,
harness_host => harness_host,
harness_port => harness_port,
alt_host => &e2e_config.alt_host,
},
)
}
pub(super) fn render_run_tests_php(
extension_name: &str,
cargo_package_name: &str,
binding_crate_dir: &str,
pkg_version: &str,
dep_mode: crate::e2e::config::DependencyMode,
) -> anyhow::Result<String> {
let resolve_pie_from_extension_dir = if dep_mode == crate::e2e::config::DependencyMode::Registry {
"true"
} else {
"false"
};
let header = hash::e2e_header(CommentStyle::DoubleSlash);
let ext_lib_name = format!("lib{}", cargo_package_name.replace('-', "_"));
let crate_local_target = format!("{}/target/release/", binding_crate_dir.trim_end_matches('/'));
let version_mismatch_condition = php_version_mismatch_condition(pkg_version)?;
const TEMPLATE: &str = r#"#!/usr/bin/env php
<?php
__HEADER__
declare(strict_types=1);
// Determine platform-specific extension suffix.
$extSuffix = match (PHP_OS_FAMILY) {
'Darwin' => '.dylib',
default => '.so',
};
$repoRoot = __DIR__ . '/../../';
$localExtCandidates = [
$repoRoot . 'target/release/__EXT_LIB_NAME__' . $extSuffix,
$repoRoot . '__CRATE_LOCAL_TARGET____EXT_LIB_NAME__' . $extSuffix,
];
$localExtPath = $localExtCandidates[0];
foreach ($localExtCandidates as $candidate) {
if (file_exists($candidate)) {
$localExtPath = $candidate;
break;
}
}
$extPath = $localExtPath;
// Check for a PIE-installed extension path (registry mode). install.sh and this
// script run as separate processes -- the canonical registry runner is `bash
// install.sh`, then `composer test`, in two different shells -- so an `export`
// in install.sh's process never reaches this one. PIE_INSTALLED_EXTENSION_PATH
// is still honored as an explicit override when a caller arranges to pass it
// through, but in registry mode the path is recomputed by default directly
// from the running PHP's own `extension_dir`, exactly as install.sh derived it.
// A local-mode harness never defaults to it: the build under test is the cargo
// cdylib above, and a copy of an earlier release in `extension_dir` is stale.
// PHP extensions are `.so` on every PIE-relevant platform, including Darwin --
// unlike the cargo cdylib above, there is no `.dylib` variant to consider here.
$resolvePieFromExtensionDir = __RESOLVE_PIE_FROM_EXTENSION_DIR__;
$pieInstalledExtPath = getenv('PIE_INSTALLED_EXTENSION_PATH');
if (!$pieInstalledExtPath && $resolvePieFromExtensionDir) {
$registryExtDir = rtrim((string) ini_get('extension_dir'), '/');
if ($registryExtDir !== '') {
$pieInstalledExtPath = $registryExtDir . '/__EXTENSION_NAME__.so';
}
}
if ($pieInstalledExtPath && file_exists($pieInstalledExtPath)) {
$extPath = $pieInstalledExtPath;
}
// Neither a local release build nor a PIE-installed extension was found. Fail
// loudly instead of falling through to PHPUnit: the ambient php.ini may still
// register a system-installed copy of this extension (e.g. from a previous
// release), and silently testing against it exercises stale, uncontrolled
// code instead of this checkout. ~keep
if (!file_exists($extPath)) {
fwrite(STDERR, "error: no __EXTENSION_NAME__ PHP extension build found.\n");
foreach ($localExtCandidates as $candidate) {
fwrite(STDERR, " looked for a local build at: $candidate\n");
}
$pieDisplay = $pieInstalledExtPath !== false ? $pieInstalledExtPath : '(unset)';
fwrite(STDERR, " looked for PIE_INSTALLED_EXTENSION_PATH at: $pieDisplay\n");
fwrite(STDERR, "Build it locally with:\n");
fwrite(STDERR, " cargo build --release -p __CARGO_PACKAGE_NAME__\n");
exit(1);
}
// Build an isolated php.ini carrying every ambient ini directive EXCEPT any
// `extension=`/`zend_extension=` line naming this extension. This runs the
// preflight check and PHPUnit under a config where only this build's own
// `-d extension=` can register the extension, instead of racing an ambient
// php.ini entry that would otherwise register it first. ~keep
function alef_build_isolated_ini(string $php, string $extensionName): string {
$iniInfo = [];
exec(escapeshellarg($php) . ' --ini 2>&1', $iniInfo);
$mainIni = null;
$scanDir = null;
foreach ($iniInfo as $line) {
if (preg_match('/^Loaded Configuration File:\s*(.+)$/', $line, $m)) {
$value = trim($m[1]);
$mainIni = ($value !== '' && $value !== '(none)') ? $value : null;
}
if (preg_match('/^Scan for additional \.ini files in:\s*(.+)$/', $line, $m)) {
$value = trim($m[1]);
$scanDir = ($value !== '' && $value !== '(none)') ? $value : null;
}
}
$sourceFiles = [];
if ($mainIni !== null && is_file($mainIni)) {
$sourceFiles[] = $mainIni;
}
if ($scanDir !== null && is_dir($scanDir)) {
$confFiles = glob($scanDir . '/*.ini') ?: [];
sort($confFiles);
$sourceFiles = array_merge($sourceFiles, $confFiles);
}
$extensionPattern = '/^\s*(?:zend_)?extension\s*=\s*(.+?)\s*$/i';
$lines = [];
foreach ($sourceFiles as $sourceFile) {
$content = file_get_contents($sourceFile);
if ($content === false) {
continue;
}
foreach (preg_split('/\R/', $content) as $line) {
if (preg_match($extensionPattern, $line, $m)) {
$value = trim($m[1], " \t\"'");
$base = basename($value);
$base = preg_replace('/\.(so|dylib|dll)$/i', '', $base);
if (strcasecmp($base, $extensionName) === 0) {
continue;
}
}
$lines[] = $line;
}
}
$isolatedIni = tempnam(sys_get_temp_dir(), 'alef-php-ini-');
file_put_contents($isolatedIni, implode("\n", $lines) . "\n");
return $isolatedIni;
}
// Indent every line of $text by four spaces, for nesting a captured
// subprocess stream inside a `fwrite(STDERR, ...)` diagnostic block.
function alef_indent_lines(string $text): string {
return implode("\n ", explode("\n", trim($text)));
}
$php = PHP_BINARY;
$isolatedIni = alef_build_isolated_ini($php, '__EXTENSION_NAME__');
register_shutdown_function(static function () use ($isolatedIni): void {
if (is_file($isolatedIni)) {
unlink($isolatedIni);
}
});
$phpConfigArgs = ['-n', '-c', $isolatedIni, '-d', 'extension=' . $extPath];
// Preflight: prove the isolated invocation actually loads THIS build before
// trusting it for the real test run. Stdout and stderr are captured
// separately so an "already loaded" startup warning is caught even though it
// does not by itself affect the exit code. ~keep
$preflightScript =
'$name = getenv("ALEF_PREFLIGHT_EXT_NAME"); ' .
'if (!extension_loaded($name)) { fwrite(STDERR, "not-loaded"); exit(2); } ' .
'echo phpversion($name);';
$preflightCmd = array_merge(
[$php],
$phpConfigArgs,
['-r', $preflightScript]
);
$descriptorSpec = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open(
$preflightCmd,
$descriptorSpec,
$pipes,
null,
['ALEF_PREFLIGHT_EXT_NAME' => '__EXTENSION_NAME__'] + $_ENV,
['bypass_shell' => true]
);
if ($process === false) {
fwrite(STDERR, "error: failed to spawn PHP preflight check for the __EXTENSION_NAME__ extension.\n");
exit(1);
}
$preflightStdout = stream_get_contents($pipes[1]);
$preflightStderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$preflightExit = proc_close($process);
if (stripos($preflightStderr, 'already loaded') !== false) {
fwrite(STDERR, "error: the __EXTENSION_NAME__ extension is already loaded from an ambient php.ini\n");
fwrite(STDERR, " entry before the isolated harness could load $extPath.\n");
fwrite(STDERR, " preflight stderr:\n");
fwrite(STDERR, " " . alef_indent_lines($preflightStderr) . "\n");
fwrite(STDERR, "This means an unrelated, previously installed copy of the extension is\n");
fwrite(STDERR, "winning over this checkout's build. Remove or disable it from php.ini /\n");
fwrite(STDERR, "conf.d before running the suite.\n");
exit(1);
}
if ($preflightExit !== 0 || $preflightStdout === '') {
fwrite(STDERR, "error: the __EXTENSION_NAME__ extension did not load from $extPath.\n");
fwrite(STDERR, " preflight exit code: $preflightExit\n");
if ($preflightStderr !== '') {
fwrite(STDERR, " preflight stderr:\n");
fwrite(STDERR, " " . alef_indent_lines($preflightStderr) . "\n");
}
exit(1);
}
$loadedVersion = $preflightStdout;
if (__VERSION_MISMATCH_CONDITION__) {
fwrite(STDERR, "error: loaded __EXTENSION_NAME__ extension version mismatch.\n");
fwrite(STDERR, " extension at $extPath reports version: $loadedVersion\n");
fwrite(STDERR, " expected version: __PKG_VERSION__\n");
fwrite(STDERR, "Rebuild it with:\n");
fwrite(STDERR, " cargo build --release -p __CARGO_PACKAGE_NAME__\n");
exit(1);
}
// Hand the verified extension path to the test process so anything it spawns can load the
// same file. bootstrap.php needs this for the app harness: this process runs under `-n`, so
// a child started as a bare PHP_BINARY would inherit no extension at all. `passthru` below
// passes the environment through, and exporting the path that was just verified is what
// keeps the harness and the tests on one build rather than two discovery attempts.
putenv('ALEF_PHP_EXTENSION_PATH=' . $extPath);
// Invoke PHPUnit through the same isolated, verified configuration.
$phpunitPath = __DIR__ . '/vendor/bin/phpunit';
if (!file_exists($phpunitPath)) {
echo "PHPUnit not found at $phpunitPath. Run 'composer install' first.\n";
exit(1);
}
$cmd = array_merge(
[$php],
$phpConfigArgs,
[$phpunitPath],
array_slice($GLOBALS['argv'], 1)
);
passthru(implode(' ', array_map('escapeshellarg', $cmd)), $exitCode);
exit($exitCode);
"#;
Ok(TEMPLATE
.replace("__HEADER__", &header)
.replace("__EXT_LIB_NAME__", &ext_lib_name)
.replace("__CRATE_LOCAL_TARGET__", &crate_local_target)
.replace("__VERSION_MISMATCH_CONDITION__", &version_mismatch_condition)
.replace("__RESOLVE_PIE_FROM_EXTENSION_DIR__", resolve_pie_from_extension_dir)
.replace("__EXTENSION_NAME__", extension_name)
.replace("__PKG_VERSION__", pkg_version)
.replace("__CARGO_PACKAGE_NAME__", cargo_package_name))
}
fn php_version_mismatch_condition(pkg_version: &str) -> anyhow::Result<String> {
const OPERATORS: [&str; 5] = [">=", "<=", "!=", ">", "<"];
let trimmed = pkg_version.trim();
for operator in OPERATORS {
if let Some(version) = trimmed.strip_prefix(operator) {
let version = version.trim();
let negated = match operator {
">=" => "<",
"<=" => ">",
">" => "<=",
"<" => ">=",
_ => "==",
};
return Ok(format!("version_compare($loadedVersion, '{version}', '{negated}')"));
}
}
let exact = trimmed.strip_prefix('=').unwrap_or(trimmed).trim();
if exact.starts_with(|c: char| c.is_ascii_digit()) && !exact.contains([' ', '|', '*', ',']) {
return Ok(format!("$loadedVersion !== '{exact}'"));
}
anyhow::bail!(
"php e2e: configured package version `{pkg_version}` is a constraint this runner cannot \
evaluate. Configure an exact version, or a single `>=`/`<=`/`>`/`<` comparison. Emitting \
a string comparison against a constraint produces a runner that can never pass and \
blames the extension for it."
)
}
#[cfg(test)]
mod tests;