use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use super::shared::{registered_unmarkable_manifest_dirs, registry_self_dependency};
const DART_DEPENDENCY_BUCKETS: [&str; 2] = ["dependencies", "dev_dependencies"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StaleDartLockFinding {
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_version: String,
}
#[cfg(test)]
pub(crate) fn check_generated_dart_lock_freshness(
generated_paths: &HashSet<PathBuf>,
base_dir: &Path,
) -> Option<anyhow::Error> {
check_generated_dart_lock_freshness_tolerating_pending_publish(generated_paths, base_dir, None)
}
fn collect_generated_dart_lock_findings(
generated_paths: &HashSet<PathBuf>,
base_dir: &Path,
) -> Vec<StaleDartLockFinding> {
let mut directories = BTreeSet::new();
for path in generated_paths {
if path.file_name().and_then(|name| name.to_str()) != Some("pubspec.yaml") {
continue;
}
if let Some(dir) = path.parent() {
directories.insert(dir.to_path_buf());
}
}
directories.extend(registered_unmarkable_manifest_dirs(base_dir, "pubspec.yaml"));
let mut findings = Vec::new();
for dir in &directories {
findings.extend(stale_dart_lock_findings(dir));
}
tracing::debug!(
manifest_dirs = directories.len(),
findings = findings.len(),
"checked generated pubspec.yaml files against their committed pubspec.lock"
);
findings
}
pub(crate) fn check_generated_dart_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_dart_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, "dart", |package| package.name.clone(), str::to_string))
else {
return Some(anyhow::anyhow!(stale_dart_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 pubspec.lock pin(s) below require this crate's own version, which is not on \
pub.dev yet -- expected after a version bump; resolves once the release publishes:\n{}",
pending.len(),
stale_dart_lock_message(&pending)
);
}
if real.is_empty() {
None
} else {
Some(anyhow::anyhow!(stale_dart_lock_message(&real)))
}
}
pub(crate) fn stale_dart_lock_findings(pubspec_dir: &Path) -> Vec<StaleDartLockFinding> {
let manifest_path = pubspec_dir.join("pubspec.yaml");
let lock_path = pubspec_dir.join("pubspec.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_yaml) = serde_saphyr::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_yaml) = serde_saphyr::from_str::<serde_json::Value>(&lock_text) else {
return Vec::new();
};
let locked = locked_pubspec_versions(&lock_yaml);
if locked.is_empty() {
return Vec::new();
}
let mut findings = Vec::new();
for bucket in DART_DEPENDENCY_BUCKETS {
let Some(declared) = manifest_yaml.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;
};
if requirement == "any" {
continue;
}
let Some(locked_version) = locked.get(name.as_str()) else {
continue;
};
match dart_constraint_matches(requirement, locked_version) {
Some(true) => {}
Some(false) => findings.push(StaleDartLockFinding {
lock: lock_path.clone(),
declared_in: manifest_path.clone(),
bucket,
dependency: name.clone(),
requirement: requirement.to_string(),
locked_version: locked_version.clone(),
}),
None => {}
}
}
}
findings.sort_by(|left, right| {
left.bucket
.cmp(right.bucket)
.then_with(|| left.dependency.cmp(&right.dependency))
});
findings
}
fn locked_pubspec_versions(lock: &serde_json::Value) -> BTreeMap<String, String> {
let Some(packages) = lock.get("packages").and_then(serde_json::Value::as_object) else {
return BTreeMap::new();
};
let mut locked = BTreeMap::new();
for (name, entry) in packages {
if let Some(version) = entry.get("version").and_then(serde_json::Value::as_str) {
locked.insert(name.clone(), version.trim_matches('"').to_string());
}
}
locked
}
fn dart_constraint_matches(constraint: &str, locked: &str) -> Option<bool> {
let trimmed = constraint.trim();
if trimmed.is_empty() || trimmed.contains(' ') {
return None;
}
let locked_version = semver::Version::parse(&pad_to_three(locked)).ok()?;
if let Some(rest) = trimmed.strip_prefix('^') {
let req = semver::VersionReq::parse(&format!("^{rest}")).ok()?;
return Some(req.matches(&locked_version));
}
for op in ["<=", ">=", "<", ">"] {
if let Some(rest) = trimmed.strip_prefix(op) {
let target = semver::Version::parse(&pad_to_three(rest.trim())).ok()?;
return Some(match op {
"<=" => locked_version <= target,
">=" => locked_version >= target,
"<" => locked_version < target,
">" => locked_version > target,
_ => unreachable!("op set above is exhaustive for this match"),
});
}
}
let target = semver::Version::parse(&pad_to_three(trimmed)).ok()?;
Some(locked_version == target)
}
fn pad_to_three(raw: &str) -> String {
let mut parts: Vec<&str> = raw.split('.').collect();
while parts.len() < 3 {
parts.push("0");
}
parts.join(".")
}
fn stale_dart_lock_message(findings: &[StaleDartLockFinding]) -> String {
let mut message = format!(
"{} committed pubspec.lock pin(s) cannot satisfy a requirement from a pubspec.yaml alef \
generated; `dart pub get --offline` (and `dart pub get --enforce-lockfile`) will fail in these \
directories:",
findings.len()
);
for finding in findings {
message.push_str(&format!(
"\n - {}: `{}` is required as `{}` in {} ({}), but the lock pins {}. Fix with: dart pub \
upgrade {} --directory {}",
finding.lock.display(),
finding.dependency,
finding.requirement,
finding.declared_in.display(),
finding.bucket,
finding.locked_version,
finding.dependency,
finding.lock.parent().unwrap_or(Path::new(".")).display(),
));
}
message.push_str(
"\nA pin held back on purpose belongs in pubspec.yaml -- a lockfile cannot record an exception \
to its own resolution.",
);
message
}
#[cfg(test)]
#[path = "dart_tests.rs"]
mod tests;