use super::{build_http_client, BlobRef, ExtRecipe, PhpRecipe};
use bougie_errors::{error_chain, BougieError};
use bougie_fetch::{fetch_blob, ArchiveKind, BlobOutcome, DownloadBar};
use bougie_index::wire::LoadDirective;
use bougie_paths::Paths;
use bougie_version::request::{Flavor, VersionLike};
use bougie_resolver::ResolveOptions;
use bougie_platform::target::{Arch, Triple};
use bougie_version::version::{PartialVersion, Version};
use eyre::{eyre, Result, WrapErr};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
const RELEASES_URL: &str = "https://windows.php.net/downloads/releases/releases.json";
const BLOB_BASE: &str = "https://windows.php.net/downloads/releases";
const PECL_BASE: &str = "https://windows.php.net/downloads/pecl/releases";
const CACHE_HOST_DIR: &str = "windows.php.net";
#[derive(Debug)]
pub struct WindowsPhpNetBackend {
client: reqwest::blocking::Client,
cache_root: PathBuf,
arch: Arch,
}
impl WindowsPhpNetBackend {
pub fn new(paths: &Paths, target: &Triple) -> Result<Self> {
let client = build_http_client("windows.php.net")?;
let cache_root = paths.cache_index(CACHE_HOST_DIR);
Ok(Self {
client,
cache_root,
arch: target.arch,
})
}
fn arch_suffix(&self) -> Result<&'static str> {
match self.arch {
Arch::X86_64 => Ok("x64"),
Arch::Aarch64 => Err(BougieError::UnknownTarget {
triple: "aarch64-pc-windows-msvc".into(),
hint: "windows.php.net does not currently publish ARM64 builds for any PHP minor; \
run bougie under x86_64 (Windows-on-ARM has working x64 emulation)"
.into(),
}
.into()),
}
}
}
impl super::Backend for WindowsPhpNetBackend {
fn client(&self) -> &reqwest::blocking::Client {
&self.client
}
fn fetch_into(
&self,
blob: &BlobRef,
install_root: &Path,
partial_dir: &Path,
bar: &DownloadBar,
) -> Result<BlobOutcome> {
let bin_dir = install_root.join("bin");
let spec = blob.as_blob_spec(partial_dir, &bin_dir);
fetch_blob(&self.client, &spec, bar)
}
fn resolve_extension(
&self,
name: &str,
php_minor: PartialVersion,
flavor: Flavor,
_version_pin: Option<&str>,
_opts: ResolveOptions,
) -> Result<ExtRecipe> {
let artifact = self.resolve_pecl(name, php_minor, flavor)?;
let dll_name = format!("php_{}.dll", artifact.name);
Ok(ExtRecipe {
name: artifact.name,
version: artifact.version,
php_minor: artifact.php_minor,
flavor: artifact.flavor,
blob: BlobRef {
url: artifact.url,
sha256: artifact.sha256,
size: 0,
archive: ArchiveKind::Zip,
strip_prefix: String::new(),
},
artifact_rel: std::path::PathBuf::from(dll_name),
load: artifact.load,
closure: Vec::new(),
needs_store_on_path: artifact.needs_store_on_path,
frozen_warning: false,
})
}
fn resolve_php(
&self,
spec: &VersionLike,
flavor: Flavor,
_opts: ResolveOptions,
) -> Result<PhpRecipe> {
let minor_key = pick_minor_key(spec)?;
let arch = self.arch_suffix()?;
let vc = vc_for_minor(&minor_key)?;
let flavor_tag = flavor_tag(flavor)?;
let entry_key = format!("{flavor_tag}-{vc}-{arch}");
let releases = fetch_releases(&self.client, &self.cache_root)?;
let minor = releases.get(&minor_key).ok_or_else(|| BougieError::Resolution {
kind: "php interpreter".into(),
detail: format!(
"windows.php.net does not publish PHP `{minor_key}`; supported minors right now: {}",
join_keys(&releases)
),
})?;
let zip = minor.flavor_zip(&entry_key).ok_or_else(|| BougieError::Resolution {
kind: "php interpreter".into(),
detail: format!(
"windows.php.net's `{minor_key}` entry is missing the `{entry_key}.zip` variant (have: {})",
minor.flavor_keys().collect::<Vec<_>>().join(", "),
),
})?;
let version: Version = minor.version.parse().wrap_err_with(|| {
format!(
"releases.json `{minor_key}.version = {}` is not a full semver",
minor.version
)
})?;
check_patch_pin(spec, version)?;
Ok(PhpRecipe {
version,
flavor,
blob: BlobRef {
url: format!("{BLOB_BASE}/{}", zip.path),
sha256: zip.sha256.clone(),
size: zip
.size
.as_deref()
.and_then(parse_human_size)
.unwrap_or(0),
archive: ArchiveKind::Zip,
strip_prefix: String::new(),
},
frozen_warning: false,
})
}
}
type Releases = BTreeMap<String, MinorEntry>;
#[derive(Debug, Deserialize)]
struct MinorEntry {
version: String,
#[serde(flatten)]
flavors: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone)]
struct ZipInfo {
path: String,
sha256: String,
size: Option<String>,
}
impl MinorEntry {
fn flavor_zip(&self, key: &str) -> Option<ZipInfo> {
let entry = self.flavors.get(key)?;
let zip = entry.get("zip")?;
let path = zip.get("path")?.as_str()?.to_owned();
let sha256 = zip.get("sha256")?.as_str()?.to_owned();
let size = zip.get("size").and_then(|v| v.as_str()).map(str::to_owned);
Some(ZipInfo { path, sha256, size })
}
fn flavor_keys(&self) -> impl Iterator<Item = &str> {
self.flavors.keys().filter_map(|k| {
if k.contains("-vs") {
Some(k.as_str())
} else {
None
}
})
}
}
fn pick_minor_key(spec: &VersionLike) -> Result<String> {
let anchor = match spec {
VersionLike::Version(pv) => *pv,
VersionLike::Constraint(c) => constraint_anchor(c).ok_or_else(|| eyre!(
"constraint `{c:?}` doesn't reduce to a single PHP minor, which is all \
windows.php.net can resolve against (releases.json exposes only the \
latest patch per minor). Try specifying a minor (`8.4`) or exact patch \
(`8.4.21`) instead, or open an issue describing the use case."
))?,
};
let minor = anchor.minor.ok_or_else(|| eyre!(
"windows.php.net needs at least <major>.<minor> (e.g. `8.4`); got `{anchor}`. \
Specify the minor — windows.php.net only ships the latest patch per minor."
))?;
Ok(format!("{}.{minor}", anchor.major))
}
fn constraint_anchor(c: &bougie_semver::Constraint) -> Option<PartialVersion> {
use bougie_semver::version::CmpOp;
match c {
bougie_semver::Constraint::Any => None,
bougie_semver::Constraint::Op { op, version, .. } => match op {
CmpOp::Ge | CmpOp::Eq | CmpOp::Le => version_major_minor(version),
CmpOp::Lt | CmpOp::Gt | CmpOp::Ne => None,
},
bougie_semver::Constraint::And(items) => items.iter().find_map(constraint_anchor),
bougie_semver::Constraint::Or(_) => None,
}
}
fn version_major_minor(v: &bougie_semver::Version) -> Option<PartialVersion> {
use bougie_semver::version::VersionKind;
let VersionKind::Numeric { segments_raw, .. } = &v.kind else {
return None;
};
let major: u32 = segments_raw.first()?.parse().ok()?;
let minor: u32 = segments_raw.get(1)?.parse().ok()?;
Some(PartialVersion {
major,
minor: Some(minor),
patch: None,
})
}
fn vc_for_minor(minor_key: &str) -> Result<&'static str> {
let (maj, min) = parse_minor(minor_key)?;
match (maj, min) {
(8, 4..) | (9.., _) => Ok("vs17"),
(8, 0..=3) => Ok("vs16"),
_ => Err(eyre!(
"PHP {minor_key} predates bougie's supported minors (8.0+); \
windows.php.net may still publish it, but bougie won't"
)),
}
}
fn parse_minor(s: &str) -> Result<(u32, u32)> {
let (maj, min) = s
.split_once('.')
.ok_or_else(|| eyre!("`{s}` is not a <major>.<minor>"))?;
Ok((maj.parse()?, min.parse()?))
}
fn parse_human_size(s: &str) -> Option<u64> {
let s = s.trim();
let split_at = s
.find(|c: char| c.is_ascii_alphabetic())
.unwrap_or(s.len());
let (num_part, unit_part) = s.split_at(split_at);
let num: f64 = num_part.trim().parse().ok()?;
if !num.is_finite() || num < 0.0 {
return None;
}
let multiplier: f64 = match unit_part.trim().to_ascii_uppercase().as_str() {
"" | "B" => 1.0,
"K" | "KB" | "KIB" => 1024.0,
"M" | "MB" | "MIB" => 1024.0 * 1024.0,
"G" | "GB" | "GIB" => 1024.0 * 1024.0 * 1024.0,
_ => return None,
};
let bytes = (num * multiplier).round();
if !bytes.is_finite() || bytes < 0.0 || bytes >= 2f64.powi(63) {
return None;
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "finite, in [0, 2^63) by the checks above"
)]
Some(bytes as u64)
}
fn flavor_tag(flavor: Flavor) -> Result<&'static str> {
match flavor {
Flavor::Nts => Ok("nts"),
Flavor::Zts => Ok("ts"),
Flavor::NtsDebug | Flavor::ZtsDebug => Err(eyre!(
"windows.php.net ships debug symbols as a separate `debug_pack` ZIP layered \
on the runtime ZIP, not as a standalone `--enable-debug` build. Bougie's \
`*-debug` flavors don't map cleanly onto that — use `nts` or `zts`, \
or open an issue if you need debug-pack overlay support."
)),
}
}
fn check_patch_pin(spec: &VersionLike, available: Version) -> Result<()> {
let VersionLike::Version(pv) = spec else {
return Ok(());
};
let Some(req_patch) = pv.patch else {
return Ok(());
};
if req_patch != available.patch {
return Err(eyre!(
"windows.php.net only ships the latest patch per minor (currently {available} for {}.{}), \
so the pin `{pv}` isn't satisfiable. Drop the patch component or upgrade to {available}.",
pv.major,
pv.minor.unwrap_or(0),
));
}
Ok(())
}
fn join_keys(r: &Releases) -> String {
r.keys().cloned().collect::<Vec<_>>().join(", ")
}
fn fetch_releases(client: &reqwest::blocking::Client, cache_root: &Path) -> Result<Releases> {
fs::create_dir_all(cache_root)
.wrap_err_with(|| format!("creating {}", cache_root.display()))?;
let body_path = cache_root.join("releases.json");
let etag_path = cache_root.join("releases.json.etag");
let cached_etag = fs::read_to_string(&etag_path).ok();
let mut req = client.get(RELEASES_URL);
if let Some(etag) = cached_etag.as_deref().filter(|s| !s.is_empty()) {
req = req.header(reqwest::header::IF_NONE_MATCH, etag.trim());
}
let resp = req.send().map_err(|e| BougieError::Network {
operation: format!("fetching {RELEASES_URL}"),
detail: error_chain(&e),
})?;
if resp.status() == reqwest::StatusCode::NOT_MODIFIED {
let bytes = fs::read(&body_path)
.wrap_err_with(|| format!("reading cached {}", body_path.display()))?;
return serde_json::from_slice(&bytes).wrap_err("parsing cached releases.json");
}
if !resp.status().is_success() {
return Err(BougieError::Network {
operation: format!("GET {RELEASES_URL}"),
detail: format!("server returned HTTP {}", resp.status()),
}
.into());
}
let new_etag = resp
.headers()
.get(reqwest::header::ETAG)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let body = resp.bytes().map_err(|e| BougieError::Network {
operation: format!("reading body of {RELEASES_URL}"),
detail: error_chain(&e),
})?;
fs::write(&body_path, &body)
.wrap_err_with(|| format!("writing {}", body_path.display()))?;
if let Some(etag) = new_etag.as_deref() {
let _ = fs::write(&etag_path, etag);
}
serde_json::from_slice(&body).wrap_err("parsing fetched releases.json")
}
#[derive(Debug)]
struct WindowsPeclVersion {
name: &'static str,
version: &'static str,
php_minor: &'static str,
flavor: &'static str,
arch: &'static str,
sha256: &'static str,
}
fn pecl_needs_store_on_path(name: &str) -> bool {
matches!(name, "imagick")
}
const WINDOWS_PECL_VERSIONS: &[WindowsPeclVersion] = &[
WindowsPeclVersion {
name: "xdebug",
version: "3.5.1",
php_minor: "8.0",
flavor: "nts",
arch: "x64",
sha256: "6105bc3ffe76c79f3a38f27a2b7d605594a68bfec42984e9ab12c17d64bac067",
},
WindowsPeclVersion {
name: "xdebug",
version: "3.5.1",
php_minor: "8.1",
flavor: "nts",
arch: "x64",
sha256: "cf5bcf99b0f64339f14c28e120d5e52c7a608ce317d1ba4b9c06b3d755eb70fc",
},
WindowsPeclVersion {
name: "xdebug",
version: "3.5.1",
php_minor: "8.2",
flavor: "nts",
arch: "x64",
sha256: "b3c1bb3c709e1f62d5e8a8b62094995663eec428f4d10136db9d96d3f3dd63b0",
},
WindowsPeclVersion {
name: "xdebug",
version: "3.5.1",
php_minor: "8.3",
flavor: "nts",
arch: "x64",
sha256: "be2e8553d51d3b048c79022cce8002e133573ad1fa33cbeaa4e823e9013faf01",
},
WindowsPeclVersion {
name: "xdebug",
version: "3.5.1",
php_minor: "8.4",
flavor: "nts",
arch: "x64",
sha256: "967cceb6aebbc5592f6aeb61e67ce2e1bef26e985a5b07efe3a622de090a70a9",
},
WindowsPeclVersion {
name: "xdebug",
version: "3.5.1",
php_minor: "8.5",
flavor: "nts",
arch: "x64",
sha256: "1f5a5ec509971c35bf738ff21ccf1e5652a223f2101ea9e3c66e79b647e06e2a",
},
WindowsPeclVersion {
name: "imagick",
version: "3.8.1",
php_minor: "8.0",
flavor: "nts",
arch: "x64",
sha256: "6d57c741e338eed606bd239e44d6ec144e54b8ff65ccf99dbb09d4b1b76b9de3",
},
WindowsPeclVersion {
name: "imagick",
version: "3.8.1",
php_minor: "8.1",
flavor: "nts",
arch: "x64",
sha256: "7297bd599f58b26ed209f9ffa373f29a2bdf6a88cacd46573ed673fb90071dba",
},
WindowsPeclVersion {
name: "imagick",
version: "3.8.1",
php_minor: "8.2",
flavor: "nts",
arch: "x64",
sha256: "c15582bfbe19abad8a7894965e82f51d6e5c167d1fa3c0876e5dc64573a4daa9",
},
WindowsPeclVersion {
name: "imagick",
version: "3.8.1",
php_minor: "8.3",
flavor: "nts",
arch: "x64",
sha256: "6954c4cd93fb2844616baab9c04a0dd83f3fde19289563f8b693f613b4cc825c",
},
WindowsPeclVersion {
name: "imagick",
version: "3.8.1",
php_minor: "8.4",
flavor: "nts",
arch: "x64",
sha256: "98bd9e5d7355aa8fbc348774613c0ee9844447a3ba5f2565a7aa08486aead541",
},
WindowsPeclVersion {
name: "imagick",
version: "3.8.1",
php_minor: "8.5",
flavor: "nts",
arch: "x64",
sha256: "67ab8675e59cbbbefd3462c91be662670592f5d02d862f5ee480d9e4707b1fc0",
},
];
#[derive(Debug, Clone)]
struct WindowsPeclArtifact {
name: String,
version: Version,
php_minor: PartialVersion,
flavor: Flavor,
url: String,
sha256: String,
load: LoadDirective,
needs_store_on_path: bool,
}
fn pecl_load_directive(name: &str) -> LoadDirective {
match name {
"xdebug" => LoadDirective::ZendExtension,
_ => LoadDirective::Extension,
}
}
impl WindowsPhpNetBackend {
fn resolve_pecl(
&self,
name: &str,
php_minor: PartialVersion,
flavor: Flavor,
) -> Result<WindowsPeclArtifact> {
let arch = self.arch_suffix()?;
let flavor_tag = flavor_tag(flavor)?;
let minor = php_minor.minor.ok_or_else(|| {
eyre!(
"PECL resolution needs <major>.<minor>; got `{}`",
php_minor
)
})?;
let php_minor_str = format!("{}.{minor}", php_minor.major);
let vc = vc_for_minor(&php_minor_str)?;
let entry = WINDOWS_PECL_VERSIONS
.iter()
.find(|e| {
e.name == name
&& e.php_minor == php_minor_str
&& e.flavor == flavor_tag
&& e.arch == arch
})
.ok_or_else(|| BougieError::Resolution {
kind: "extension".into(),
detail: format!(
"no compile-time WINDOWS_PECL_VERSIONS entry for ext-{name} on \
PHP {php_minor_str} ({flavor_tag}-{arch}). windows.php.net \
may still publish it — open an issue so bougie's next release \
can bake in the (version, sha256) pair."
),
})?;
let filename = format!(
"php_{name}-{}-{php_minor_str}-{flavor_tag}-{vc}-{arch}.zip",
entry.version
);
Ok(WindowsPeclArtifact {
name: name.to_owned(),
version: entry.version.parse().wrap_err_with(|| {
format!(
"WINDOWS_PECL_VERSIONS entry for {name} has non-semver `version = {}`",
entry.version
)
})?,
php_minor,
flavor,
url: format!("{PECL_BASE}/{name}/{}/{filename}", entry.version),
sha256: entry.sha256.to_owned(),
load: pecl_load_directive(name),
needs_store_on_path: pecl_needs_store_on_path(name),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use bougie_version::version::PartialVersion;
#[test]
fn vc_pinning_follows_php_minor() {
assert_eq!(vc_for_minor("8.4").unwrap(), "vs17");
assert_eq!(vc_for_minor("8.5").unwrap(), "vs17");
assert_eq!(vc_for_minor("9.0").unwrap(), "vs17");
assert_eq!(vc_for_minor("8.0").unwrap(), "vs16");
assert_eq!(vc_for_minor("8.3").unwrap(), "vs16");
assert!(vc_for_minor("7.4").is_err()); }
#[test]
fn flavor_tag_rejects_debug_combos() {
assert_eq!(flavor_tag(Flavor::Nts).unwrap(), "nts");
assert_eq!(flavor_tag(Flavor::Zts).unwrap(), "ts");
assert!(flavor_tag(Flavor::NtsDebug).is_err());
assert!(flavor_tag(Flavor::ZtsDebug).is_err());
}
#[test]
fn pick_minor_key_requires_minor_component() {
let just_major =
VersionLike::Version(PartialVersion { major: 8, minor: None, patch: None });
assert!(pick_minor_key(&just_major).is_err());
let with_minor =
VersionLike::Version(PartialVersion { major: 8, minor: Some(4), patch: None });
assert_eq!(pick_minor_key(&with_minor).unwrap(), "8.4");
let with_patch =
VersionLike::Version(PartialVersion { major: 8, minor: Some(4), patch: Some(21) });
assert_eq!(pick_minor_key(&with_patch).unwrap(), "8.4");
}
#[test]
fn pick_minor_key_accepts_caret_tilde_and_pinned_op_constraints() {
use bougie_semver::Constraint;
let caret = VersionLike::Constraint(Constraint::parse("^8.4").unwrap());
assert_eq!(pick_minor_key(&caret).unwrap(), "8.4");
let tilde = VersionLike::Constraint(Constraint::parse("~8.3.0").unwrap());
assert_eq!(pick_minor_key(&tilde).unwrap(), "8.3");
let exact = VersionLike::Constraint(Constraint::parse("8.5.0").unwrap());
assert_eq!(pick_minor_key(&exact).unwrap(), "8.5");
let pinned_op = VersionLike::Constraint(Constraint::parse("=8.2.15").unwrap());
assert_eq!(pick_minor_key(&pinned_op).unwrap(), "8.2");
}
#[test]
fn pick_minor_key_anchors_on_lower_bound_for_open_constraints() {
use bougie_semver::Constraint;
let caret_major = VersionLike::Constraint(Constraint::parse("^8").unwrap());
assert_eq!(pick_minor_key(&caret_major).unwrap(), "8.0");
let open = VersionLike::Constraint(Constraint::parse(">=8.0").unwrap());
assert_eq!(pick_minor_key(&open).unwrap(), "8.0");
}
#[test]
fn pick_minor_key_rejects_wildcard_and_unions() {
use bougie_semver::Constraint;
let star = VersionLike::Constraint(Constraint::parse("*").unwrap());
assert!(pick_minor_key(&star).is_err());
let union = VersionLike::Constraint(Constraint::parse("^7.4 || ^8.0").unwrap());
assert!(pick_minor_key(&union).is_err());
}
#[test]
fn pick_minor_key_accepts_wildcard_patch() {
use bougie_semver::Constraint;
let c = VersionLike::Constraint(Constraint::parse("8.3.*").unwrap());
assert_eq!(pick_minor_key(&c).unwrap(), "8.3");
}
#[test]
fn check_patch_pin_accepts_match_and_rejects_drift() {
let pin_match = VersionLike::Version(PartialVersion {
major: 8,
minor: Some(4),
patch: Some(21),
});
let pin_drift = VersionLike::Version(PartialVersion {
major: 8,
minor: Some(4),
patch: Some(20),
});
let no_patch =
VersionLike::Version(PartialVersion { major: 8, minor: Some(4), patch: None });
let available = Version::new(8, 4, 21);
assert!(check_patch_pin(&pin_match, available).is_ok());
assert!(check_patch_pin(&pin_drift, available).is_err());
assert!(check_patch_pin(&no_patch, available).is_ok());
}
#[test]
fn parses_minor_entry_and_extracts_zip() {
let json = r#"{
"8.4": {
"version": "8.4.21",
"ts-vs17-x64": {
"mtime": "2026-05-06T09:37:26+00:00",
"zip": {
"path": "php-8.4.21-Win32-vs17-x64.zip",
"size": "33.31MB",
"sha256": "9e2f6e455d3f42993f09deed23ad0178b3787090c924793e50414b6a92de186a"
},
"debug_pack": {"path":"x","sha256":"y"}
},
"nts-vs17-x64": {
"zip": {
"path": "php-8.4.21-nts-Win32-vs17-x64.zip",
"sha256": "2cb57d0d3a17b1248c6a53b600719d4b051e1c374373404d5031409c0725031d"
}
},
"source": {"path":"src.zip","sha256":"abc"}
}
}"#;
let r: Releases = serde_json::from_str(json).unwrap();
let minor = r.get("8.4").expect("8.4 entry");
assert_eq!(minor.version, "8.4.21");
let nts = minor.flavor_zip("nts-vs17-x64").unwrap();
assert_eq!(nts.path, "php-8.4.21-nts-Win32-vs17-x64.zip");
assert!(nts.sha256.starts_with("2cb57d0d"));
assert!(nts.size.is_none());
let ts = minor.flavor_zip("ts-vs17-x64").unwrap();
assert_eq!(ts.size.as_deref(), Some("33.31MB"));
assert!(minor.flavor_zip("nts-vs17-arm64").is_none());
let keys: Vec<_> = minor.flavor_keys().collect();
assert!(keys.contains(&"ts-vs17-x64"));
assert!(keys.contains(&"nts-vs17-x64"));
assert!(!keys.contains(&"source"));
}
#[test]
fn parse_human_size_handles_real_releases_json_shapes() {
assert_eq!(parse_human_size("33.31MB"), Some(34_928_067));
assert_eq!(parse_human_size("512KB"), Some(524_288));
assert_eq!(parse_human_size("1.2GB"), Some(1_288_490_189));
assert_eq!(parse_human_size("100"), Some(100));
assert_eq!(parse_human_size("100B"), Some(100));
assert_eq!(parse_human_size(" 33.31 mb "), Some(34_928_067));
assert_eq!(parse_human_size("1MiB"), Some(1_048_576));
}
#[test]
fn parse_human_size_rejects_unrecognised_inputs() {
assert!(parse_human_size("").is_none());
assert!(parse_human_size("MB").is_none());
assert!(parse_human_size("abc").is_none());
assert!(parse_human_size("-5MB").is_none());
assert!(parse_human_size("NaN").is_none());
assert!(parse_human_size("2TB").is_none());
}
#[test]
fn pecl_load_directive_classifies_known_extensions() {
assert_eq!(pecl_load_directive("xdebug"), LoadDirective::ZendExtension);
assert_eq!(pecl_load_directive("redis"), LoadDirective::Extension);
assert_eq!(pecl_load_directive("apcu"), LoadDirective::Extension);
assert_eq!(pecl_load_directive("mongodb"), LoadDirective::Extension);
assert_eq!(pecl_load_directive("imagick"), LoadDirective::Extension);
}
#[test]
fn pecl_needs_store_on_path_only_imagick() {
assert!(pecl_needs_store_on_path("imagick"));
assert!(!pecl_needs_store_on_path("xdebug"));
assert!(!pecl_needs_store_on_path("redis"));
assert!(!pecl_needs_store_on_path("apcu"));
}
#[test]
fn windows_pecl_versions_table_is_well_formed() {
for e in WINDOWS_PECL_VERSIONS {
assert_eq!(e.flavor, "nts", "{}: TS not yet supported in table", e.name);
assert_eq!(e.arch, "x64", "{}: x86 not yet supported in table", e.name);
assert_eq!(e.sha256.len(), 64, "{}: bad sha256 length", e.name);
assert!(
e.sha256.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"{}: sha256 must be lowercase hex",
e.name
);
assert!(
e.version.parse::<Version>().is_ok(),
"{}: version `{}` must be full semver",
e.name,
e.version,
);
let (maj, min) = parse_minor(e.php_minor).unwrap_or_else(|err| {
panic!("{}: php_minor `{}` malformed: {err}", e.name, e.php_minor)
});
assert!(maj >= 8, "{}: php_minor major {maj} pre-bougie-support", e.name);
let _ = min;
}
}
#[test]
fn resolve_pecl_builds_xdebug_url_for_php_84_nts_x64() {
let td = tempfile::TempDir::new().unwrap();
let paths = bougie_paths::Paths::new(td.path().into(), td.path().join("cache"));
let target = bougie_platform::target::Triple {
arch: Arch::X86_64,
vendor: bougie_platform::target::Vendor::Pc,
os: bougie_platform::target::Os::Windows,
env: Some(bougie_platform::target::Env::Msvc),
};
let backend = WindowsPhpNetBackend::new(&paths, &target).unwrap();
let art = backend
.resolve_pecl(
"xdebug",
PartialVersion { major: 8, minor: Some(4), patch: None },
Flavor::Nts,
)
.unwrap();
assert_eq!(art.name, "xdebug");
assert_eq!(art.version, Version::new(3, 5, 1));
assert_eq!(art.url,
"https://windows.php.net/downloads/pecl/releases/xdebug/3.5.1/php_xdebug-3.5.1-8.4-nts-vs17-x64.zip");
assert_eq!(art.load, LoadDirective::ZendExtension);
assert!(!art.needs_store_on_path);
assert_eq!(
art.sha256,
"967cceb6aebbc5592f6aeb61e67ce2e1bef26e985a5b07efe3a622de090a70a9"
);
}
#[test]
fn resolve_pecl_imagick_signals_needs_store_on_path() {
let td = tempfile::TempDir::new().unwrap();
let paths = bougie_paths::Paths::new(td.path().into(), td.path().join("cache"));
let target = bougie_platform::target::Triple {
arch: Arch::X86_64,
vendor: bougie_platform::target::Vendor::Pc,
os: bougie_platform::target::Os::Windows,
env: Some(bougie_platform::target::Env::Msvc),
};
let backend = WindowsPhpNetBackend::new(&paths, &target).unwrap();
let art = backend
.resolve_pecl(
"imagick",
PartialVersion { major: 8, minor: Some(4), patch: None },
Flavor::Nts,
)
.unwrap();
assert_eq!(art.name, "imagick");
assert_eq!(art.version, Version::new(3, 8, 1));
assert_eq!(art.load, LoadDirective::Extension);
assert!(art.needs_store_on_path, "imagick must set needs_store_on_path");
}
#[test]
fn resolve_extension_for_imagick_produces_recipe_with_empty_closure_and_path_extras() {
use super::super::Backend as _;
let td = tempfile::TempDir::new().unwrap();
let paths = bougie_paths::Paths::new(td.path().into(), td.path().join("cache"));
let target = bougie_platform::target::Triple {
arch: Arch::X86_64,
vendor: bougie_platform::target::Vendor::Pc,
os: bougie_platform::target::Os::Windows,
env: Some(bougie_platform::target::Env::Msvc),
};
let backend = WindowsPhpNetBackend::new(&paths, &target).unwrap();
let recipe = backend
.resolve_extension(
"imagick",
PartialVersion { major: 8, minor: Some(4), patch: None },
Flavor::Nts,
None,
bougie_resolver::ResolveOptions::default(),
)
.unwrap();
assert_eq!(recipe.name, "imagick");
assert_eq!(recipe.version, Version::new(3, 8, 1));
assert_eq!(recipe.artifact_rel, std::path::PathBuf::from("php_imagick.dll"));
assert_eq!(recipe.blob.archive, ArchiveKind::Zip);
assert_eq!(recipe.blob.strip_prefix, "");
assert!(recipe.closure.is_empty(), "windows.php.net PECL has no closure entries");
assert!(recipe.needs_store_on_path, "imagick must signal needs_store_on_path");
assert!(!recipe.frozen_warning);
}
#[test]
fn resolve_extension_ignores_version_pin_and_opts() {
use super::super::Backend as _;
let td = tempfile::TempDir::new().unwrap();
let paths = bougie_paths::Paths::new(td.path().into(), td.path().join("cache"));
let target = bougie_platform::target::Triple {
arch: Arch::X86_64,
vendor: bougie_platform::target::Vendor::Pc,
os: bougie_platform::target::Os::Windows,
env: Some(bougie_platform::target::Env::Msvc),
};
let backend = WindowsPhpNetBackend::new(&paths, &target).unwrap();
let recipe = backend
.resolve_extension(
"xdebug",
PartialVersion { major: 8, minor: Some(4), patch: None },
Flavor::Nts,
Some("99.99.99"),
bougie_resolver::ResolveOptions::default(),
)
.unwrap();
assert_eq!(recipe.version, Version::new(3, 5, 1));
}
#[test]
fn resolve_pecl_errors_when_table_has_no_entry() {
let td = tempfile::TempDir::new().unwrap();
let paths = bougie_paths::Paths::new(td.path().into(), td.path().join("cache"));
let target = bougie_platform::target::Triple {
arch: Arch::X86_64,
vendor: bougie_platform::target::Vendor::Pc,
os: bougie_platform::target::Os::Windows,
env: Some(bougie_platform::target::Env::Msvc),
};
let backend = WindowsPhpNetBackend::new(&paths, &target).unwrap();
let err = backend
.resolve_pecl(
"redis",
PartialVersion { major: 8, minor: Some(4), patch: None },
Flavor::Nts,
)
.unwrap_err();
assert!(err.to_string().contains("WINDOWS_PECL_VERSIONS"), "got: {err}");
}
}