use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use super::shared::{registered_unmarkable_manifest_dirs, registry_self_dependency};
const COMPOSER_DEPENDENCY_BUCKETS: [&str; 2] = ["require", "require-dev"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StaleComposerLockFinding {
pub(crate) lock: PathBuf,
pub(crate) declared_in: PathBuf,
pub(crate) bucket: &'static str,
pub(crate) dependency: String,
pub(crate) requirement: String,
pub(crate) locked_versions: Vec<String>,
}
#[cfg(test)]
pub(crate) fn check_generated_composer_lock_freshness(
generated_paths: &HashSet<PathBuf>,
base_dir: &Path,
) -> Option<anyhow::Error> {
check_generated_composer_lock_freshness_tolerating_pending_publish(generated_paths, base_dir, None)
}
fn collect_generated_composer_lock_findings(
generated_paths: &HashSet<PathBuf>,
base_dir: &Path,
) -> Vec<StaleComposerLockFinding> {
let mut directories = BTreeSet::new();
for path in generated_paths {
if path.file_name().and_then(|name| name.to_str()) != Some("composer.json") {
continue;
}
if let Some(dir) = path.parent() {
directories.insert(dir.to_path_buf());
}
}
directories.extend(registered_unmarkable_manifest_dirs(base_dir, "composer.json"));
let mut findings = Vec::new();
for dir in &directories {
findings.extend(stale_composer_lock_findings(dir));
}
tracing::debug!(
manifest_dirs = directories.len(),
findings = findings.len(),
"checked generated composer.json files against their committed composer.lock"
);
findings
}
pub(crate) fn check_generated_composer_lock_freshness_tolerating_pending_publish(
generated_paths: &HashSet<PathBuf>,
base_dir: &Path,
resolved_cfg: Option<&crate::core::config::ResolvedCrateConfig>,
) -> Option<anyhow::Error> {
let findings = collect_generated_composer_lock_findings(generated_paths, base_dir);
if findings.is_empty() {
return None;
}
let Some(self_dependency) = resolved_cfg
.and_then(|cfg| registry_self_dependency(cfg, "php", |package| package.name.clone(), str::to_string))
else {
return Some(anyhow::anyhow!(stale_composer_lock_message(&findings)));
};
let (pending, real): (Vec<_>, Vec<_>) = findings.into_iter().partition(|finding| {
finding.dependency == self_dependency.name && finding.requirement == self_dependency.requirement
});
if !pending.is_empty() {
tracing::warn!(
"{} committed composer.lock pin(s) below require this crate's own version, which is not on \
the registry yet -- expected after a version bump; resolves once the release publishes:\n{}",
pending.len(),
stale_composer_lock_message(&pending)
);
}
if real.is_empty() {
None
} else {
Some(anyhow::anyhow!(stale_composer_lock_message(&real)))
}
}
pub(crate) fn stale_composer_lock_findings(composer_dir: &Path) -> Vec<StaleComposerLockFinding> {
let manifest_path = composer_dir.join("composer.json");
let lock_path = composer_dir.join("composer.lock");
if !manifest_path.is_file() {
return Vec::new();
}
let Ok(manifest_text) = std::fs::read_to_string(&manifest_path) else {
return Vec::new();
};
let Ok(manifest_json) = serde_json::from_str::<serde_json::Value>(&manifest_text) else {
return Vec::new();
};
let Ok(lock_text) = std::fs::read_to_string(&lock_path) else {
return Vec::new();
};
let Ok(lock_json) = serde_json::from_str::<serde_json::Value>(&lock_text) else {
return Vec::new();
};
let locked = locked_composer_versions(&lock_json);
if locked.is_empty() {
return Vec::new();
}
let mut findings = Vec::new();
for bucket in COMPOSER_DEPENDENCY_BUCKETS {
let Some(declared) = manifest_json.get(bucket).and_then(serde_json::Value::as_object) else {
continue;
};
for (name, spec_value) in declared {
let Some(requirement) = spec_value.as_str() else {
continue;
};
let Some(locked_versions) = locked.get(name.as_str()) else {
continue;
};
let Some(all_satisfied) = locked_versions
.iter()
.map(|version| composer_constraint_matches(requirement, version))
.collect::<Option<Vec<bool>>>()
else {
continue;
};
if all_satisfied.iter().any(|matches| *matches) {
continue;
}
findings.push(StaleComposerLockFinding {
lock: lock_path.clone(),
declared_in: manifest_path.clone(),
bucket,
dependency: name.clone(),
requirement: requirement.to_string(),
locked_versions: locked_versions.iter().map(ToString::to_string).collect(),
});
}
}
findings.sort_by(|left, right| {
left.bucket
.cmp(right.bucket)
.then_with(|| left.dependency.cmp(&right.dependency))
});
findings
}
fn locked_composer_versions(lock: &serde_json::Value) -> BTreeMap<String, Vec<semver::Version>> {
let mut locked: BTreeMap<String, Vec<semver::Version>> = BTreeMap::new();
for bucket in ["packages", "packages-dev"] {
let Some(entries) = lock.get(bucket).and_then(serde_json::Value::as_array) else {
continue;
};
for entry in entries {
let (Some(name), Some(version)) = (
entry.get("name").and_then(serde_json::Value::as_str),
entry.get("version").and_then(serde_json::Value::as_str),
) else {
continue;
};
if let Some(parsed) = parse_composer_version(version) {
locked.entry(name.to_string()).or_default().push(parsed);
}
}
}
for versions in locked.values_mut() {
versions.sort();
}
locked
}
fn parse_composer_version(raw: &str) -> Option<semver::Version> {
semver::Version::parse(&pad_to_three(raw.strip_prefix(['v', 'V']).unwrap_or(raw))).ok()
}
fn pad_to_three(raw: &str) -> String {
let split_at = raw.find(['-', '+']).unwrap_or(raw.len());
let (numeric, suffix) = raw.split_at(split_at);
let mut parts: Vec<&str> = numeric.split('.').collect();
while parts.len() < 3 {
parts.push("0");
}
format!("{}{suffix}", parts.join("."))
}
fn composer_constraint_matches(constraint: &str, version: &semver::Version) -> Option<bool> {
let trimmed = constraint.trim();
if trimmed.is_empty() || trimmed.contains("||") || trimmed.contains(',') || trimmed.contains(' ') {
return None;
}
if let Some(rest) = trimmed.strip_prefix('^') {
let req = semver::VersionReq::parse(&format!("^{}", pad_to_three(rest))).ok()?;
return Some(req.matches(version));
}
if let Some(rest) = trimmed.strip_prefix('~') {
return Some(composer_tilde_matches(rest, version));
}
for op in ["<=", ">=", "!=", "<", ">", "="] {
if let Some(rest) = trimmed.strip_prefix(op) {
let target = parse_composer_version(rest.trim())?;
return Some(match op {
"<=" => *version <= target,
">=" => *version >= target,
"!=" => *version != target,
"<" => *version < target,
">" => *version > target,
"=" => *version == target,
_ => unreachable!("op set above is exhaustive for this match"),
});
}
}
let target = parse_composer_version(trimmed)?;
Some(*version == target)
}
fn composer_tilde_matches(rest: &str, version: &semver::Version) -> bool {
let written_components = rest.split('.').filter(|part| !part.is_empty()).count();
let Some(lower) = parse_composer_version(rest) else {
return false;
};
if *version < lower {
return false;
}
let upper = if written_components >= 3 {
semver::Version::new(lower.major, lower.minor + 1, 0)
} else {
semver::Version::new(lower.major + 1, 0, 0)
};
*version < upper
}
fn stale_composer_lock_message(findings: &[StaleComposerLockFinding]) -> String {
let mut message = format!(
"{} committed composer.lock pin(s) cannot satisfy a requirement from a composer.json alef \
generated; `composer install` (and `composer validate --strict` in CI) will fail in these \
directories:",
findings.len()
);
for finding in findings {
message.push_str(&format!(
"\n - {}: `{}` is required as `{}` in {} ({}), but the lock pins only {}. Fix with: composer \
update {} --working-dir {}",
finding.lock.display(),
finding.dependency,
finding.requirement,
finding.declared_in.display(),
finding.bucket,
finding.locked_versions.join(", "),
finding.dependency,
finding.lock.parent().unwrap_or(Path::new(".")).display(),
));
}
message.push_str(
"\nA pin held back on purpose belongs in composer.json -- a lockfile cannot record an exception \
to its own resolution.",
);
message
}
#[cfg(test)]
#[path = "php_tests.rs"]
mod tests;