use crate::changelog;
use crate::component::{self, Component, VersionTarget};
use crate::config::{from_str, set_json_pointer, to_string_pretty};
use crate::defaults;
use crate::error::{Error, Result};
use crate::local_files::{self, FileSystem};
use crate::module::{load_all_modules, ModuleManifest};
use crate::ssh::execute_local_command_in_dir;
use crate::utils::{io, parser, validation};
use regex::Regex;
use serde::Serialize;
use serde_json::Value;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
pub fn run_pre_bump_commands(commands: &[String], working_dir: &str) -> Result<()> {
if commands.is_empty() {
return Ok(());
}
for command in commands {
let output = execute_local_command_in_dir(command, Some(working_dir), None);
if !output.success {
let error_text = if output.stderr.trim().is_empty() {
output.stdout
} else {
output.stderr
};
return Err(Error::internal_unexpected(format!(
"Pre version bump command failed: {}\n{}",
command, error_text
)));
}
}
Ok(())
}
fn run_post_bump_commands(commands: &[String], working_dir: &str) -> Result<()> {
if commands.is_empty() {
return Ok(());
}
for command in commands {
let output = execute_local_command_in_dir(command, Some(working_dir), None);
if !output.success {
let error_text = if output.stderr.trim().is_empty() {
output.stdout
} else {
output.stderr
};
return Err(Error::internal_unexpected(format!(
"Post version bump command failed: {}\n{}",
command, error_text
)));
}
}
Ok(())
}
pub fn parse_version(content: &str, pattern: &str) -> Option<String> {
parser::extract_first(content, pattern)
}
pub fn parse_versions(content: &str, pattern: &str) -> Option<Vec<String>> {
parser::extract_all(content, pattern)
}
pub fn replace_versions(
content: &str,
pattern: &str,
new_version: &str,
) -> Option<(String, usize)> {
parser::replace_all(content, pattern, new_version)
}
pub fn default_pattern_for_file(filename: &str) -> Option<String> {
for module in load_all_modules().unwrap_or_default() {
if let Some(pattern) = find_version_pattern_in_module(&module, filename) {
return Some(pattern);
}
}
None
}
fn find_version_pattern_in_module(module: &ModuleManifest, filename: &str) -> Option<String> {
for vp in &module.version_patterns {
if filename.ends_with(&vp.extension) {
return Some(vp.pattern.clone());
}
}
None
}
pub fn increment_version(version: &str, bump_type: &str) -> Option<String> {
let parts: Vec<&str> = version.split('.').collect();
if parts.len() != 3 {
return None;
}
let major: u32 = parts[0].parse().ok()?;
let minor: u32 = parts[1].parse().ok()?;
let patch: u32 = parts[2].parse().ok()?;
let (new_major, new_minor, new_patch) = match bump_type {
"patch" => (major, minor, patch + 1),
"minor" => (major, minor + 1, 0),
"major" => (major + 1, 0, 0),
_ => return None,
};
Some(format!("{}.{}.{}", new_major, new_minor, new_patch))
}
pub fn update_version_in_file(
path: &str,
pattern: &str,
old_version: &str,
new_version: &str,
) -> Result<usize> {
if Path::new(path).extension().is_some_and(|ext| ext == "json")
&& default_pattern_for_file(path).as_deref() == Some(pattern)
{
let content = local_files::local().read(Path::new(path))?;
let mut json: Value = from_str(&content)?;
let Some(current) = json.get("version").and_then(|v: &Value| v.as_str()) else {
return Err(Error::config_missing_key("version", Some(path.to_string())));
};
if current != old_version {
return Err(Error::internal_unexpected(format!(
"Version mismatch in {}: found {}, expected {}",
path, current, old_version
)));
}
set_json_pointer(
&mut json,
"/version",
serde_json::Value::String(new_version.to_string()),
)?;
let output = to_string_pretty(&json)?;
local_files::local().write(Path::new(path), &output)?;
return Ok(1);
}
let content = io::read_file(Path::new(path), "read version file")?;
let versions = parse_versions(&content, pattern).ok_or_else(|| {
Error::validation_invalid_argument(
"versionPattern",
format!("Invalid version regex pattern '{}'", pattern),
None,
Some(vec![pattern.to_string()]),
)
})?;
if versions.is_empty() {
return Err(Error::internal_unexpected(format!(
"Could not find version in {}",
path
)));
}
for v in &versions {
if v != old_version {
return Err(Error::internal_unexpected(format!(
"Version mismatch in {}: found {}, expected {}",
path, v, old_version
)));
}
}
let (new_content, replaced_count) = replace_versions(&content, pattern, new_version)
.ok_or_else(|| {
Error::validation_invalid_argument(
"versionPattern",
format!("Invalid version regex pattern '{}'", pattern),
None,
Some(vec![pattern.to_string()]),
)
})?;
io::write_file(Path::new(path), &new_content, "write version file")?;
Ok(replaced_count)
}
pub fn get_component_version(component: &Component) -> Option<String> {
let target = component.version_targets.as_ref()?.first()?;
read_local_version(&component.local_path, target)
}
pub fn read_local_version(local_path: &str, version_target: &VersionTarget) -> Option<String> {
let path = resolve_version_file_path(local_path, &version_target.file);
let content = local_files::local().read(Path::new(&path)).ok()?;
let pattern: String = version_target
.pattern
.clone()
.or_else(|| default_pattern_for_file(&version_target.file))?;
parse_version(&content, &pattern)
}
fn resolve_version_file_path(local_path: &str, file: &str) -> String {
parser::resolve_path_string(local_path, file)
}
#[derive(Debug, Clone, Serialize)]
pub struct VersionTargetInfo {
pub file: String,
pub pattern: String,
pub full_path: String,
pub match_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct ComponentVersionInfo {
pub version: String,
pub targets: Vec<VersionTargetInfo>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BumpResult {
pub old_version: String,
pub new_version: String,
pub targets: Vec<VersionTargetInfo>,
pub changelog_path: String,
pub changelog_finalized: bool,
pub changelog_changed: bool,
}
fn resolve_target_pattern(target: &VersionTarget) -> Result<String> {
target
.pattern
.clone()
.or_else(|| default_pattern_for_file(&target.file))
.ok_or_else(|| {
Error::validation_invalid_argument(
"versionTargets[].pattern",
format!(
"No version pattern configured for '{}' and no module provides one",
target.file
),
None,
None,
)
})
}
#[derive(Debug, Clone, Serialize)]
pub struct ChangelogValidationResult {
pub changelog_path: String,
pub changelog_finalized: bool,
pub changelog_changed: bool,
}
pub fn validate_changelog_for_bump(
component: &Component,
current_version: &str,
new_version: &str,
) -> Result<ChangelogValidationResult> {
let settings = changelog::resolve_effective_settings(Some(component));
let changelog_path = changelog::resolve_changelog_path(component)?;
let changelog_content = local_files::local().read(&changelog_path)?;
let latest_changelog_version = changelog::get_latest_finalized_version(&changelog_content)
.ok_or_else(|| {
Error::validation_invalid_argument(
"changelog",
"Changelog has no finalized versions".to_string(),
None,
Some(vec![
"Add at least one finalized version section like '## [0.1.0] - YYYY-MM-DD'"
.to_string(),
]),
)
})?;
let changelog_ver = semver::Version::parse(&latest_changelog_version);
let file_ver = semver::Version::parse(current_version);
match (changelog_ver, file_ver) {
(Ok(clv), Ok(fv)) if clv > fv => {
return Err(Error::validation_invalid_argument(
"version",
format!(
"Version mismatch: changelog is at {} but files are at {}. Setting version would create a version gap.",
latest_changelog_version, current_version
),
None,
Some(vec![
"Ensure changelog and version files are in sync before updating version.".to_string(),
]),
));
}
_ => {}
}
let (_, changelog_changed) = changelog::finalize_next_section(
&changelog_content,
&settings.next_section_aliases,
new_version,
false,
)?;
Ok(ChangelogValidationResult {
changelog_path: changelog_path.to_string_lossy().to_string(),
changelog_finalized: true,
changelog_changed,
})
}
pub fn validate_and_finalize_changelog(
component: &Component,
current_version: &str,
new_version: &str,
) -> Result<ChangelogValidationResult> {
let settings = changelog::resolve_effective_settings(Some(component));
let changelog_path = changelog::resolve_changelog_path(component)?;
let changelog_content = local_files::local().read(&changelog_path)?;
let latest_changelog_version = changelog::get_latest_finalized_version(&changelog_content)
.ok_or_else(|| {
Error::validation_invalid_argument(
"changelog",
"Changelog has no finalized versions".to_string(),
None,
Some(vec![
"Add at least one finalized version section like '## [0.1.0] - YYYY-MM-DD'"
.to_string(),
]),
)
})?;
let changelog_ver = semver::Version::parse(&latest_changelog_version);
let file_ver = semver::Version::parse(current_version);
match (changelog_ver, file_ver) {
(Ok(clv), Ok(fv)) if clv > fv => {
return Err(Error::validation_invalid_argument(
"version",
format!(
"Version mismatch: changelog is at {} but files are at {}. Setting version would create a version gap.",
latest_changelog_version, current_version
),
None,
Some(vec![
"Ensure changelog and version files are in sync before updating version.".to_string(),
]),
));
}
_ => {}
}
let (finalized_changelog, changelog_changed) = changelog::finalize_next_section(
&changelog_content,
&settings.next_section_aliases,
new_version,
false,
)?;
if changelog_changed {
local_files::local().write(&changelog_path, &finalized_changelog)?;
}
Ok(ChangelogValidationResult {
changelog_path: changelog_path.to_string_lossy().to_string(),
changelog_finalized: true,
changelog_changed,
})
}
fn build_version_parse_error(file: &str, pattern: &str, content: &str) -> Error {
let preview: String = content.chars().take(500).collect();
let mut hints = Vec::new();
if pattern.contains("\\\\s") || pattern.contains("\\\\d") {
hints.push("Pattern appears double-escaped. Use \\s for whitespace, \\d for digits.");
}
if content.contains("Version:")
&& !Regex::new(pattern)
.map(|r| r.is_match(content))
.unwrap_or(false)
{
hints.push("File contains 'Version:' but pattern doesn't match. Check spacing and format.");
}
let hints_text = if hints.is_empty() {
String::new()
} else {
format!("\nHints:\n - {}", hints.join("\n - "))
};
Error::internal_unexpected(format!(
"Could not parse version from {} using pattern: {}{}\n\nFile preview (first 500 chars):\n{}",
file, pattern, hints_text, preview
))
}
pub fn read_component_version(component: &Component) -> Result<ComponentVersionInfo> {
let targets = component
.version_targets
.as_ref()
.ok_or_else(|| Error::config_missing_key("versionTargets", Some(component.id.clone())))?;
if targets.is_empty() {
return Err(Error::config_invalid_value(
"versionTargets",
None,
format!("Component '{}' has empty versionTargets", component.id),
));
}
let primary = &targets[0];
let primary_pattern = resolve_target_pattern(primary)?;
let primary_full_path = resolve_version_file_path(&component.local_path, &primary.file);
let content = local_files::local().read(Path::new(&primary_full_path))?;
let versions = parse_versions(&content, &primary_pattern).ok_or_else(|| {
Error::validation_invalid_argument(
"versionPattern",
format!("Invalid version regex pattern '{}'", primary_pattern),
None,
Some(vec![primary_pattern.clone()]),
)
})?;
if versions.is_empty() {
return Err(build_version_parse_error(
&primary.file,
&primary_pattern,
&content,
));
}
let version = parser::require_identical(&versions, &primary.file)?;
let mut target_infos = vec![VersionTargetInfo {
file: primary.file.clone(),
pattern: primary_pattern,
full_path: primary_full_path,
match_count: versions.len(),
}];
for target in targets.iter().skip(1) {
let pattern = resolve_target_pattern(target)?;
let full_path = resolve_version_file_path(&component.local_path, &target.file);
let content = local_files::local().read(Path::new(&full_path))?;
let target_versions = parse_versions(&content, &pattern).ok_or_else(|| {
Error::validation_invalid_argument(
"versionPattern",
format!("Invalid version regex pattern '{}'", pattern),
None,
Some(vec![pattern.clone()]),
)
})?;
target_infos.push(VersionTargetInfo {
file: target.file.clone(),
pattern,
full_path,
match_count: target_versions.len(),
});
}
Ok(ComponentVersionInfo {
version,
targets: target_infos,
})
}
pub fn read_version(component_id: Option<&str>) -> Result<ComponentVersionInfo> {
let id = match component_id {
None => {
let version = crate::upgrade::current_version().to_string();
return Ok(ComponentVersionInfo {
version,
targets: vec![],
});
}
Some(id) => id,
};
let component = component::load(id)?;
read_component_version(&component)
}
#[derive(Debug, Clone, Serialize)]
pub struct SetResult {
pub old_version: String,
pub new_version: String,
pub targets: Vec<VersionTargetInfo>,
pub changelog_path: String,
pub changelog_finalized: bool,
pub changelog_changed: bool,
}
pub fn set_component_version(component: &Component, new_version: &str) -> Result<SetResult> {
let targets = component
.version_targets
.as_ref()
.ok_or_else(|| Error::config_missing_key("versionTargets", Some(component.id.clone())))?;
if targets.is_empty() {
return Err(Error::config_invalid_value(
"versionTargets",
None,
format!("Component '{}' has empty versionTargets", component.id),
));
}
let primary = &targets[0];
let primary_pattern = resolve_target_pattern(primary)?;
let primary_full_path = resolve_version_file_path(&component.local_path, &primary.file);
let primary_content = local_files::local().read(Path::new(&primary_full_path))?;
let primary_versions = parse_versions(&primary_content, &primary_pattern).ok_or_else(|| {
Error::validation_invalid_argument(
"versionPattern",
format!("Invalid version regex pattern '{}'", primary_pattern),
None,
Some(vec![primary_pattern.clone()]),
)
})?;
if primary_versions.is_empty() {
return Err(build_version_parse_error(
&primary.file,
&primary_pattern,
&primary_content,
));
}
let old_version = parser::require_identical(&primary_versions, &primary.file)?;
let mut target_infos = Vec::new();
for target in targets {
let version_pattern = resolve_target_pattern(target)?;
let full_path = resolve_version_file_path(&component.local_path, &target.file);
let content = local_files::local().read(Path::new(&full_path))?;
let versions = parse_versions(&content, &version_pattern).ok_or_else(|| {
Error::validation_invalid_argument(
"versionPattern",
format!("Invalid version regex pattern '{}'", version_pattern),
None,
Some(vec![version_pattern.clone()]),
)
})?;
if versions.is_empty() {
return Err(Error::internal_unexpected(format!(
"Could not find version in {}",
target.file
)));
}
let found = parser::require_identical(&versions, &target.file)?;
if found != old_version {
return Err(Error::internal_unexpected(format!(
"Version mismatch in {}: found {}, expected {}",
target.file, found, old_version
)));
}
let match_count = versions.len();
let replaced_count =
update_version_in_file(&full_path, &version_pattern, &old_version, new_version)?;
if replaced_count != match_count {
return Err(Error::internal_unexpected(format!(
"Unexpected replacement count in {}",
target.file
)));
}
target_infos.push(VersionTargetInfo {
file: target.file.clone(),
pattern: version_pattern,
full_path,
match_count,
});
}
Ok(SetResult {
old_version,
new_version: new_version.to_string(),
targets: target_infos,
changelog_path: String::new(),
changelog_finalized: false,
changelog_changed: false,
})
}
pub fn set_version(component_id: Option<&str>, new_version: &str) -> Result<SetResult> {
let id = validation::require(component_id, "componentId", "Missing componentId")?;
let component = component::load(id)?;
set_component_version(&component, new_version)
}
pub fn bump_component_version(component: &Component, bump_type: &str) -> Result<BumpResult> {
let targets = component
.version_targets
.as_ref()
.ok_or_else(|| Error::config_missing_key("versionTargets", Some(component.id.clone())))?;
if targets.is_empty() {
return Err(Error::config_invalid_value(
"versionTargets",
None,
format!("Component '{}' has empty versionTargets", component.id),
));
}
let primary = &targets[0];
let primary_pattern = resolve_target_pattern(primary)?;
let primary_full_path = resolve_version_file_path(&component.local_path, &primary.file);
let primary_content = local_files::local().read(Path::new(&primary_full_path))?;
let primary_versions = parse_versions(&primary_content, &primary_pattern).ok_or_else(|| {
Error::validation_invalid_argument(
"versionPattern",
format!("Invalid version regex pattern '{}'", primary_pattern),
None,
Some(vec![primary_pattern.clone()]),
)
})?;
if primary_versions.is_empty() {
return Err(build_version_parse_error(
&primary.file,
&primary_pattern,
&primary_content,
));
}
let old_version = parser::require_identical(&primary_versions, &primary.file)?;
let new_version = increment_version(&old_version, bump_type).ok_or_else(|| {
Error::validation_invalid_argument(
"version",
format!("Invalid version format: {}", old_version),
None,
Some(vec![old_version.clone()]),
)
})?;
let changelog_validation =
validate_and_finalize_changelog(component, &old_version, &new_version)?;
let mut target_infos = Vec::new();
for target in targets {
let version_pattern = resolve_target_pattern(target)?;
let full_path = resolve_version_file_path(&component.local_path, &target.file);
let content = local_files::local().read(Path::new(&full_path))?;
let versions = parse_versions(&content, &version_pattern).ok_or_else(|| {
Error::validation_invalid_argument(
"versionPattern",
format!("Invalid version regex pattern '{}'", version_pattern),
None,
Some(vec![version_pattern.clone()]),
)
})?;
if versions.is_empty() {
return Err(Error::internal_unexpected(format!(
"Could not find version in {}",
target.file
)));
}
let found = parser::require_identical(&versions, &target.file)?;
if found != old_version {
return Err(Error::internal_unexpected(format!(
"Version mismatch in {}: found {}, expected {}",
target.file, found, old_version
)));
}
let match_count = versions.len();
let replaced_count =
update_version_in_file(&full_path, &version_pattern, &old_version, &new_version)?;
if replaced_count != match_count {
return Err(Error::internal_unexpected(format!(
"Unexpected replacement count in {}",
target.file
)));
}
target_infos.push(VersionTargetInfo {
file: target.file.clone(),
pattern: version_pattern,
full_path,
match_count,
});
}
run_post_bump_commands(&component.post_version_bump_commands, &component.local_path)?;
Ok(BumpResult {
old_version,
new_version,
targets: target_infos,
changelog_path: changelog_validation.changelog_path,
changelog_finalized: changelog_validation.changelog_finalized,
changelog_changed: changelog_validation.changelog_changed,
})
}
pub fn bump_version(component_id: Option<&str>, bump_type: &str) -> Result<BumpResult> {
let id = validation::require_with_hints(
component_id,
"componentId",
"Missing componentId",
vec![
"Provide a component ID: homeboy version bump <component-id> <bump-type>".to_string(),
"List available components: homeboy component list".to_string(),
],
)?;
let component = component::load(id)?;
bump_component_version(&component, bump_type)
}
pub fn detect_version_targets(base_path: &str) -> Result<Vec<(String, String, String)>> {
let mut found = Vec::new();
let version_candidates = defaults::load_defaults().version_candidates;
for candidate in &version_candidates {
let full_path = format!("{}/{}", base_path, candidate.file);
if Path::new(&full_path).exists() {
let content = fs::read_to_string(&full_path).ok();
if let Some(content) = content {
if parse_version(&content, &candidate.pattern).is_some() {
found.push((candidate.file.clone(), candidate.pattern.clone(), full_path));
}
}
}
}
if let Ok(entries) = fs::read_dir(base_path) {
let php_pattern = r"Version:\s*(\d+\.\d+\.\d+)";
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "php") {
if let Ok(content) = fs::read_to_string(&path) {
if (content.contains("Plugin Name:") || content.contains("Theme Name:"))
&& parse_version(&content, php_pattern).is_some()
{
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown.php");
found.push((
filename.to_string(),
php_pattern.to_string(),
path.to_string_lossy().to_string(),
));
}
}
}
}
}
Ok(found)
}
#[derive(Debug, Clone, Serialize)]
pub struct UnconfiguredPattern {
pub file: String,
pub pattern: String,
pub description: String,
pub found_version: String,
pub full_path: String,
}
pub fn detect_unconfigured_patterns(
component: &Component,
) -> Vec<UnconfiguredPattern> {
let mut unconfigured = Vec::new();
let base_path = &component.local_path;
let configured: HashSet<(String, String)> = component
.version_targets
.as_ref()
.map(|targets| {
targets
.iter()
.filter_map(|t| {
let pattern = t
.pattern
.clone()
.or_else(|| default_pattern_for_file(&t.file))?;
Some((t.file.clone(), pattern))
})
.collect()
})
.unwrap_or_default();
let php_constant_patterns = [
(
r#"define\s*\(\s*['"]([A-Z_]+VERSION)['"]\s*,\s*['"](\d+\.\d+\.\d+)['"]\s*\)"#,
"PHP constant",
),
];
if let Ok(entries) = fs::read_dir(base_path) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "php") {
if let Ok(content) = fs::read_to_string(&path) {
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown.php")
.to_string();
for (pattern, description) in &php_constant_patterns {
if let Ok(re) = Regex::new(pattern) {
for caps in re.captures_iter(&content) {
if let (Some(const_name), Some(version)) =
(caps.get(1), caps.get(2))
{
let specific_pattern = format!(
r#"define\s*\(\s*['"]{}['"]\s*,\s*['"](\d+\.\d+\.\d+)['"]\s*\)"#,
regex::escape(const_name.as_str())
);
if !configured.contains(&(filename.clone(), specific_pattern.clone())) {
unconfigured.push(UnconfiguredPattern {
file: filename.clone(),
pattern: specific_pattern,
description: format!(
"{}: {}",
description,
const_name.as_str()
),
found_version: version.as_str().to_string(),
full_path: path.to_string_lossy().to_string(),
});
}
}
}
}
}
}
}
}
}
unconfigured
}