use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use super::shared::{registered_unmarkable_manifest_dirs, registry_self_dependency};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StaleGoSumFinding {
pub(crate) lock: PathBuf,
pub(crate) declared_in: PathBuf,
pub(crate) dependency: String,
pub(crate) requirement: String,
pub(crate) locked_versions: Vec<String>,
}
#[cfg(test)]
pub(crate) fn check_generated_go_sum_freshness(
generated_paths: &HashSet<PathBuf>,
base_dir: &Path,
) -> Option<anyhow::Error> {
check_generated_go_sum_freshness_tolerating_pending_publish(generated_paths, base_dir, None)
}
fn collect_generated_go_sum_findings(generated_paths: &HashSet<PathBuf>, base_dir: &Path) -> Vec<StaleGoSumFinding> {
let mut directories = BTreeSet::new();
for path in generated_paths {
if path.file_name().and_then(|name| name.to_str()) != Some("go.mod") {
continue;
}
if let Some(dir) = path.parent() {
directories.insert(dir.to_path_buf());
}
}
directories.extend(registered_unmarkable_manifest_dirs(base_dir, "go.mod"));
let mut findings = Vec::new();
for dir in &directories {
findings.extend(stale_go_sum_findings(dir));
}
tracing::debug!(
manifest_dirs = directories.len(),
findings = findings.len(),
"checked generated go.mod files against their committed go.sum"
);
findings
}
pub(crate) fn check_generated_go_sum_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_go_sum_findings(generated_paths, base_dir);
if findings.is_empty() {
return None;
}
let Some(self_dependency) = resolved_cfg.and_then(|cfg| {
registry_self_dependency(
cfg,
"go",
|package| package.name.clone().or_else(|| package.module.clone()),
normalize_go_version,
)
}) else {
return Some(anyhow::anyhow!(stale_go_sum_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 go.sum entr(y/ies) below are missing for this crate's own version, which is not \
on the module proxy yet -- expected after a version bump; resolves once the release \
publishes:\n{}",
pending.len(),
stale_go_sum_message(&pending)
);
}
if real.is_empty() {
None
} else {
Some(anyhow::anyhow!(stale_go_sum_message(&real)))
}
}
fn normalize_go_version(raw: &str) -> String {
if raw.starts_with('v') {
raw.to_string()
} else {
format!("v{raw}")
}
}
pub(crate) fn stale_go_sum_findings(go_mod_dir: &Path) -> Vec<StaleGoSumFinding> {
let manifest_path = go_mod_dir.join("go.mod");
let lock_path = go_mod_dir.join("go.sum");
let Ok(manifest_text) = std::fs::read_to_string(&manifest_path) else {
return Vec::new();
};
let Ok(lock_text) = std::fs::read_to_string(&lock_path) else {
return Vec::new();
};
let (requires, replaced) = parse_go_mod_requires(&manifest_text);
let locked = locked_go_sum_versions(&lock_text);
if locked.is_empty() {
return Vec::new();
}
let mut findings = Vec::new();
for (module, version) in requires {
if replaced.contains(&module) {
continue;
}
let Some(versions) = locked.get(&module) else {
continue;
};
if versions.contains(&version) {
continue;
}
findings.push(StaleGoSumFinding {
lock: lock_path.clone(),
declared_in: manifest_path.clone(),
dependency: module,
requirement: version,
locked_versions: versions.iter().cloned().collect(),
});
}
findings.sort_by(|left, right| left.dependency.cmp(&right.dependency));
findings
}
fn parse_go_mod_requires(go_mod_text: &str) -> (BTreeMap<String, String>, BTreeSet<String>) {
let mut requires = BTreeMap::new();
let mut replaced = BTreeSet::new();
let mut in_require_block = false;
for raw_line in go_mod_text.lines() {
let line = raw_line.split("//").next().unwrap_or(raw_line).trim();
if line.is_empty() {
continue;
}
if in_require_block {
if line == ")" {
in_require_block = false;
continue;
}
if let Some((module, version)) = parse_module_version_pair(line) {
requires.insert(module, version);
}
continue;
}
if line == "require (" {
in_require_block = true;
continue;
}
if let Some(rest) = line.strip_prefix("require ") {
if let Some((module, version)) = parse_module_version_pair(rest) {
requires.insert(module, version);
}
continue;
}
if let Some(rest) = line.strip_prefix("replace ")
&& let Some((module, _)) = rest.split_once("=>")
{
replaced.insert(module.trim().to_string());
}
}
(requires, replaced)
}
fn parse_module_version_pair(text: &str) -> Option<(String, String)> {
let mut parts = text.split_whitespace();
let module = parts.next()?;
let version = parts.next()?;
Some((module.to_string(), version.to_string()))
}
fn locked_go_sum_versions(go_sum_text: &str) -> BTreeMap<String, BTreeSet<String>> {
let mut locked: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for line in go_sum_text.lines() {
let mut fields = line.split_whitespace();
let (Some(module), Some(version_field)) = (fields.next(), fields.next()) else {
continue;
};
let version = version_field.strip_suffix("/go.mod").unwrap_or(version_field);
locked
.entry(module.to_string())
.or_default()
.insert(version.to_string());
}
locked
}
fn stale_go_sum_message(findings: &[StaleGoSumFinding]) -> String {
let mut message = format!(
"{} committed go.sum ledger entr(y/ies) missing a checksum for a require pin in a go.mod alef \
generated; `go build -mod=readonly` / `go test -mod=readonly` (the CI default) will fail with \
\"missing go.sum entry\":",
findings.len()
);
for finding in findings {
message.push_str(&format!(
"\n - {}: `{}` is required at `{}` by {}, but the ledger records only {}. Fix with: cd {} \
&& go mod download {}",
finding.lock.display(),
finding.dependency,
finding.requirement,
finding.declared_in.display(),
finding.locked_versions.join(", "),
finding.lock.parent().unwrap_or(Path::new(".")).display(),
finding.dependency,
));
}
message.push_str(
"\nA pin held back on purpose belongs in go.mod -- a checksum ledger cannot record an exception \
to its own resolution.",
);
message
}
#[cfg(test)]
#[path = "go_tests.rs"]
mod tests;