use crate::config::{load_package_name_files_registry, PackageNameLookup};
use crate::utils::{collapse_project_path, command_exists};
use colored::Colorize;
use semver::Version;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use serde_yaml::Value as YamlValue;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use toml::Value as TomlValue;
use super::adapters::*;
use super::release_ledger::{
find_latest_successful_release_ledger, find_latest_unfinished_release_ledger, ReleaseLedger,
};
use super::{
collect_git_versions, collect_remote_git_versions, current_scope_slug, git_show_head_file,
infer_version_scope_name, is_nested_repository_scope, resolve_registry_paths, resolve_registry_relative_path,
run_git_command, version_scope_label, GitTagObservation, RegistryVersionDetails,
RegistryVersionObservation, ResolvedRegistryPath, VersionObservation, VersionReport,
VersionScope,
};
#[derive(Debug, Deserialize)]
pub(crate) struct CratesIoLatestResponse {
#[serde(rename = "crate")]
crate_meta: CratesIoMeta,
#[serde(default)]
versions: Vec<CratesIoVersionMeta>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct CratesIoMeta {
newest_version: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct CratesIoVersionMeta {
num: String,
created_at: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct NpmDistTags {
latest: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct NpmLatestResponse {
#[serde(rename = "dist-tags")]
dist_tags: NpmDistTags,
#[serde(default)]
time: BTreeMap<String, String>,
}
pub(crate) struct VersionDirtyFileReview {
scope_label: String,
summary: String,
groups: Vec<String>,
}
pub(crate) fn collect_dirty_version_files(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
) -> 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, version_scope);
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)
}
pub(crate) fn collect_head_versions(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
) -> 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, version_scope) {
let Ok(content) = git_show_head_file(project_root, &entry.relative) else {
continue;
};
match read_version_from_blob_with_override(
&entry.relative,
&content,
cargo_toml_content.as_deref(),
entry.cargo_package_override.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)
}
pub(crate) fn collect_local_versions(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
warnings: &mut Vec<String>,
) -> Vec<VersionObservation> {
let mut observed = Vec::new();
for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
let path: &PathBuf = &entry.absolute;
if !path.exists() {
continue;
}
match read_version_from_resolved_path(&entry) {
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
}
pub(crate) async fn collect_registry_versions(
project_root: &Path,
invocation_dir: &Path,
lookups: &[PackageNameLookup],
version_scope: &VersionScope,
warnings: &mut Vec<String>,
) -> Vec<RegistryVersionObservation> {
let mut entries: Vec<RegistryVersionObservation> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
let client: reqwest::Client = reqwest::Client::new();
for lookup in lookups {
let dedupe_key: String = 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,
version_scope,
&lookup.file,
);
let path = project_root.join(&source_file);
if !path.exists() {
continue;
}
let content: String = match fs::read_to_string(&path) {
Ok(content) => content,
Err(err) => {
warnings.push(format!("Failed to read {}: {}", path.display(), err));
continue;
}
};
let package_name: String = 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, published_at, note) =
match fetch_registry_latest_version(&client, &lookup.registry, &package_name).await {
Ok(details) => {
let parsed: Option<Version> = parse_version(&details.version).ok();
let note: Option<String> = if parsed.is_none() {
Some(format!("Non-semver registry version: {}", details.version))
} else {
None
};
(parsed, Some(details.version), details.published_at, note)
}
Err(err) => (None, None, None, Some(err)),
};
entries.push(RegistryVersionObservation {
registry: lookup.registry.clone(),
package_name,
source_file,
latest,
raw_version,
published_at,
note,
});
}
entries.sort_by(|a, b| {
a.registry
.cmp(&b.registry)
.then_with(|| a.package_name.cmp(&b.package_name))
});
entries
}
pub(crate) fn collect_version_report(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
) -> VersionReport {
let mut report: VersionReport = VersionReport::default();
let repo_label = project_root
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("repository");
let scope_slug = current_scope_slug(version_scope, repo_label);
report.worktree = collect_local_versions(
project_root,
invocation_dir,
registry,
version_scope,
&mut report.warnings,
);
match collect_head_versions(project_root, invocation_dir, registry, version_scope) {
Ok(entries) => report.head = entries,
Err(err) => report.warnings.push(err),
}
match collect_git_versions(project_root, version_scope) {
Ok(tags) => report.local_tags = tags,
Err(err) => report.warnings.push(err),
}
match collect_remote_git_versions(project_root, "origin", version_scope) {
Ok(tags) => report.remote_tags = tags,
Err(err) => report.warnings.push(err),
}
match collect_dirty_version_files(project_root, invocation_dir, registry, version_scope) {
Ok(files) => report.dirty_files = files,
Err(err) => report.warnings.push(err),
}
match find_latest_unfinished_release_ledger(project_root, &scope_slug) {
Ok(ledger) => report.release_ledger = ledger,
Err(err) => report.warnings.push(err),
}
report
}
pub(crate) async fn fetch_crates_latest_version(
client: &reqwest::Client,
package_name: &str,
) -> Result<RegistryVersionDetails, String> {
let mut url: reqwest::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: reqwest::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: CratesIoLatestResponse = response.json().await.map_err(|e| {
format!(
"Failed to parse crates.io response for {}: {}",
package_name, e
)
})?;
let published_at = payload
.versions
.iter()
.find(|version| version.num == payload.crate_meta.newest_version)
.and_then(|version| version.created_at.clone());
Ok(RegistryVersionDetails {
version: payload.crate_meta.newest_version,
published_at,
})
}
pub(crate) async fn fetch_npm_latest_version(
client: &reqwest::Client,
package_name: &str,
) -> Result<RegistryVersionDetails, 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);
}
let response: reqwest::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(RegistryVersionDetails {
published_at: payload.time.get(&payload.dist_tags.latest).cloned(),
version: payload.dist_tags.latest,
})
}
pub(crate) async fn fetch_registry_latest_version(
client: &reqwest::Client,
registry: &str,
package_name: &str,
) -> Result<RegistryVersionDetails, String> {
let normalized_registry: String = 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)),
}
}
pub(crate) fn highest_version_observation(entries: &[VersionObservation]) -> Option<Version> {
entries.iter().map(|entry| entry.version.clone()).max()
}
pub(crate) 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 {
let older = tags
.iter()
.skip(1)
.take(5)
.map(|tag| tag.version.to_string())
.collect::<Vec<_>>();
println!(
" {:<20} {}",
"older tags".bright_white(),
older.join(", ").dimmed()
);
if tags.len() > 6 {
println!(
" {:<20} {}",
"older hidden".bright_white(),
format!("{} more", tags.len() - 6).dimmed()
);
}
}
}
pub(crate) fn print_git_versions(project_root: &Path, version_scope: &VersionScope) -> Result<(), String> {
let tags: Vec<GitTagObservation> = collect_git_versions(project_root, version_scope)?;
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(())
}
pub(crate) 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();
let published_at = entry
.published_at
.as_deref()
.map(|value| format!(" {}", format!("published {value}").dimmed()))
.unwrap_or_default();
println!(
" {:<9} {:<28} {:<16} {}{}{}",
entry.registry.bright_white(),
entry.package_name.bright_white(),
latest_display,
entry.source_file.dimmed(),
published_at,
note
);
}
}
pub(crate) 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: Vec<&VersionObservation> = stale_version_observations(entries);
let latest_count: usize = entries.len().saturating_sub(stale_entries.len());
println!(
" {:<28} {} ({}/{})",
"latest".bright_white(),
highest.to_string().bright_green().bold(),
latest_count,
entries.len()
);
for entry in entries {
let dirty: bool = dirty_files
.map(|files| files.iter().any(|file| file == &entry.location))
.unwrap_or(false);
let dirty_suffix: String = if dirty {
format!(" {}", "modified".bright_magenta())
} else {
String::new()
};
let stale_suffix: String = if entry.version != highest {
format!(" {}", "stale".bright_yellow())
} else {
String::new()
};
println!(
" {:<28} {}{}{}",
entry.location.bright_white(),
entry.version.to_string().bright_green(),
dirty_suffix,
stale_suffix
);
}
if !stale_entries.is_empty() {
println!(
" {}",
format!(
"{} stale entr{}",
stale_entries.len(),
if stale_entries.len() == 1 { "y" } else { "ies" }
)
.bright_yellow()
);
}
}
pub(crate) 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())
);
if let Some(ledger) = &report.release_ledger {
println!(
"{:<20} {} {}",
"Release ledger".bright_white(),
ledger.version.bright_yellow(),
format!("{:?}", ledger.status).bright_cyan()
);
}
println!("{:<20} {}", "Worktree status".bright_white(), dirty_suffix);
if let Some(review) = summarize_version_dirty_files(project_root, &report.dirty_files) {
println!(
"{:<20} {}",
"Suggested scope".bright_white(),
review.scope_label.bright_cyan()
);
if !review.groups.is_empty() {
println!("{:<20} {}", "Why".bright_white(), review.summary);
for group in review.groups {
println!("{:<20} {}", "", group);
}
}
}
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);
}
}
}
pub(crate) 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: String = 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)),
}
}
#[cfg(test)]
pub(crate) fn read_version_from_blob(
relative: &str,
content: &str,
cargo_toml_content: Option<&str>,
) -> Result<Option<String>, String> {
read_version_from_blob_with_override(relative, content, cargo_toml_content, None)
}
pub(crate) fn read_version_from_blob_with_override(
relative: &str,
content: &str,
cargo_toml_content: Option<&str>,
cargo_package_override: 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)
}
"openapi.json" | "swagger.json" => read_json_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_with_package(
content,
cargo_toml_content,
cargo_package_override,
),
"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),
},
}
}
pub(crate) fn resolve_current_version_for_bump(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
) -> Version {
let mut _warnings = Vec::new();
let local_versions = collect_local_versions(
project_root,
invocation_dir,
registry,
version_scope,
&mut _warnings,
);
let head_versions =
collect_head_versions(project_root, invocation_dir, registry, version_scope)
.unwrap_or_default();
let local_tags = collect_git_versions(project_root, version_scope).unwrap_or_default();
local_versions
.iter()
.map(|entry| entry.version.clone())
.chain(head_versions.iter().map(|entry| entry.version.clone()))
.chain(local_tags.iter().map(|entry| entry.version.clone()))
.max()
.unwrap_or_else(default_version)
}
pub(crate) fn resolve_release_version_from_report(
project_root: &Path,
version_scope: &VersionScope,
report: &VersionReport,
) -> Version {
if is_nested_repository_scope(project_root, version_scope) {
report.highest_project_local_available()
} else {
report.highest_available()
}
}
pub(crate) 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()
}
pub(crate) fn summarize_file_group(files: &[String]) -> String {
if files.is_empty() {
return String::new();
}
if files.len() <= 4 {
return files.join(", ");
}
format!(
"{} (and {} more)",
files.iter().take(4).cloned().collect::<Vec<_>>().join(", "),
files.len() - 4
)
}
pub(crate) fn summarize_version_dirty_files(
project_root: &Path,
dirty_files: &[String],
) -> Option<VersionDirtyFileReview> {
if dirty_files.is_empty() {
return None;
}
let mut crate_names = BTreeSet::new();
let mut service_names = BTreeSet::new();
let mut touches_workspace_files = false;
let mut workspace_files = Vec::new();
let mut crate_files = Vec::new();
let mut service_files = Vec::new();
let mut other_files = Vec::new();
for file in dirty_files {
let path = Path::new(file);
if path == Path::new("Cargo.toml")
|| path == Path::new("Cargo.lock")
|| path == Path::new("README.md")
|| path == Path::new("CHANGELOG.md")
|| path == Path::new("SECURITY.md")
{
touches_workspace_files = true;
workspace_files.push(file.clone());
continue;
}
if let Some((crate_name, service_name)) = infer_version_scope_name(project_root, path) {
if let Some(crate_name) = crate_name {
crate_names.insert(crate_name);
crate_files.push(file.clone());
}
if let Some(service_name) = service_name {
service_names.insert(service_name);
service_files.push(file.clone());
}
} else {
other_files.push(file.clone());
}
}
let scope_label = if !crate_names.is_empty() && service_names.is_empty() {
if crate_names.len() == 1 {
format!("crate `{}`", crate_names.iter().next().unwrap())
} else {
format!("{} crates", crate_names.len())
}
} else if !service_names.is_empty() && crate_names.is_empty() {
if service_names.len() == 1 {
format!("service `{}`", service_names.iter().next().unwrap())
} else {
format!("{} services", service_names.len())
}
} else if touches_workspace_files {
"repository / main crates".to_string()
} else {
"repository".to_string()
};
let summary = if dirty_files.len() <= 8 {
format!("{} file(s): {}", dirty_files.len(), dirty_files.join(", "))
} else {
format!(
"{} file(s): {} ...",
dirty_files.len(),
dirty_files
.iter()
.take(8)
.cloned()
.collect::<Vec<_>>()
.join(", ")
)
};
let mut groups = Vec::new();
if !workspace_files.is_empty() {
groups.push(format!(
"Workspace files: {}",
summarize_file_group(&workspace_files)
));
}
if !crate_files.is_empty() {
groups.push(format!(
"Crate files: {}",
summarize_file_group(&crate_files)
));
}
if !service_files.is_empty() {
groups.push(format!(
"Service files: {}",
summarize_file_group(&service_files)
));
}
if !other_files.is_empty() {
groups.push(format!(
"Other files: {}",
summarize_file_group(&other_files)
));
}
Some(VersionDirtyFileReview {
scope_label,
summary,
groups,
})
}