mod default_pattern_for_file;
mod types;
mod version;
pub use default_pattern_for_file::*;
pub use types::*;
pub use version::*;
use crate::component::{self, Component, VersionTarget};
use crate::config::{from_str, set_json_pointer, to_string_pretty};
use crate::engine::hooks::{self, HookFailureMode};
use crate::engine::local_files::{self, FileSystem};
use crate::engine::text;
use crate::error::{Error, Result};
use crate::extension::ExtensionManifest;
use crate::release::changelog;
use serde_json::Value;
use std::path::Path;
pub(crate) fn replace_versions(
content: &str,
pattern: &str,
new_version: &str,
) -> Option<(String, usize)> {
text::replace_all(content, pattern, new_version)
}
fn find_version_pattern_in_extension(
extension: &ExtensionManifest,
filename: &str,
) -> Option<String> {
for vp in extension.version_patterns() {
if filename.ends_with(&vp.extension) {
return Some(vp.pattern.clone());
}
}
None
}
pub(crate) 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 = local_files::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()]),
)
})?;
local_files::write_file(Path::new(path), &new_content, "write version file")?;
Ok(replaced_count)
}
pub(crate) 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 pre_validate_version_targets(
targets: &[VersionTarget],
local_path: &str,
expected_version: &str,
) -> Result<Vec<VersionTargetInfo>> {
let mut target_infos = Vec::new();
for target in targets {
let version_pattern = resolve_target_pattern(target)?;
let full_path = resolve_version_file_path(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 = text::require_identical(&versions, &target.file)?;
if found != expected_version {
return Err(Error::validation_invalid_argument(
"version",
format!(
"Version mismatch in {}: found {}, expected {}",
target.file, found, expected_version
),
None,
Some(vec![
format!(
"All version targets must be at {} before release proceeds.",
expected_version
),
format!(
"Update {} from {} to {}, then re-run `homeboy release`.",
target.file, found, expected_version
),
]),
));
}
target_infos.push(VersionTargetInfo {
file: target.file.clone(),
pattern: version_pattern,
full_path,
match_count: versions.len(),
warning: None,
});
}
Ok(target_infos)
}
pub(crate) fn validate_and_finalize_changelog(
component: &Component,
current_version: &str,
new_version: &str,
generated_entries: Option<&std::collections::HashMap<String, Vec<String>>>,
) -> Result<ChangelogValidationResult> {
let settings = changelog::resolve_effective_settings(Some(component));
let changelog_path = changelog::resolve_changelog_path(component)?;
let changelog_content = match local_files::local().read(&changelog_path) {
Ok(content) => content,
Err(e) => {
let error_str = e.to_string();
if error_str.contains("File not found") || error_str.contains("No such file") {
return Err(Error::validation_invalid_argument(
"changelog",
format!("Changelog file not found: {}", changelog_path.display()),
None,
Some(vec![format!(
"Configure the component's changelog target, then re-run release:\n homeboy component set {} --changelog-target \"CHANGELOG.md\"",
component.id
)]),
));
}
return Err(e);
}
};
let latest_changelog_version = changelog::get_latest_finalized_version(&changelog_content);
if let Some(ref prev) = latest_changelog_version {
let changelog_ver = semver::Version::parse(prev);
let file_ver = semver::Version::parse(current_version);
if let (Ok(clv), Ok(fv)) = (changelog_ver, file_ver) {
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.",
prev, current_version
),
None,
Some(vec![
format!("The changelog has a finalized section for {} but the version files are still at {}.", prev, current_version),
"This usually means a previous release was partially prepared.".to_string(),
String::new(),
"To resolve:".to_string(),
format!(" 1. Update all version_targets to {} (to match the changelog), commit, and re-run", prev),
format!(" 2. Or revert the changelog {} section and re-run to let homeboy regenerate it", prev),
]),
));
}
}
}
let (finalized_changelog, changelog_changed) = if let Some(entries) = generated_entries {
let entries_ref: std::collections::HashMap<&str, Vec<String>> = entries
.iter()
.map(|(k, v)| (k.as_str(), v.clone()))
.collect();
changelog::finalize_with_generated_entries(
&changelog_content,
&settings.next_section_aliases,
&entries_ref,
new_version,
)?
} else {
changelog::finalize_next_section(
&changelog_content,
&settings.next_section_aliases,
new_version,
false,
)?
};
if changelog_changed {
local_files::local().write(&changelog_path, &finalized_changelog)?;
} else if component.changelog_target.is_some() {
let changelog_file = component
.changelog_target
.as_deref()
.unwrap_or("CHANGELOG.md");
let version_targets_list: Vec<String> = component
.version_targets
.as_ref()
.map(|targets| targets.iter().map(|t| format!(" - {}", t.file)).collect())
.unwrap_or_default();
return Err(Error::validation_invalid_argument(
"changelog",
format!(
"Configured changelog target '{}' was not updated for release {}",
changelog_file, new_version
),
None,
Some(vec![
format!(
"Planned release: {} → {} (bump: {})",
current_version, new_version,
if current_version == new_version { "none" } else { "auto" }
),
"Pre-flight contract — before running `homeboy release`, the target component must have:".to_string(),
format!(" 1. A `## [{}]` section at the top of {}", new_version, changelog_file),
" 2. Every version_targets file updated to match".to_string(),
" 3. All changes committed".to_string(),
String::new(),
"To preview the target version without running the pipeline:".to_string(),
format!(" homeboy release {} --dry-run", component.id),
String::new(),
"Or let homeboy manage the changelog automatically:".to_string(),
format!(" homeboy release {} (homeboy generates entries from conventional commits)", component.id),
if !version_targets_list.is_empty() {
format!("Version target files:\n{}", version_targets_list.join("\n"))
} else {
String::new()
},
].into_iter().filter(|s| !s.is_empty()).collect()),
));
}
Ok(ChangelogValidationResult {
changelog_path: changelog_path.to_string_lossy().to_string(),
changelog_finalized: true,
changelog_changed,
})
}
pub fn validate_baseline_alignment(
version: Option<&ComponentVersionSnapshot>,
baseline_ref: Option<&str>,
) -> Option<String> {
let version_snapshot = version?;
let baseline = baseline_ref?;
let baseline_version = baseline.strip_prefix('v').unwrap_or(baseline);
if version_snapshot.version != baseline_version {
Some(format!(
"Version mismatch: source files show {} but git baseline is {}. Consider creating a tag or bumping the version.",
version_snapshot.version, baseline
))
} else {
None
}
}
pub(crate) fn bump_component_version(
component: &Component,
bump_type: &str,
changelog_entries: Option<&std::collections::HashMap<String, Vec<String>>>,
) -> Result<BumpResult> {
component::validate_local_path(component)?;
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 = text::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 target_infos = pre_validate_version_targets(targets, &component.local_path, &old_version)?;
let changelog_validation =
validate_and_finalize_changelog(component, &old_version, &new_version, changelog_entries)?;
for info in &target_infos {
let replaced_count =
update_version_in_file(&info.full_path, &info.pattern, &old_version, &new_version)?;
if replaced_count != info.match_count {
return Err(Error::internal_unexpected(format!(
"Unexpected replacement count in {}",
info.file
)));
}
}
let has_cargo_target = target_infos.iter().any(|t| t.file.ends_with("Cargo.toml"));
if has_cargo_target {
log_status!(
"version",
"Regenerating Cargo.lock after Cargo.toml version bump"
);
let lockfile_result = std::process::Command::new("cargo")
.args(["generate-lockfile"])
.current_dir(&component.local_path)
.output();
if let Err(e) = lockfile_result {
log_status!("warning", "Failed to regenerate Cargo.lock: {}", e);
}
}
let since_tags_replaced = replace_since_tag_placeholders(component, &new_version)?;
hooks::run_hooks(
component,
hooks::events::PRE_VERSION_BUMP,
HookFailureMode::Fatal,
)?;
hooks::run_hooks(
component,
hooks::events::POST_VERSION_BUMP,
HookFailureMode::Fatal,
)?;
Ok(BumpResult {
old_version,
new_version,
targets: target_infos,
since_tags_replaced,
changelog_path: changelog_validation.changelog_path,
changelog_finalized: changelog_validation.changelog_finalized,
changelog_changed: changelog_validation.changelog_changed,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::component::Component;
use regex::Regex;
use std::fs;
fn make_test_component(temp_dir: &tempfile::TempDir) -> Component {
Component {
id: "test-component".to_string(),
local_path: temp_dir.path().to_string_lossy().to_string(),
remote_path: String::new(),
changelog_target: Some("CHANGELOG.md".to_string()),
..Default::default()
}
}
#[test]
fn since_tag_regex_matches_placeholders() {
let pattern_str = format!(r"@since\s+({})", DEFAULT_SINCE_PLACEHOLDER);
let regex = Regex::new(&pattern_str).unwrap();
assert!(regex.is_match("@since 0.0.0"));
assert!(regex.is_match("@since NEXT"));
assert!(regex.is_match("@since TBD"));
assert!(regex.is_match("@since TODO"));
assert!(regex.is_match("@since UNRELEASED"));
assert!(regex.is_match("@since x.x.x"));
assert!(regex.is_match(" * @since TBD"));
assert!(regex.is_match(" * @since NEXT"));
assert!(!regex.is_match("@since 1.2.3"));
assert!(!regex.is_match("@since 0.1.0"));
}
#[test]
fn since_tag_replacement_preserves_context() {
let pattern_str = format!(r"@since\s+({})", DEFAULT_SINCE_PLACEHOLDER);
let regex = Regex::new(&pattern_str).unwrap();
let input = " * @since TBD\n * @since 1.0.0\n * @since NEXT\n";
let result = regex.replace_all(input, |caps: ®ex::Captures| {
let full = caps.get(0).unwrap().as_str();
let placeholder = caps.get(1).unwrap().as_str();
full.replacen(placeholder, "2.0.0", 1)
});
assert_eq!(
result,
" * @since 2.0.0\n * @since 1.0.0\n * @since 2.0.0\n"
);
}
#[test]
fn validate_and_finalize_changelog_fails_when_configured_target_stays_stale() {
let temp_dir = tempfile::tempdir().unwrap();
let changelog_path = temp_dir.path().join("CHANGELOG.md");
fs::write(
&changelog_path,
"# Changelog\n\n## [0.4.16] - 2026-03-01\n\n### Fixed\n- old entry\n",
)
.unwrap();
let component = make_test_component(&temp_dir);
validate_and_finalize_changelog(&component, "0.4.16", "0.4.17", None)
.expect_err("configured stale changelog should block release");
let unchanged = fs::read_to_string(&changelog_path).unwrap();
assert!(unchanged.contains("## [0.4.16] - 2026-03-01"));
assert!(!unchanged.contains("0.4.17"));
}
#[test]
fn validate_and_finalize_changelog_bootstraps_first_release_with_generated_entries() {
let temp_dir = tempfile::tempdir().unwrap();
let changelog_path = temp_dir.path().join("CHANGELOG.md");
fs::write(&changelog_path, "# Changelog\n\n## Unreleased\n\n").unwrap();
let component = make_test_component(&temp_dir);
let mut entries: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
entries.insert(
"added".to_string(),
vec!["initial public release".to_string()],
);
let result = validate_and_finalize_changelog(&component, "0.1.0", "0.1.0", Some(&entries))
.expect("bootstrap finalize should succeed");
assert!(result.changelog_finalized);
assert!(result.changelog_changed);
let finalized = fs::read_to_string(&changelog_path).unwrap();
assert!(
finalized.contains("## [0.1.0]"),
"expected first version section, got: {}",
finalized
);
}
}