use crate::config::{
load_package_name_files_registry, load_versioning_files_registry, PackageNameLookup,
};
use crate::utils::{command_exists, find_xbp_config_upwards};
use colored::Colorize;
use regex::Regex;
use semver::Version;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use toml::Value as TomlValue;
#[derive(Clone, Debug)]
struct VersionObservation {
location: String,
version: Version,
}
#[derive(Clone, Debug)]
struct GitTagObservation {
version: Version,
raw_tags: Vec<String>,
}
#[derive(Clone, Debug)]
struct RegistryVersionObservation {
registry: String,
package_name: String,
source_file: String,
latest: Option<Version>,
raw_version: Option<String>,
note: Option<String>,
}
#[derive(Clone, Debug)]
struct ResolvedRegistryPath {
relative: String,
absolute: PathBuf,
}
#[derive(Default, Debug)]
struct VersionReport {
worktree: Vec<VersionObservation>,
head: Vec<VersionObservation>,
local_tags: Vec<GitTagObservation>,
remote_tags: Vec<GitTagObservation>,
registry_versions: Vec<RegistryVersionObservation>,
dirty_files: Vec<String>,
warnings: Vec<String>,
}
impl VersionReport {
fn highest_worktree(&self) -> Option<Version> {
self.worktree
.iter()
.map(|entry| entry.version.clone())
.max()
}
fn highest_head(&self) -> Option<Version> {
self.head.iter().map(|entry| entry.version.clone()).max()
}
fn highest_local_tag(&self) -> Option<Version> {
self.local_tags
.iter()
.map(|entry| entry.version.clone())
.max()
}
fn highest_remote_tag(&self) -> Option<Version> {
self.remote_tags
.iter()
.map(|entry| entry.version.clone())
.max()
}
fn highest_git(&self) -> Option<Version> {
self.highest_remote_tag()
.or_else(|| self.highest_local_tag())
}
fn highest_registry(&self) -> Option<Version> {
self.registry_versions
.iter()
.filter_map(|entry| entry.latest.clone())
.max()
}
fn highest_available(&self) -> Version {
self.highest_worktree()
.into_iter()
.chain(self.highest_head())
.chain(self.highest_git())
.chain(self.highest_registry())
.max()
.unwrap_or_else(default_version)
}
fn divergent_versions(&self) -> Vec<Version> {
let mut versions = BTreeSet::new();
for entry in &self.worktree {
versions.insert(entry.version.clone());
}
for entry in &self.head {
versions.insert(entry.version.clone());
}
for entry in &self.local_tags {
versions.insert(entry.version.clone());
}
for entry in &self.remote_tags {
versions.insert(entry.version.clone());
}
for entry in &self.registry_versions {
if let Some(version) = &entry.latest {
versions.insert(version.clone());
}
}
versions.into_iter().collect()
}
}
pub async fn run_version_command(
target: Option<String>,
git_only: bool,
_debug: bool,
) -> Result<(), String> {
if git_only && target.is_some() {
return Err("`xbp version --git` does not accept `major`, `minor`, `patch`, or explicit version values.".to_string());
}
let invocation_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let project_root = resolve_project_root();
let registry = load_versioning_files_registry()?;
if git_only {
print_git_versions(&project_root)?;
return Ok(());
}
match target.as_deref() {
None => {
let mut report = collect_version_report(&project_root, &invocation_dir, ®istry);
match load_package_name_files_registry() {
Ok(lookups) => {
report.registry_versions = collect_registry_versions(
&project_root,
&invocation_dir,
&lookups,
&mut report.warnings,
)
.await;
}
Err(err) => report.warnings.push(err),
}
print_version_report(&project_root, &report);
Ok(())
}
Some("major") | Some("minor") | Some("patch") => {
let report = collect_version_report(&project_root, &invocation_dir, ®istry);
let current = report.highest_available();
let next = bump_version(¤t, target.as_deref().unwrap());
let updated = write_version_to_configured_files(
&project_root,
&invocation_dir,
®istry,
&next,
)?;
println!(
"Updated {} version file(s) from {} to {}.",
updated, current, next
);
Ok(())
}
Some(explicit) => {
if let Some((package_name, version)) = parse_package_version_target(explicit)? {
let updated = write_package_version_to_configured_files(
&project_root,
&invocation_dir,
®istry,
&package_name,
&version,
)?;
println!(
"Updated {} file(s) for package `{}` to {}.",
updated, package_name, version
);
} else {
let version = parse_version(explicit)?;
let updated = write_version_to_configured_files(
&project_root,
&invocation_dir,
®istry,
&version,
)?;
println!("Updated {} version file(s) to {}.", updated, version);
}
Ok(())
}
}
}
pub async fn print_version() {
println!("XBP Version: {}", env!("CARGO_PKG_VERSION"));
}
fn resolve_project_root() -> PathBuf {
let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
if let Some(root) = git_repository_root(&cwd) {
return root;
}
if let Some(found) = find_xbp_config_upwards(&cwd) {
return found.project_root;
}
cwd
}
fn collect_version_report(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
) -> VersionReport {
let mut report = VersionReport::default();
report.worktree =
collect_local_versions(project_root, invocation_dir, registry, &mut report.warnings);
match collect_head_versions(project_root, invocation_dir, registry) {
Ok(entries) => report.head = entries,
Err(err) => report.warnings.push(err),
}
match collect_git_versions(project_root) {
Ok(tags) => report.local_tags = tags,
Err(err) => report.warnings.push(err),
}
match collect_remote_git_versions(project_root, "origin") {
Ok(tags) => report.remote_tags = tags,
Err(err) => report.warnings.push(err),
}
match collect_dirty_version_files(project_root, invocation_dir, registry) {
Ok(files) => report.dirty_files = files,
Err(err) => report.warnings.push(err),
}
report
}
async fn collect_registry_versions(
project_root: &Path,
invocation_dir: &Path,
lookups: &[PackageNameLookup],
warnings: &mut Vec<String>,
) -> Vec<RegistryVersionObservation> {
let mut entries = Vec::new();
let mut seen = BTreeSet::new();
let client = reqwest::Client::new();
for lookup in lookups {
let dedupe_key = format!(
"{}|{}|{}|{}",
lookup.file, lookup.format, lookup.key, lookup.registry
);
if !seen.insert(dedupe_key) {
continue;
}
let source_file =
resolve_registry_relative_path(project_root, invocation_dir, &lookup.file);
let path = project_root.join(&source_file);
if !path.exists() {
continue;
}
let content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(err) => {
warnings.push(format!("Failed to read {}: {}", path.display(), err));
continue;
}
};
let package_name = match read_package_name_from_lookup(lookup, &content) {
Ok(Some(value)) => value,
Ok(None) => continue,
Err(err) => {
warnings.push(format!("{}: {}", source_file, err));
continue;
}
};
let (latest, raw_version, note) =
match fetch_registry_latest_version(&client, &lookup.registry, &package_name).await {
Ok(version) => {
let parsed = parse_version(&version).ok();
let note = if parsed.is_none() {
Some(format!("Non-semver registry version: {}", version))
} else {
None
};
(parsed, Some(version), note)
}
Err(err) => (None, None, Some(err)),
};
entries.push(RegistryVersionObservation {
registry: lookup.registry.clone(),
package_name,
source_file,
latest,
raw_version,
note,
});
}
entries.sort_by(|a, b| {
a.registry
.cmp(&b.registry)
.then_with(|| a.package_name.cmp(&b.package_name))
});
entries
}
fn read_package_name_from_lookup(
lookup: &PackageNameLookup,
content: &str,
) -> Result<Option<String>, String> {
let key_parts: Vec<&str> = lookup
.key
.split('.')
.map(|part| part.trim())
.filter(|part| !part.is_empty())
.collect();
if key_parts.is_empty() {
return Err("Lookup key cannot be empty".to_string());
}
let format = lookup.format.trim().to_ascii_lowercase();
match format.as_str() {
"json" => {
let value: JsonValue = serde_json::from_str(content)
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
Ok(json_lookup_string(&value, &key_parts))
}
"yaml" | "yml" => {
let value: YamlValue = serde_yaml::from_str(content)
.map_err(|e| format!("Failed to parse YAML: {}", e))?;
Ok(yaml_lookup_string(&value, &key_parts))
}
"toml" => {
let value: TomlValue =
toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
Ok(toml_lookup_string(&value, &key_parts))
}
other => Err(format!("Unsupported lookup format `{}`", other)),
}
}
async fn fetch_registry_latest_version(
client: &reqwest::Client,
registry: &str,
package_name: &str,
) -> Result<String, String> {
let normalized_registry = registry.trim().to_ascii_lowercase();
match normalized_registry.as_str() {
"npm" => fetch_npm_latest_version(client, package_name).await,
"crates.io" | "crate" | "crates" => fetch_crates_latest_version(client, package_name).await,
_ => Err(format!("Unsupported registry `{}`", registry)),
}
}
#[derive(Debug, Deserialize)]
struct NpmLatestResponse {
version: String,
}
async fn fetch_npm_latest_version(
client: &reqwest::Client,
package_name: &str,
) -> Result<String, String> {
let mut url = reqwest::Url::parse("https://registry.npmjs.org/")
.map_err(|e| format!("Failed to build npm URL: {}", e))?;
{
let mut segments = url
.path_segments_mut()
.map_err(|_| "Failed to compose npm URL segments".to_string())?;
segments.push(package_name);
segments.push("latest");
}
let response = client
.get(url)
.header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
.send()
.await
.map_err(|e| format!("Failed npm lookup for {}: {}", package_name, e))?;
if !response.status().is_success() {
return Err(format!(
"npm lookup for {} returned status {}",
package_name,
response.status()
));
}
let payload: NpmLatestResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse npm response for {}: {}", package_name, e))?;
Ok(payload.version)
}
#[derive(Debug, Deserialize)]
struct CratesIoResponse {
#[serde(rename = "crate")]
crate_meta: CratesIoMeta,
}
#[derive(Debug, Deserialize)]
struct CratesIoMeta {
newest_version: String,
}
async fn fetch_crates_latest_version(
client: &reqwest::Client,
package_name: &str,
) -> Result<String, String> {
let mut url = reqwest::Url::parse("https://crates.io/api/v1/crates/")
.map_err(|e| format!("Failed to build crates.io URL: {}", e))?;
{
let mut segments = url
.path_segments_mut()
.map_err(|_| "Failed to compose crates.io URL segments".to_string())?;
segments.push(package_name);
}
let response = client
.get(url)
.header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
.send()
.await
.map_err(|e| format!("Failed crates.io lookup for {}: {}", package_name, e))?;
if !response.status().is_success() {
return Err(format!(
"crates.io lookup for {} returned status {}",
package_name,
response.status()
));
}
let payload: CratesIoResponse = response.json().await.map_err(|e| {
format!(
"Failed to parse crates.io response for {}: {}",
package_name, e
)
})?;
Ok(payload.crate_meta.newest_version)
}
fn collect_local_versions(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
warnings: &mut Vec<String>,
) -> Vec<VersionObservation> {
let mut observed = Vec::new();
for entry in resolve_registry_paths(project_root, invocation_dir, registry) {
let path = &entry.absolute;
if !path.exists() {
continue;
}
match read_version_from_path(&path) {
Ok(Some(version)) => {
if let Ok(parsed) = parse_version(&version) {
observed.push(VersionObservation {
location: entry.relative.clone(),
version: parsed,
});
} else {
warnings.push(format!("Ignoring non-semver version in {}", path.display()));
}
}
Ok(None) => {}
Err(err) => warnings.push(format!("{}: {}", path.display(), err)),
}
}
observed.sort_by(|a, b| a.location.cmp(&b.location));
observed
}
fn collect_git_versions(project_root: &Path) -> Result<Vec<GitTagObservation>, String> {
if !command_exists("git") {
return Err("Git is not installed; skipping git tag inspection.".to_string());
}
let output = Command::new("git")
.current_dir(project_root)
.args(["tag", "--list"])
.output()
.map_err(|e| format!("Failed to execute `git tag --list`: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err("`git tag --list` failed in the current directory.".to_string());
}
return Err(format!("`git tag --list` failed: {}", stderr));
}
Ok(parse_local_git_tag_output(&String::from_utf8_lossy(
&output.stdout,
)))
}
fn collect_remote_git_versions(
project_root: &Path,
remote: &str,
) -> Result<Vec<GitTagObservation>, String> {
if !command_exists("git") {
return Err("Git is not installed; skipping remote tag inspection.".to_string());
}
let output = Command::new("git")
.current_dir(project_root)
.args(["ls-remote", "--tags", remote])
.output()
.map_err(|e| format!("Failed to execute `git ls-remote --tags {}`: {}", remote, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err(format!("`git ls-remote --tags {}` failed.", remote));
}
return Err(format!(
"`git ls-remote --tags {}` failed: {}",
remote, stderr
));
}
Ok(parse_remote_git_tag_output(&String::from_utf8_lossy(
&output.stdout,
)))
}
fn print_git_versions(project_root: &Path) -> Result<(), String> {
let tags = collect_git_versions(project_root)?;
if tags.is_empty() {
println!("No semantic git tags found in {}.", project_root.display());
return Ok(());
}
println!("Git versions from `git tag --list`:");
for tag in tags {
if tag.raw_tags.len() > 1 {
println!(" {} ({})", tag.version, tag.raw_tags.join(", "));
} else {
println!(" {}", tag.version);
}
}
Ok(())
}
fn print_version_observations(
title: &str,
entries: &[VersionObservation],
dirty_files: Option<&[String]>,
) {
println!();
println!("{}", title.bright_cyan().bold());
println!("{}", "─".repeat(72).bright_black());
if entries.is_empty() {
println!(" {}", "none found".dimmed());
return;
}
let Some(highest) = highest_version_observation(entries) else {
println!(" {}", "none found".dimmed());
return;
};
let stale_entries = stale_version_observations(entries);
let latest_count = entries.len().saturating_sub(stale_entries.len());
println!(
" {:<28} {} ({}/{})",
"latest".bright_white(),
highest.to_string().bright_green().bold(),
latest_count,
entries.len()
);
if stale_entries.is_empty() {
return;
}
println!(" {}", "stale entries".bright_yellow().bold());
for entry in stale_entries {
let dirty = dirty_files
.map(|files| files.iter().any(|file| file == &entry.location))
.unwrap_or(false);
let dirty_suffix = if dirty {
format!(" {}", "modified".bright_magenta())
} else {
String::new()
};
println!(
" {:<28} {}{}",
entry.location.bright_white(),
entry.version.to_string().bright_green(),
dirty_suffix
);
}
}
fn print_git_tag_observations(title: &str, tags: &[GitTagObservation]) {
println!();
println!("{}", title.bright_cyan().bold());
println!("{}", "─".repeat(72).bright_black());
if tags.is_empty() {
println!(" {}", "none found".dimmed());
return;
}
let latest = &tags[0];
if latest.raw_tags.len() > 1 {
println!(
" {:<20} {}",
latest.version.to_string().bright_green().bold(),
latest.raw_tags.join(", ").dimmed()
);
} else {
println!(" {}", latest.version.to_string().bright_green().bold());
}
if tags.len() > 1 {
println!(
" {:<20} {}",
"older tags".bright_white(),
format!("{} hidden", tags.len() - 1).dimmed()
);
}
}
fn collect_head_versions(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
) -> Result<Vec<VersionObservation>, String> {
if !command_exists("git") {
return Err("Git is not installed; skipping committed HEAD inspection.".to_string());
}
let head_check = Command::new("git")
.current_dir(project_root)
.args(["rev-parse", "--verify", "HEAD"])
.output()
.map_err(|e| format!("Failed to execute `git rev-parse --verify HEAD`: {}", e))?;
if !head_check.status.success() {
return Ok(Vec::new());
}
let mut observed = Vec::new();
let cargo_toml_content = git_show_head_file(project_root, "Cargo.toml").ok();
for entry in resolve_registry_paths(project_root, invocation_dir, registry) {
let Ok(content) = git_show_head_file(project_root, &entry.relative) else {
continue;
};
match read_version_from_blob(&entry.relative, &content, cargo_toml_content.as_deref()) {
Ok(Some(version)) => {
if let Ok(parsed) = parse_version(&version) {
observed.push(VersionObservation {
location: entry.relative.clone(),
version: parsed,
});
}
}
Ok(None) => {}
Err(_) => {}
}
}
observed.sort_by(|a, b| a.location.cmp(&b.location));
Ok(observed)
}
fn collect_dirty_version_files(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
) -> Result<Vec<String>, String> {
if !command_exists("git") {
return Err("Git is not installed; skipping worktree status inspection.".to_string());
}
let mut args = vec!["status", "--porcelain", "--"];
let resolved = resolve_registry_paths(project_root, invocation_dir, registry);
for entry in &resolved {
args.push(entry.relative.as_str());
}
let output = Command::new("git")
.current_dir(project_root)
.args(&args)
.output()
.map_err(|e| format!("Failed to execute `git status --porcelain`: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err("`git status --porcelain` failed.".to_string());
}
return Err(format!("`git status --porcelain` failed: {}", stderr));
}
let mut dirty = Vec::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
if line.len() < 4 {
continue;
}
let path = line[3..].trim();
if !path.is_empty() {
dirty.push(path.replace('\\', "/"));
}
}
dirty.sort();
dirty.dedup();
Ok(dirty)
}
fn git_show_head_file(project_root: &Path, relative: &str) -> Result<String, String> {
let output = Command::new("git")
.current_dir(project_root)
.args(["show", &format!("HEAD:{}", relative)])
.output()
.map_err(|e| format!("Failed to read {} from HEAD: {}", relative, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err(format!("{} is not present in HEAD", relative));
}
return Err(format!("Failed to read {} from HEAD: {}", relative, stderr));
}
String::from_utf8(output.stdout)
.map_err(|e| format!("{} in HEAD is not valid UTF-8: {}", relative, e))
}
fn parse_local_git_tag_output(output: &str) -> Vec<GitTagObservation> {
let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
for line in output.lines() {
let tag = line.trim();
if tag.is_empty() {
continue;
}
if let Ok(version) = parse_version(tag) {
by_version.entry(version).or_default().push(tag.to_string());
}
}
git_tag_map_to_vec(by_version)
}
fn parse_remote_git_tag_output(output: &str) -> Vec<GitTagObservation> {
let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
for line in output.lines() {
let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
let tag = reference
.strip_prefix("refs/tags/")
.unwrap_or(reference)
.trim_end_matches("^{}")
.trim();
if tag.is_empty() {
continue;
}
if let Ok(version) = parse_version(tag) {
by_version.entry(version).or_default().push(tag.to_string());
}
}
git_tag_map_to_vec(by_version)
}
fn git_tag_map_to_vec(by_version: BTreeMap<Version, Vec<String>>) -> Vec<GitTagObservation> {
let mut versions: Vec<GitTagObservation> = by_version
.into_iter()
.map(|(version, mut raw_tags)| {
raw_tags.sort();
raw_tags.dedup();
GitTagObservation { version, raw_tags }
})
.collect();
versions.sort_by(|a, b| b.version.cmp(&a.version));
versions
}
fn read_version_from_blob(
relative: &str,
content: &str,
cargo_toml_content: Option<&str>,
) -> Result<Option<String>, String> {
let file_name = Path::new(relative)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
match file_name {
"README.md" => read_readme_version_from_content(content),
"openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
read_openapi_version_from_content(content)
}
"package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
| "xbp.json" | "deno.json" => read_json_root_version_from_content(content),
"deno.jsonc" => read_regex_version_from_content(content, r#""version"\s*:\s*"([^"]+)""#),
"Cargo.toml" => read_cargo_toml_version_from_content(content),
"Cargo.lock" => read_cargo_lock_version_from_content(content, cargo_toml_content),
"pyproject.toml" => read_pyproject_version_from_content(content),
"Chart.yaml" => read_yaml_root_version_from_content(content, "version"),
"xbp.yaml" | "xbp.yml" => read_yaml_root_version_from_content(content, "version"),
"pom.xml" => {
read_regex_version_from_content(content, r"<version>\s*([^<\s]+)\s*</version>")
}
"build.gradle" | "build.gradle.kts" => {
read_regex_version_from_content(content, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
}
"mix.exs" => read_regex_version_from_content(content, r#"version:\s*"([^"]+)""#),
_ => match Path::new(relative).extension().and_then(|ext| ext.to_str()) {
Some("json") => read_json_root_version_from_content(content),
Some("yaml") | Some("yml") => read_yaml_root_version_from_content(content, "version"),
Some("toml") => read_toml_root_version_from_content(content),
Some("md") => read_readme_version_from_content(content),
_ => Ok(None),
},
}
}
fn print_version_report(project_root: &Path, report: &VersionReport) {
let dirty_suffix = if report.dirty_files.is_empty() {
"clean".green().to_string()
} else {
format!("dirty ({})", report.dirty_files.len())
.bright_magenta()
.to_string()
};
println!(
"\n{} {}",
"Version Summary".bright_cyan().bold(),
project_root.display().to_string().bright_white()
);
println!("{}", "─".repeat(72).bright_black());
println!(
"{:<20} {}",
"Highest available".bright_white(),
report.highest_available().to_string().bright_green().bold()
);
println!(
"{:<20} {}",
"Worktree".bright_white(),
report
.highest_worktree()
.unwrap_or_else(default_version)
.to_string()
.bright_yellow()
);
println!(
"{:<20} {}",
"Committed HEAD".bright_white(),
report
.highest_head()
.map(|v| v.to_string())
.unwrap_or_else(|| "none".dimmed().to_string())
);
println!(
"{:<20} {}",
"GitHub tags".bright_white(),
report
.highest_remote_tag()
.map(|v| v.to_string())
.unwrap_or_else(|| "none".dimmed().to_string())
);
println!(
"{:<20} {}",
"Registry latest".bright_white(),
report
.highest_registry()
.map(|v| v.to_string())
.unwrap_or_else(|| "none".dimmed().to_string())
);
println!(
"{:<20} {}",
"Local tags".bright_white(),
report
.highest_local_tag()
.map(|v| v.to_string())
.unwrap_or_else(|| "none".dimmed().to_string())
);
println!("{:<20} {}", "Worktree status".bright_white(), dirty_suffix);
print_version_observations(
"Worktree version files",
&report.worktree,
Some(&report.dirty_files),
);
print_version_observations("Committed HEAD version files", &report.head, None);
print_registry_observations("Published package versions", &report.registry_versions);
print_git_tag_observations("GitHub tags", &report.remote_tags);
print_git_tag_observations("Local git tags", &report.local_tags);
let divergent = report.divergent_versions();
let highest = report.highest_available();
let outdated: Vec<_> = divergent
.into_iter()
.filter(|version| version != &highest)
.collect();
println!();
println!("{}", "Divergence".bright_cyan().bold());
println!("{}", "─".repeat(72).bright_black());
println!(
" {:<20} {}",
"latest target".bright_white(),
highest.to_string().bright_green().bold()
);
if !outdated.is_empty() {
for version in outdated {
println!(
" {} {}",
"•".bright_yellow(),
version.to_string().bright_yellow()
);
}
println!();
println!(
"{} {}",
"Fix local files with".bright_white(),
format!("xbp version {}", highest).black().on_bright_green()
);
} else {
println!(" {}", "all relevant sources are aligned".green());
}
if !report.warnings.is_empty() {
println!();
println!("{}", "Warnings".bright_yellow().bold());
println!("{}", "─".repeat(72).bright_black());
for warning in &report.warnings {
println!(" {} {}", "!".bright_yellow(), warning);
}
}
}
fn highest_version_observation(entries: &[VersionObservation]) -> Option<Version> {
entries.iter().map(|entry| entry.version.clone()).max()
}
fn stale_version_observations(entries: &[VersionObservation]) -> Vec<&VersionObservation> {
let Some(highest) = highest_version_observation(entries) else {
return Vec::new();
};
entries
.iter()
.filter(|entry| entry.version < highest)
.collect()
}
fn print_registry_observations(title: &str, entries: &[RegistryVersionObservation]) {
println!();
println!("{}", title.bright_cyan().bold());
println!("{}", "─".repeat(72).bright_black());
if entries.is_empty() {
println!(" {}", "none found".dimmed());
return;
}
for entry in entries {
let latest_display = match (&entry.latest, &entry.raw_version) {
(Some(version), _) => version.to_string().bright_green().to_string(),
(None, Some(raw)) => raw.as_str().bright_yellow().to_string(),
(None, None) => "unavailable".dimmed().to_string(),
};
let note = entry
.note
.as_ref()
.map(|value| format!(" {}", value.bright_yellow()))
.unwrap_or_default();
println!(
" {:<9} {:<28} {:<16} {}{}",
entry.registry.bright_white(),
entry.package_name.bright_white(),
latest_display,
entry.source_file.dimmed(),
note
);
}
}
fn write_version_to_configured_files(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version: &Version,
) -> Result<usize, String> {
let mut updated = 0usize;
let mut errors = Vec::new();
for entry in resolve_registry_paths(project_root, invocation_dir, registry) {
let path = &entry.absolute;
if !path.exists() {
continue;
}
match write_version_to_path(&path, version) {
Ok(()) => updated += 1,
Err(err) => errors.push(format!("{}: {}", path.display(), err)),
}
}
if updated == 0 && errors.is_empty() {
return Err("No configured version files were found to update.".to_string());
}
if !errors.is_empty() {
return Err(format!(
"Updated {} file(s), but some version targets failed:\n{}",
updated,
errors.join("\n")
));
}
Ok(updated)
}
fn read_version_from_path(path: &Path) -> Result<Option<String>, String> {
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
match file_name {
"README.md" => read_readme_version(path),
"openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
read_openapi_version(path)
}
"package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
| "xbp.json" => read_json_root_version(path),
"deno.json" => read_json_root_version(path),
"deno.jsonc" => read_regex_version(path, r#""version"\s*:\s*"([^"]+)""#),
"Cargo.toml" => read_cargo_toml_version(path),
"Cargo.lock" => read_cargo_lock_version(path),
"pyproject.toml" => read_pyproject_version(path),
"Chart.yaml" => read_yaml_root_version(path, "version"),
"xbp.yaml" | "xbp.yml" => read_yaml_root_version(path, "version"),
"pom.xml" => read_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>"),
"build.gradle" | "build.gradle.kts" => {
read_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
}
"mix.exs" => read_regex_version(path, r#"version:\s*"([^"]+)""#),
_ => match path.extension().and_then(|ext| ext.to_str()) {
Some("json") => read_json_root_version(path),
Some("yaml") | Some("yml") => read_yaml_root_version(path, "version"),
Some("toml") => read_toml_root_version(path),
Some("md") => read_readme_version(path),
_ => Ok(None),
},
}
}
fn write_version_to_path(path: &Path, version: &Version) -> Result<(), String> {
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
match file_name {
"README.md" => write_readme_version(path, version),
"openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
write_openapi_version(path, version)
}
"package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
| "xbp.json" => write_json_root_version(path, version),
"deno.json" => write_json_root_version(path, version),
"deno.jsonc" => write_regex_version(path, r#""version"\s*:\s*"([^"]+)""#, version),
"Cargo.toml" => write_cargo_toml_version(path, version),
"Cargo.lock" => write_cargo_lock_version(path, version),
"pyproject.toml" => write_pyproject_version(path, version),
"Chart.yaml" => write_chart_version(path, version),
"xbp.yaml" | "xbp.yml" => write_yaml_root_version(path, "version", version),
"pom.xml" => write_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>", version),
"build.gradle" | "build.gradle.kts" => {
write_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#, version)
}
"mix.exs" => write_regex_version(path, r#"version:\s*"([^"]+)""#, version),
_ => match path.extension().and_then(|ext| ext.to_str()) {
Some("json") => write_json_root_version(path, version),
Some("yaml") | Some("yml") => write_yaml_root_version(path, "version", version),
Some("toml") => write_toml_root_version(path, version),
Some("md") => write_readme_version(path, version),
_ => Err("Unsupported version file type".to_string()),
},
}
}
fn read_json_root_version_from_content(content: &str) -> Result<Option<String>, String> {
let value: JsonValue =
serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
Ok(value
.get("version")
.and_then(JsonValue::as_str)
.map(|value| value.to_string()))
}
fn read_json_root_version(path: &Path) -> Result<Option<String>, String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_json_root_version_from_content(&content)
}
fn write_json_root_version(path: &Path, version: &Version) -> Result<(), String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: JsonValue =
serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
let object = value
.as_object_mut()
.ok_or_else(|| "Expected a JSON object".to_string())?;
object.insert(
"version".to_string(),
JsonValue::String(version.to_string()),
);
fs::write(
path,
serde_json::to_string_pretty(&value)
.map_err(|e| format!("Failed to serialize JSON: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
fn read_yaml_root_version_from_content(content: &str, key: &str) -> Result<Option<String>, String> {
let value: YamlValue =
serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
Ok(yaml_get_string(&value, key))
}
fn read_yaml_root_version(path: &Path, key: &str) -> Result<Option<String>, String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_yaml_root_version_from_content(&content, key)
}
fn write_yaml_root_version(path: &Path, key: &str, version: &Version) -> Result<(), String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: YamlValue =
serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
let mapping = yaml_root_mapping_mut(&mut value)?;
mapping.insert(
YamlValue::String(key.to_string()),
YamlValue::String(version.to_string()),
);
fs::write(
path,
serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
fn read_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
let value: YamlValue =
serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
let info = yaml_get_mapping(&value, "info");
Ok(info.and_then(|mapping| {
mapping
.get(YamlValue::String("version".to_string()))
.and_then(YamlValue::as_str)
.map(|value| value.to_string())
}))
}
fn read_openapi_version(path: &Path) -> Result<Option<String>, String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_openapi_version_from_content(&content)
}
fn write_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: YamlValue =
serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
let root = yaml_root_mapping_mut(&mut value)?;
let info_key = YamlValue::String("info".to_string());
if !matches!(root.get(&info_key), Some(YamlValue::Mapping(_))) {
root.insert(info_key.clone(), YamlValue::Mapping(YamlMapping::new()));
}
let info = root
.get_mut(&info_key)
.and_then(YamlValue::as_mapping_mut)
.ok_or_else(|| "Expected `info` to be a YAML mapping".to_string())?;
info.insert(
YamlValue::String("version".to_string()),
YamlValue::String(version.to_string()),
);
fs::write(
path,
serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
fn read_toml_root_version_from_content(content: &str) -> Result<Option<String>, String> {
let value: TomlValue =
toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
Ok(value
.get("version")
.and_then(TomlValue::as_str)
.map(|value| value.to_string()))
}
fn read_toml_root_version(path: &Path) -> Result<Option<String>, String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_toml_root_version_from_content(&content)
}
fn write_toml_root_version(path: &Path, version: &Version) -> Result<(), String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: TomlValue =
toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
let table = value
.as_table_mut()
.ok_or_else(|| "Expected a TOML table".to_string())?;
table.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
fs::write(
path,
toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
fn read_cargo_toml_version_from_content(content: &str) -> Result<Option<String>, String> {
let value: TomlValue =
toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
Ok(value
.get("package")
.and_then(TomlValue::as_table)
.and_then(|package| package.get("version"))
.and_then(TomlValue::as_str)
.map(|value| value.to_string()))
}
fn read_cargo_toml_version(path: &Path) -> Result<Option<String>, String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_cargo_toml_version_from_content(&content)
}
fn write_cargo_toml_version(path: &Path, version: &Version) -> Result<(), String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: TomlValue =
toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
let package = value
.get_mut("package")
.and_then(TomlValue::as_table_mut)
.ok_or_else(|| "Expected `[package]` in Cargo.toml".to_string())?;
package.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
fs::write(
path,
toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
fn read_pyproject_version_from_content(content: &str) -> Result<Option<String>, String> {
let value: TomlValue =
toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
let project_version = value
.get("project")
.and_then(TomlValue::as_table)
.and_then(|project| project.get("version"))
.and_then(TomlValue::as_str);
let poetry_version = value
.get("tool")
.and_then(TomlValue::as_table)
.and_then(|tool| tool.get("poetry"))
.and_then(TomlValue::as_table)
.and_then(|poetry| poetry.get("version"))
.and_then(TomlValue::as_str);
Ok(project_version
.or(poetry_version)
.map(|value| value.to_string()))
}
fn read_pyproject_version(path: &Path) -> Result<Option<String>, String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_pyproject_version_from_content(&content)
}
fn write_pyproject_version(path: &Path, version: &Version) -> Result<(), String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: TomlValue =
toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
if let Some(project) = value.get_mut("project").and_then(TomlValue::as_table_mut) {
project.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
} else if let Some(poetry) = value
.get_mut("tool")
.and_then(TomlValue::as_table_mut)
.and_then(|tool| tool.get_mut("poetry"))
.and_then(TomlValue::as_table_mut)
{
poetry.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
} else {
let table = value
.as_table_mut()
.ok_or_else(|| "Expected a TOML table".to_string())?;
table.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
}
fs::write(
path,
toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
fn read_cargo_lock_version_from_content(
content: &str,
cargo_toml_content: Option<&str>,
) -> Result<Option<String>, String> {
let value: TomlValue =
toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
let cargo_toml_content = cargo_toml_content
.ok_or_else(|| "Missing Cargo.toml content for Cargo.lock".to_string())?;
let package_name = cargo_package_name_from_content(cargo_toml_content)?;
Ok(value
.get("package")
.and_then(TomlValue::as_array)
.and_then(|packages| {
packages.iter().find_map(|package| {
let table = package.as_table()?;
if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
table
.get("version")
.and_then(TomlValue::as_str)
.map(|value| value.to_string())
} else {
None
}
})
}))
}
fn read_cargo_lock_version(path: &Path) -> Result<Option<String>, String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let cargo_toml = fs::read_to_string(
path.parent()
.unwrap_or_else(|| Path::new("."))
.join("Cargo.toml"),
)
.map_err(|e| format!("Failed to read file: {}", e))?;
read_cargo_lock_version_from_content(&content, Some(&cargo_toml))
}
fn write_cargo_lock_version(path: &Path, version: &Version) -> Result<(), String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: TomlValue =
toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
let package_name = cargo_package_name(path)?;
let packages = value
.get_mut("package")
.and_then(TomlValue::as_array_mut)
.ok_or_else(|| "Expected `package` entries in Cargo.lock".to_string())?;
let mut updated = false;
for package in packages {
if let Some(table) = package.as_table_mut() {
if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
table.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
updated = true;
}
}
}
if !updated {
return Err(format!(
"Could not find package `{}` in Cargo.lock",
package_name
));
}
fs::write(
path,
toml::to_string(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
fn cargo_package_name(path: &Path) -> Result<String, String> {
let cargo_toml = path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("Cargo.toml");
let content = fs::read_to_string(&cargo_toml)
.map_err(|e| format!("Failed to read {}: {}", cargo_toml.display(), e))?;
cargo_package_name_from_content(&content)
}
fn cargo_package_name_from_content(content: &str) -> Result<String, String> {
let value: TomlValue =
toml::from_str(content).map_err(|e| format!("Failed to parse Cargo.toml: {}", e))?;
value
.get("package")
.and_then(TomlValue::as_table)
.and_then(|package| package.get("name"))
.and_then(TomlValue::as_str)
.map(|value| value.to_string())
.ok_or_else(|| "Could not determine Cargo package name".to_string())
}
fn read_readme_version_from_content(content: &str) -> Result<Option<String>, String> {
read_regex_version_from_content(content, r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
}
fn read_readme_version(path: &Path) -> Result<Option<String>, String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_readme_version_from_content(&content)
}
fn write_readme_version(path: &Path, version: &Version) -> Result<(), String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let marker = format!("current version: `{}`", version);
let regex = Regex::new(r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
.map_err(|e| format!("Failed to build README regex: {}", e))?;
let updated = if regex.is_match(&content) {
regex.replace(&content, marker.as_str()).to_string()
} else if let Some(first_break) = content.find('\n') {
let mut next = String::new();
next.push_str(&content[..=first_break]);
next.push('\n');
next.push_str(&marker);
next.push('\n');
next.push_str(&content[first_break + 1..]);
next
} else {
format!("{}\n\n{}\n", content, marker)
};
fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
}
fn read_regex_version(path: &Path, pattern: &str) -> Result<Option<String>, String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_regex_version_from_content(&content, pattern)
}
fn read_regex_version_from_content(content: &str, pattern: &str) -> Result<Option<String>, String> {
let regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
Ok(regex
.captures(content)
.and_then(|captures| captures.get(1))
.map(|matched| matched.as_str().trim().to_string()))
}
fn write_regex_version(path: &Path, pattern: &str, version: &Version) -> Result<(), String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
if !regex.is_match(&content) {
return Err("No version pattern found".to_string());
}
let updated = regex
.replace(&content, |caps: ®ex::Captures<'_>| {
caps[0].replace(&caps[1], &version.to_string())
})
.to_string();
fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
}
fn write_package_version_to_configured_files(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
package_name: &str,
version: &Version,
) -> Result<usize, String> {
let mut updated = 0usize;
let mut errors = Vec::new();
for entry in resolve_registry_paths(project_root, invocation_dir, registry) {
let path = &entry.absolute;
if !path.exists() {
continue;
}
match write_package_version_to_path(&path, package_name, version) {
Ok(true) => updated += 1,
Ok(false) => {}
Err(err) => errors.push(format!("{}: {}", path.display(), err)),
}
}
if updated == 0 && errors.is_empty() {
return Err(format!(
"No configured TOML files contained package assignment `{}`.",
package_name
));
}
if !errors.is_empty() {
return Err(format!(
"Updated {} file(s), but some package version targets failed:\n{}",
updated,
errors.join("\n")
));
}
Ok(updated)
}
fn write_package_version_to_path(
path: &Path,
package_name: &str,
version: &Version,
) -> Result<bool, String> {
let is_toml = matches!(path.extension().and_then(|ext| ext.to_str()), Some("toml"));
if !is_toml {
return Ok(false);
}
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let (updated, changed) =
rewrite_toml_package_assignment_versions(&content, package_name, version)?;
if changed {
fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))?;
}
Ok(changed)
}
fn rewrite_toml_package_assignment_versions(
content: &str,
package_name: &str,
version: &Version,
) -> Result<(String, bool), String> {
let escaped_name = regex::escape(package_name);
let inline_pattern = format!(
r#"(?m)^(\s*{}\s*=\s*\{{[^\n]*?\bversion\s*=\s*")([^"]+)(")"#,
escaped_name
);
let string_pattern = format!(r#"(?m)^(\s*{}\s*=\s*")([^"]+)(".*)$"#, escaped_name);
let inline_regex =
Regex::new(&inline_pattern).map_err(|e| format!("Invalid inline-table regex: {}", e))?;
let string_regex =
Regex::new(&string_pattern).map_err(|e| format!("Invalid string regex: {}", e))?;
let replacement = version.to_string();
let after_inline = inline_regex
.replace_all(content, |caps: ®ex::Captures<'_>| {
format!("{}{}{}", &caps[1], replacement, &caps[3])
})
.to_string();
let after_string = string_regex
.replace_all(&after_inline, |caps: ®ex::Captures<'_>| {
format!("{}{}{}", &caps[1], replacement, &caps[3])
})
.to_string();
Ok((after_string.clone(), after_string != content))
}
fn write_chart_version(path: &Path, version: &Version) -> Result<(), String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: YamlValue =
serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
let mapping = yaml_root_mapping_mut(&mut value)?;
mapping.insert(
YamlValue::String("version".to_string()),
YamlValue::String(version.to_string()),
);
if mapping.contains_key(YamlValue::String("appVersion".to_string())) {
mapping.insert(
YamlValue::String("appVersion".to_string()),
YamlValue::String(version.to_string()),
);
}
fs::write(
path,
serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
fn yaml_root_mapping_mut(value: &mut YamlValue) -> Result<&mut YamlMapping, String> {
value
.as_mapping_mut()
.ok_or_else(|| "Expected a YAML mapping".to_string())
}
fn yaml_get_mapping<'a>(value: &'a YamlValue, key: &str) -> Option<&'a YamlMapping> {
value
.as_mapping()
.and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
.and_then(YamlValue::as_mapping)
}
fn yaml_get_string(value: &YamlValue, key: &str) -> Option<String> {
value
.as_mapping()
.and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
.and_then(YamlValue::as_str)
.map(|value| value.to_string())
}
fn json_lookup_string(value: &JsonValue, key_parts: &[&str]) -> Option<String> {
let mut current = value;
for part in key_parts {
current = current.get(*part)?;
}
current.as_str().map(|value| value.to_string())
}
fn yaml_lookup_string(value: &YamlValue, key_parts: &[&str]) -> Option<String> {
let mut current = value;
for part in key_parts {
let mapping = current.as_mapping()?;
current = mapping.get(YamlValue::String((*part).to_string()))?;
}
current.as_str().map(|value| value.to_string())
}
fn toml_lookup_string(value: &TomlValue, key_parts: &[&str]) -> Option<String> {
let mut current = value;
for part in key_parts {
current = current.get(*part)?;
}
current.as_str().map(|value| value.to_string())
}
fn parse_version(input: &str) -> Result<Version, String> {
let trimmed = input.trim();
let normalized = trimmed.strip_prefix('v').unwrap_or(trimmed);
Version::parse(normalized).map_err(|e| format!("Invalid semantic version `{}`: {}", input, e))
}
fn parse_package_version_target(input: &str) -> Result<Option<(String, Version)>, String> {
let Some((raw_package, raw_version)) = input.split_once('=') else {
return Ok(None);
};
let package_name = raw_package.trim();
if package_name.is_empty() {
return Ok(None);
}
let package_name_regex = Regex::new(r"^[A-Za-z0-9._-]+$")
.map_err(|e| format!("Failed to build package-name validator: {}", e))?;
if !package_name_regex.is_match(package_name) {
return Err(format!(
"Invalid package target `{}`. Use `package=1.2.3` with only letters, digits, `-`, `_`, or `.` in the package name.",
input
));
}
let version = parse_version(raw_version.trim())?;
Ok(Some((package_name.to_string(), version)))
}
fn bump_version(current: &Version, kind: &str) -> Version {
let mut next = current.clone();
match kind {
"major" => {
next.major += 1;
next.minor = 0;
next.patch = 0;
next.pre = semver::Prerelease::EMPTY;
next.build = semver::BuildMetadata::EMPTY;
}
"minor" => {
next.minor += 1;
next.patch = 0;
next.pre = semver::Prerelease::EMPTY;
next.build = semver::BuildMetadata::EMPTY;
}
_ => {
next.patch += 1;
next.pre = semver::Prerelease::EMPTY;
next.build = semver::BuildMetadata::EMPTY;
}
}
next
}
fn default_version() -> Version {
Version::new(0, 1, 0)
}
fn resolve_registry_paths(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
) -> Vec<ResolvedRegistryPath> {
let mut resolved = Vec::new();
let mut seen = BTreeSet::new();
for relative in registry {
let resolved_relative =
resolve_registry_relative_path(project_root, invocation_dir, relative);
if !seen.insert(resolved_relative.clone()) {
continue;
}
resolved.push(ResolvedRegistryPath {
absolute: project_root.join(&resolved_relative),
relative: resolved_relative,
});
}
resolved
}
fn resolve_registry_relative_path(
project_root: &Path,
invocation_dir: &Path,
relative: &str,
) -> String {
let preferred = invocation_dir.join(relative);
if preferred.exists() {
if let Ok(stripped) = preferred.strip_prefix(project_root) {
return normalized_relative_path(stripped);
}
}
relative.replace('\\', "/")
}
fn normalized_relative_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn git_repository_root(dir: &Path) -> Option<PathBuf> {
if !command_exists("git") {
return None;
}
let output = Command::new("git")
.current_dir(dir)
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
if root.is_empty() {
None
} else {
Some(PathBuf::from(root))
}
}
#[cfg(test)]
mod tests {
use super::{
bump_version, cargo_package_name, highest_version_observation, parse_local_git_tag_output,
parse_package_version_target, parse_remote_git_tag_output, parse_version,
read_cargo_lock_version, read_cargo_toml_version, read_json_root_version,
read_openapi_version, read_package_name_from_lookup, read_pyproject_version,
read_readme_version, read_regex_version, read_toml_root_version, read_version_from_blob,
read_version_from_path, read_yaml_root_version, rewrite_toml_package_assignment_versions,
stale_version_observations, write_cargo_lock_version, write_cargo_toml_version,
write_chart_version, write_json_root_version, write_openapi_version,
write_package_version_to_configured_files, write_pyproject_version, write_readme_version,
write_regex_version, write_toml_root_version, write_version_to_configured_files,
write_yaml_root_version, VersionObservation,
};
use crate::config::PackageNameLookup;
use semver::Version;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let dir = std::env::temp_dir().join(format!("xbp-version-{}-{}", label, nanos));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn parses_prefixed_semver() {
assert_eq!(
parse_version("v1.2.3").expect("version"),
Version::new(1, 2, 3)
);
}
#[test]
fn rejects_invalid_semver() {
let error = parse_version("not-a-version").expect_err("invalid semver should fail");
assert!(error.contains("Invalid semantic version"));
}
#[test]
fn bumps_versions_correctly() {
let base = Version::new(0, 1, 0);
assert_eq!(bump_version(&base, "major"), Version::new(1, 0, 0));
assert_eq!(bump_version(&base, "minor"), Version::new(0, 2, 0));
assert_eq!(bump_version(&base, "patch"), Version::new(0, 1, 1));
}
#[test]
fn parse_package_version_target_supports_assignment_syntax() {
let parsed = parse_package_version_target("demo_pkg=1.2.3")
.expect("parse")
.expect("target");
assert_eq!(parsed.0, "demo_pkg".to_string());
assert_eq!(parsed.1, Version::new(1, 2, 3));
}
#[test]
fn parse_package_version_target_rejects_invalid_package_names() {
let error = parse_package_version_target("bad package=1.2.3")
.expect_err("invalid package target should fail");
assert!(error.contains("Invalid package target"));
}
#[test]
fn parse_package_version_target_returns_none_without_assignment() {
assert!(parse_package_version_target("1.2.3")
.expect("parse")
.is_none());
}
#[test]
fn parse_package_version_target_returns_none_for_empty_package_name() {
assert!(parse_package_version_target(" =1.2.3")
.expect("parse")
.is_none());
}
#[test]
fn bumping_clears_prerelease_and_build_metadata() {
let base = Version::parse("1.2.3-beta.1+sha").expect("version");
assert_eq!(bump_version(&base, "patch"), Version::new(1, 2, 4));
assert_eq!(bump_version(&base, "minor"), Version::new(1, 3, 0));
assert_eq!(bump_version(&base, "major"), Version::new(2, 0, 0));
}
#[test]
fn cargo_toml_adapter_reads_and_writes() {
let dir = temp_dir("cargo");
let path = dir.join("Cargo.toml");
fs::write(
&path,
r#"[package]
name = "xbp"
version = "1.0.0"
"#,
)
.expect("write Cargo.toml");
assert_eq!(
read_cargo_toml_version(&path).expect("read"),
Some("1.0.0".to_string())
);
write_cargo_toml_version(&path, &Version::new(1, 1, 0)).expect("write");
assert_eq!(
read_version_from_path(&path).expect("read"),
Some("1.1.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn json_root_adapter_reads_and_writes() {
let dir = temp_dir("json");
let path = dir.join("package.json");
fs::write(&path, r#"{ "name": "xbp", "version": "1.4.0" }"#).expect("write json");
assert_eq!(
read_json_root_version(&path).expect("read"),
Some("1.4.0".to_string())
);
write_json_root_version(&path, &Version::new(1, 5, 0)).expect("write");
assert_eq!(
read_version_from_path(&path).expect("read"),
Some("1.5.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn yaml_root_adapter_reads_and_writes() {
let dir = temp_dir("yaml");
let path = dir.join("xbp.yaml");
fs::write(&path, "project_name: demo\nversion: 0.2.0\n").expect("write yaml");
assert_eq!(
read_yaml_root_version(&path, "version").expect("read"),
Some("0.2.0".to_string())
);
write_yaml_root_version(&path, "version", &Version::new(0, 3, 0)).expect("write");
assert_eq!(
read_version_from_path(&path).expect("read"),
Some("0.3.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn toml_root_adapter_reads_and_writes() {
let dir = temp_dir("toml");
let path = dir.join("config.toml");
fs::write(&path, "name = \"demo\"\nversion = \"3.1.4\"\n").expect("write toml");
assert_eq!(
read_toml_root_version(&path).expect("read"),
Some("3.1.4".to_string())
);
write_toml_root_version(&path, &Version::new(3, 2, 0)).expect("write");
assert_eq!(
read_toml_root_version(&path).expect("read"),
Some("3.2.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn openapi_adapter_reads_and_writes_nested_version() {
let dir = temp_dir("openapi");
let path = dir.join("openapi.yaml");
fs::write(
&path,
"openapi: 3.0.3\ninfo:\n title: Test\n version: 1.2.3\n",
)
.expect("write openapi");
assert_eq!(
read_openapi_version(&path).expect("read"),
Some("1.2.3".to_string())
);
write_openapi_version(&path, &Version::new(2, 0, 0)).expect("write");
assert_eq!(
read_openapi_version(&path).expect("read"),
Some("2.0.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn openapi_writer_creates_missing_info_mapping() {
let dir = temp_dir("openapi-missing-info");
let path = dir.join("openapi.yaml");
fs::write(&path, "openapi: 3.1.0\npaths: {}\n").expect("write openapi");
write_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
assert_eq!(
read_openapi_version(&path).expect("read"),
Some("4.0.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn pyproject_reader_prefers_project_version() {
let dir = temp_dir("pyproject-project");
let path = dir.join("pyproject.toml");
fs::write(
&path,
"[project]\nname = \"demo\"\nversion = \"0.8.0\"\n\n[tool.poetry]\nversion = \"9.9.9\"\n",
)
.expect("write pyproject");
assert_eq!(
read_pyproject_version(&path).expect("read"),
Some("0.8.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn pyproject_reader_falls_back_to_poetry_version() {
let dir = temp_dir("pyproject-poetry");
let path = dir.join("pyproject.toml");
fs::write(
&path,
"[tool.poetry]\nname = \"demo\"\nversion = \"1.9.0\"\n",
)
.expect("write pyproject");
assert_eq!(
read_pyproject_version(&path).expect("read"),
Some("1.9.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn pyproject_writer_updates_project_table() {
let dir = temp_dir("pyproject-write-project");
let path = dir.join("pyproject.toml");
fs::write(&path, "[project]\nname = \"demo\"\nversion = \"1.0.0\"\n")
.expect("write pyproject");
write_pyproject_version(&path, &Version::new(1, 1, 0)).expect("write");
assert_eq!(
read_pyproject_version(&path).expect("read"),
Some("1.1.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn pyproject_writer_updates_poetry_table() {
let dir = temp_dir("pyproject-write-poetry");
let path = dir.join("pyproject.toml");
fs::write(
&path,
"[tool.poetry]\nname = \"demo\"\nversion = \"2.0.0\"\n",
)
.expect("write pyproject");
write_pyproject_version(&path, &Version::new(2, 1, 0)).expect("write");
assert_eq!(
read_pyproject_version(&path).expect("read"),
Some("2.1.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cargo_lock_reader_and_writer_follow_package_name() {
let dir = temp_dir("cargo-lock");
let cargo_toml = dir.join("Cargo.toml");
let cargo_lock = dir.join("Cargo.lock");
fs::write(
&cargo_toml,
r#"[package]
name = "xbp"
version = "1.0.0"
"#,
)
.expect("write Cargo.toml");
fs::write(
&cargo_lock,
r#"version = 4
[[package]]
name = "xbp"
version = "1.0.0"
[[package]]
name = "other"
version = "9.9.9"
"#,
)
.expect("write Cargo.lock");
assert_eq!(
read_cargo_lock_version(&cargo_lock).expect("read"),
Some("1.0.0".to_string())
);
write_cargo_lock_version(&cargo_lock, &Version::new(1, 0, 1)).expect("write");
assert_eq!(
read_cargo_lock_version(&cargo_lock).expect("read"),
Some("1.0.1".to_string())
);
let updated = fs::read_to_string(&cargo_lock).expect("read updated lock");
assert!(updated.contains("name = \"other\"\nversion = \"9.9.9\""));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cargo_lock_writer_errors_when_package_missing() {
let dir = temp_dir("cargo-lock-missing");
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"1.0.0\"\n",
)
.expect("write Cargo.toml");
let cargo_lock = dir.join("Cargo.lock");
fs::write(
&cargo_lock,
"version = 4\n\n[[package]]\nname = \"other\"\nversion = \"0.1.0\"\n",
)
.expect("write Cargo.lock");
let error = write_cargo_lock_version(&cargo_lock, &Version::new(2, 0, 0))
.expect_err("missing package should fail");
assert!(error.contains("Could not find package `xbp`"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cargo_package_name_reads_package_section() {
let dir = temp_dir("cargo-package-name");
let cargo_lock = dir.join("Cargo.lock");
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"xbp-cli\"\nversion = \"1.0.0\"\n",
)
.expect("write Cargo.toml");
fs::write(&cargo_lock, "version = 4\n").expect("write Cargo.lock");
assert_eq!(
cargo_package_name(&cargo_lock).expect("name"),
"xbp-cli".to_string()
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn readme_adapter_updates_current_version_marker() {
let dir = temp_dir("readme");
let path = dir.join("README.md");
fs::write(&path, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
write_readme_version(&path, &Version::new(1, 2, 0)).expect("write");
assert_eq!(
read_readme_version(&path).expect("read"),
Some("1.2.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn readme_writer_inserts_marker_when_missing() {
let dir = temp_dir("readme-insert");
let path = dir.join("README.md");
fs::write(&path, "# XBP\n\nTight readme.\n").expect("write readme");
write_readme_version(&path, &Version::new(3, 0, 0)).expect("write");
let content = fs::read_to_string(&path).expect("read readme");
assert!(content.contains("current version: `3.0.0`"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn regex_adapter_reads_and_writes_versions() {
let dir = temp_dir("regex");
let path = dir.join("build.gradle");
fs::write(&path, "version = '5.4.3'\n").expect("write gradle");
assert_eq!(
read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
Some("5.4.3".to_string())
);
write_regex_version(
&path,
r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
&Version::new(5, 5, 0),
)
.expect("write");
assert_eq!(
read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
Some("5.5.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn regex_writer_errors_without_matching_pattern() {
let dir = temp_dir("regex-miss");
let path = dir.join("build.gradle");
fs::write(&path, "group = 'demo'\n").expect("write gradle");
let error = write_regex_version(
&path,
r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
&Version::new(1, 0, 0),
)
.expect_err("missing version should fail");
assert!(error.contains("No version pattern found"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn toml_package_assignment_rewriter_updates_string_and_inline_table() {
let original = r#"[dependencies]
serde = "1.0.219"
tokio = { version = "1.44.1", features = ["full"] }
"#;
let (updated, changed) =
rewrite_toml_package_assignment_versions(original, "tokio", &Version::new(1, 45, 0))
.expect("rewrite");
assert!(changed);
assert!(updated.contains(r#"tokio = { version = "1.45.0", features = ["full"] }"#));
let (updated, changed) =
rewrite_toml_package_assignment_versions(&updated, "serde", &Version::new(1, 1, 0))
.expect("rewrite");
assert!(changed);
assert!(updated.contains(r#"serde = "1.1.0""#));
}
#[test]
fn package_version_writer_updates_registry_toml_targets() {
let dir = temp_dir("package-version-registry");
let cargo_toml = dir.join("Cargo.toml");
fs::write(
&cargo_toml,
r#"[package]
name = "demo"
version = "0.1.0"
[dependencies]
serde = "1.0.219"
tokio = { version = "1.44.1", features = ["full"] }
"#,
)
.expect("write Cargo.toml");
let updated = write_package_version_to_configured_files(
&dir,
&dir,
&["Cargo.toml".to_string()],
"tokio",
&Version::new(1, 45, 1),
)
.expect("update package assignment");
assert_eq!(updated, 1);
let content = fs::read_to_string(&cargo_toml).expect("read Cargo.toml");
assert!(content.contains(r#"tokio = { version = "1.45.1", features = ["full"] }"#));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn package_version_writer_errors_when_package_assignment_not_found() {
let dir = temp_dir("package-version-missing");
let cargo_toml = dir.join("Cargo.toml");
fs::write(
&cargo_toml,
r#"[package]
name = "demo"
version = "0.1.0"
[dependencies]
serde = "1.0.219"
"#,
)
.expect("write Cargo.toml");
let error = write_package_version_to_configured_files(
&dir,
&dir,
&["Cargo.toml".to_string()],
"tokio",
&Version::new(1, 45, 1),
)
.expect_err("missing package assignment should fail");
assert!(error.contains("No configured TOML files contained package assignment `tokio`"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn chart_writer_updates_app_version_when_present() {
let dir = temp_dir("chart");
let path = dir.join("Chart.yaml");
fs::write(
&path,
"apiVersion: v2\nname: demo\nversion: 0.1.0\nappVersion: 0.1.0\n",
)
.expect("write chart");
write_chart_version(&path, &Version::new(0, 2, 0)).expect("write");
let content = fs::read_to_string(&path).expect("read chart");
assert!(content.contains("version: 0.2.0"));
assert!(content.contains("appVersion: 0.2.0"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_file_writer_deduplicates_registry_entries() {
let dir = temp_dir("dedupe");
let readme = dir.join("README.md");
fs::write(&readme, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
let updated = write_version_to_configured_files(
&dir,
&dir,
&[
"README.md".to_string(),
"README.md".to_string(),
"missing.md".to_string(),
],
&Version::new(1, 1, 0),
)
.expect("write versions");
assert_eq!(updated, 1);
assert_eq!(
read_readme_version(&readme).expect("read"),
Some("1.1.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_file_writer_prefers_invocation_directory_targets() {
let dir = temp_dir("invocation-precedence");
let app_dir = dir.join("apps").join("web");
fs::create_dir_all(&app_dir).expect("create app dir");
let root_package = dir.join("package.json");
let app_package = app_dir.join("package.json");
fs::write(&root_package, r#"{ "name": "root", "version": "9.9.9" }"#)
.expect("write root package");
fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
.expect("write app package");
let updated = write_version_to_configured_files(
&dir,
&app_dir,
&["package.json".to_string()],
&Version::new(2, 14, 0),
)
.expect("write versions");
assert_eq!(updated, 1);
assert_eq!(
read_json_root_version(&root_package).expect("read root"),
Some("9.9.9".to_string())
);
assert_eq!(
read_json_root_version(&app_package).expect("read app"),
Some("2.14.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_file_writer_deduplicates_when_local_and_root_relative_match_same_file() {
let dir = temp_dir("invocation-dedupe");
let app_dir = dir.join("apps").join("web");
fs::create_dir_all(&app_dir).expect("create app dir");
let app_package = app_dir.join("package.json");
fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
.expect("write app package");
let updated = write_version_to_configured_files(
&dir,
&app_dir,
&[
"package.json".to_string(),
"apps/web/package.json".to_string(),
],
&Version::new(2, 14, 0),
)
.expect("write versions");
assert_eq!(updated, 1);
assert_eq!(
read_json_root_version(&app_package).expect("read app"),
Some("2.14.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn configured_file_writer_errors_when_no_targets_exist() {
let dir = temp_dir("no-targets");
let error = write_version_to_configured_files(
&dir,
&dir,
&["missing.toml".to_string()],
&Version::new(1, 0, 0),
)
.expect_err("missing targets should fail");
assert!(error.contains("No configured version files were found"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn remote_git_tag_parser_deduplicates_peeled_refs() {
let parsed = parse_remote_git_tag_output(
"abc refs/tags/v0.1.7-exp\nabc refs/tags/v0.1.7-exp^{}\ndef refs/tags/v0.2.0\n",
);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].version, Version::parse("0.2.0").expect("version"));
assert_eq!(
parsed[1].version,
Version::parse("0.1.7-exp").expect("version")
);
assert_eq!(parsed[1].raw_tags, vec!["v0.1.7-exp".to_string()]);
}
#[test]
fn local_git_tag_parser_normalizes_prefixed_versions() {
let parsed = parse_local_git_tag_output("v1.0.0\n1.0.0\nv0.9.0\n");
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].version, Version::new(1, 0, 0));
assert_eq!(
parsed[0].raw_tags,
vec!["1.0.0".to_string(), "v1.0.0".to_string()]
);
}
#[test]
fn blob_reader_handles_head_readme_versions() {
assert_eq!(
read_version_from_blob("README.md", "# Demo\n\ncurrent version: `0.4.0`\n", None)
.expect("read"),
Some("0.4.0".to_string())
);
}
#[test]
fn blob_reader_handles_head_cargo_lock_versions() {
let cargo_toml = "[package]\nname = \"athena-mcp\"\nversion = \"0.1.0\"\n";
let cargo_lock = "version = 4\n\n[[package]]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n";
assert_eq!(
read_version_from_blob("Cargo.lock", cargo_lock, Some(cargo_toml)).expect("read"),
Some("0.2.0".to_string())
);
}
#[test]
fn package_name_lookup_reads_json_name_for_npm() {
let lookup = PackageNameLookup {
file: "package.json".to_string(),
format: "json".to_string(),
key: "name".to_string(),
registry: "npm".to_string(),
};
assert_eq!(
read_package_name_from_lookup(&lookup, r#"{ "name": "@xylex/athena-mcp" }"#)
.expect("read"),
Some("@xylex/athena-mcp".to_string())
);
}
#[test]
fn package_name_lookup_reads_toml_nested_package_name() {
let lookup = PackageNameLookup {
file: "Cargo.toml".to_string(),
format: "toml".to_string(),
key: "package.name".to_string(),
registry: "crates.io".to_string(),
};
assert_eq!(
read_package_name_from_lookup(
&lookup,
"[package]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n"
)
.expect("read"),
Some("athena-mcp".to_string())
);
}
#[test]
fn package_name_lookup_errors_on_unknown_format() {
let lookup = PackageNameLookup {
file: "meta.txt".to_string(),
format: "ini".to_string(),
key: "name".to_string(),
registry: "npm".to_string(),
};
let error = read_package_name_from_lookup(&lookup, "name=demo")
.expect_err("unsupported format should fail");
assert!(error.contains("Unsupported lookup format"));
}
#[test]
fn highest_version_observation_returns_max_version() {
let entries = vec![
VersionObservation {
location: "README.md".to_string(),
version: Version::new(1, 0, 0),
},
VersionObservation {
location: "Cargo.toml".to_string(),
version: Version::new(1, 2, 0),
},
];
assert_eq!(
highest_version_observation(&entries).expect("max version"),
Version::new(1, 2, 0)
);
}
#[test]
fn stale_version_observations_only_returns_outdated_entries() {
let entries = vec![
VersionObservation {
location: "README.md".to_string(),
version: Version::new(1, 1, 0),
},
VersionObservation {
location: "Cargo.toml".to_string(),
version: Version::new(1, 2, 0),
},
VersionObservation {
location: "openapi.yaml".to_string(),
version: Version::new(1, 0, 5),
},
];
let stale = stale_version_observations(&entries);
assert_eq!(stale.len(), 2);
assert!(stale.iter().any(|entry| entry.location == "README.md"));
assert!(stale.iter().any(|entry| entry.location == "openapi.yaml"));
assert!(!stale.iter().any(|entry| entry.location == "Cargo.toml"));
}
}