use super::validate_versions::VersionCheck;
use crate::core::config::{ResolvedCrateConfig, extras::Language};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
pub(crate) type TrackedPaths<'a> = Option<&'a HashSet<PathBuf>>;
pub(super) fn collect(config: &ResolvedCrateConfig, workspace_root: &Path, canonical: &str) -> Vec<VersionCheck> {
let tracked = crate::cli::git::tracked_paths_under(workspace_root);
if tracked.is_none() {
tracing::warn!(
workspace_root = %workspace_root.display(),
"cannot determine which files are git-tracked (not a git work tree, or `git` is unavailable) - \
version discovery falls back to a plain disk walk and may report build-staging copies"
);
}
let tracked = tracked.as_ref();
let mut checks = Vec::new();
collect_csproj_checks(config, workspace_root, canonical, tracked, &mut checks);
collect_single_manifest_checks(config, workspace_root, canonical, &mut checks);
collect_cargo_lock_checks(workspace_root, canonical, tracked, &mut checks);
checks.sort_by(|left, right| left.label.cmp(&right.label));
checks
}
fn collect_csproj_checks(
config: &ResolvedCrateConfig,
workspace_root: &Path,
canonical: &str,
tracked: TrackedPaths<'_>,
checks: &mut Vec<VersionCheck>,
) {
let directory = config.package_dir(Language::Csharp);
let assembly_version = crate::core::version::to_dotnet_assembly_version(canonical);
for path in glob_under(workspace_root, &directory, "**/*.csproj", tracked) {
let Some(content) = std::fs::read_to_string(&path).ok() else {
continue;
};
for field in ["Version", "AssemblyVersion", "FileVersion", "InformationalVersion"] {
let Some(found) = read_xml_element(&content, field) else {
continue;
};
let expected = match field {
"AssemblyVersion" | "FileVersion" => assembly_version.as_str(),
_ => canonical,
};
push_check(checks, workspace_root, &path, Some(field), found, expected, None);
}
}
}
fn collect_single_manifest_checks(
config: &ResolvedCrateConfig,
workspace_root: &Path,
canonical: &str,
checks: &mut Vec<VersionCheck>,
) {
let dart = workspace_root
.join(config.package_dir(Language::Dart))
.join("pubspec.yaml");
if let Some(found) = read_prefixed_value(&dart, "version:") {
push_check(checks, workspace_root, &dart, None, found, canonical, None);
}
let zig = workspace_root
.join(config.package_dir(Language::Zig))
.join("build.zig.zon");
if let Some(found) = read_zig_version(&zig) {
push_check(checks, workspace_root, &zig, None, found, canonical, None);
}
}
pub(crate) struct DiscoveredCargoLock {
pub(crate) path: PathBuf,
pub(crate) packages: Vec<toml::Value>,
pub(crate) blocked_on_publish: Option<String>,
}
pub(crate) fn discover_cargo_locks(
workspace_root: &Path,
canonical: &str,
tracked: TrackedPaths<'_>,
) -> Vec<DiscoveredCargoLock> {
let submodules = registered_submodule_paths(workspace_root);
let manifests = cargo_manifest_versions(workspace_root, canonical, &submodules, tracked);
let mut discovered = Vec::new();
for lock_path in glob_under(workspace_root, "", "**/Cargo.lock", tracked) {
if ignored_path(workspace_root, &lock_path, &submodules) {
continue;
}
let Some(content) = std::fs::read_to_string(&lock_path).ok() else {
continue;
};
let Some(packages) = toml::from_str::<toml::Value>(&content)
.ok()
.and_then(|value| value.get("package").and_then(toml::Value::as_array).cloned())
else {
continue;
};
let blocked_on_publish = unpublished_dependency(&lock_path, &packages, &manifests);
discovered.push(DiscoveredCargoLock {
path: lock_path,
packages,
blocked_on_publish,
});
}
discovered
}
fn collect_cargo_lock_checks(
workspace_root: &Path,
canonical: &str,
tracked: TrackedPaths<'_>,
checks: &mut Vec<VersionCheck>,
) {
let manifests = cargo_manifest_versions(
workspace_root,
canonical,
®istered_submodule_paths(workspace_root),
tracked,
);
for lock in discover_cargo_locks(workspace_root, canonical, tracked) {
for package in &lock.packages {
if package.get("source").is_some() {
continue;
}
let Some(name) = package.get("name").and_then(toml::Value::as_str) else {
continue;
};
let Some(found) = package.get("version").and_then(toml::Value::as_str) else {
continue;
};
let Some(expected) = manifests.get(name) else {
continue;
};
push_check(
checks,
workspace_root,
&lock.path,
Some(name),
found.to_string(),
expected,
lock.blocked_on_publish.as_deref(),
);
}
}
}
fn unpublished_dependency(
lock_path: &Path,
packages: &[toml::Value],
manifests: &HashMap<String, String>,
) -> Option<String> {
let content = std::fs::read_to_string(lock_path.with_file_name("Cargo.toml")).ok()?;
let manifest = toml::from_str::<toml::Value>(&content).ok()?;
for (name, required) in registry_dependencies_on_local_crates(&manifest, manifests) {
let Some(resolved) = packages
.iter()
.find(|package| package.get("name").and_then(toml::Value::as_str) == Some(name.as_str()))
else {
continue;
};
if resolved.get("source").is_none() {
continue;
}
let resolved_version = resolved
.get("version")
.and_then(toml::Value::as_str)
.unwrap_or_default();
if resolved_version != required {
return Some(format!("{name}@{required}"));
}
}
None
}
fn registry_dependencies_on_local_crates(
manifest: &toml::Value,
manifests: &HashMap<String, String>,
) -> Vec<(String, String)> {
let mut requirements = Vec::new();
for table in dependency_tables(manifest) {
for (key, value) in table {
let name = value
.get("package")
.and_then(toml::Value::as_str)
.unwrap_or(key.as_str())
.to_string();
let Some(local_version) = manifests.get(&name) else {
continue;
};
if value.get("path").is_some() {
continue;
}
let requirement = match value {
toml::Value::String(requirement) => Some(requirement.as_str()),
other => other.get("version").and_then(toml::Value::as_str),
};
let Some(requirement) = requirement else {
continue;
};
if exact_requirement(requirement) == local_version.as_str() {
requirements.push((name, local_version.clone()));
}
}
}
requirements
}
fn exact_requirement(requirement: &str) -> &str {
requirement.trim().trim_start_matches(['=', '^', '~']).trim()
}
fn dependency_tables(manifest: &toml::Value) -> Vec<&toml::Table> {
const KINDS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"];
let mut sources = vec![manifest];
if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) {
sources.extend(targets.values());
}
let mut tables = Vec::new();
for source in sources {
for kind in KINDS {
if let Some(table) = source.get(kind).and_then(toml::Value::as_table) {
tables.push(table);
}
}
}
tables
}
fn cargo_manifest_versions(
workspace_root: &Path,
canonical: &str,
submodules: &HashSet<PathBuf>,
tracked: TrackedPaths<'_>,
) -> HashMap<String, String> {
let mut versions = HashMap::new();
for path in glob_under(workspace_root, "", "**/Cargo.toml", tracked) {
if ignored_path(workspace_root, &path, submodules) {
continue;
}
let Some(content) = std::fs::read_to_string(path).ok() else {
continue;
};
let Some(package) = toml::from_str::<toml::Value>(&content)
.ok()
.and_then(|value| value.get("package").and_then(toml::Value::as_table).cloned())
else {
continue;
};
let Some(name) = package.get("name").and_then(toml::Value::as_str) else {
continue;
};
let version = package
.get("version")
.and_then(toml::Value::as_str)
.unwrap_or(canonical);
versions.insert(name.to_string(), version.to_string());
}
versions
}
fn glob_under(workspace_root: &Path, directory: &str, suffix: &str, tracked: TrackedPaths<'_>) -> Vec<PathBuf> {
let root = glob::Pattern::escape(&workspace_root.to_string_lossy());
let directory = directory.trim_matches(['/', '\\']);
let pattern = if directory.is_empty() {
format!("{root}/{suffix}")
} else {
format!("{root}/{directory}/{suffix}")
};
glob::glob(&pattern)
.into_iter()
.flatten()
.flatten()
.filter(|path| tracked.is_none_or(|tracked| tracked.contains(path)))
.collect()
}
fn ignored_path(workspace_root: &Path, path: &Path, submodules: &HashSet<PathBuf>) -> bool {
let Ok(relative) = path.strip_prefix(workspace_root) else {
return false;
};
relative.components().any(|component| {
matches!(
component.as_os_str().to_str(),
Some("target" | ".git" | ".alef-cache" | "vendor" | "deps")
)
}) || is_inside_unregistered_checkout(workspace_root, relative, submodules)
}
fn is_inside_unregistered_checkout(workspace_root: &Path, relative: &Path, submodules: &HashSet<PathBuf>) -> bool {
let mut ancestor = workspace_root.to_path_buf();
let mut components = relative.components().peekable();
while let Some(component) = components.next() {
if components.peek().is_none() {
break;
}
ancestor.push(component);
if !submodules.contains(&ancestor) && ancestor.join(".git").exists() {
return true;
}
}
false
}
fn registered_submodule_paths(workspace_root: &Path) -> HashSet<PathBuf> {
let mut registered = HashSet::new();
let Ok(content) = std::fs::read_to_string(workspace_root.join(".gitmodules")) else {
return registered;
};
let mut inside_submodule_section = false;
for line in content.lines() {
let line = line.trim();
if line.starts_with('[') {
inside_submodule_section = line.starts_with("[submodule");
continue;
}
if !inside_submodule_section {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
if key.trim() != "path" {
continue;
}
let value = value.trim().trim_matches('"');
if !value.is_empty() {
registered.insert(workspace_root.join(value));
}
}
registered
}
fn read_xml_element(content: &str, element: &str) -> Option<String> {
let open = format!("<{element}>");
let close = format!("</{element}>");
let start = content.find(&open)? + open.len();
let end = content[start..].find(&close)?;
Some(content[start..start + end].trim().to_string())
}
fn read_prefixed_value(path: &Path, prefix: &str) -> Option<String> {
std::fs::read_to_string(path).ok()?.lines().find_map(|line| {
line.strip_prefix(prefix)
.map(|value| value.split('#').next().unwrap_or(value).trim().to_string())
})
}
fn read_zig_version(path: &Path) -> Option<String> {
std::fs::read_to_string(path).ok()?.lines().find_map(|line| {
line.trim()
.strip_prefix(".version")?
.split_once('=')
.map(|(_, value)| value.trim().trim_end_matches(',').trim_matches('"').to_string())
})
}
fn push_check(
checks: &mut Vec<VersionCheck>,
workspace_root: &Path,
path: &Path,
field: Option<&str>,
found: String,
expected: &str,
blocked_on_publish: Option<&str>,
) {
let mut label = path
.strip_prefix(workspace_root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
if let Some(field) = field {
label.push('#');
label.push_str(field);
}
let matches = found == expected;
checks.push(VersionCheck {
label,
matches,
found: Some(found),
blocked_on_publish: blocked_on_publish.filter(|_| !matches).map(str::to_string),
});
}
#[cfg(test)]
#[path = "version_manifests/tests.rs"]
mod tests;