use chrono::Local;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use crate::component::{self, Component};
use crate::config::read_json_spec_to_string;
use crate::core::local_files::{self, FileSystem};
use crate::core::version;
use crate::error::{Error, Result};
use crate::project;
const DEFAULT_NEXT_SECTION_LABEL: &str = "Unreleased";
const KEEP_A_CHANGELOG_SUBSECTIONS: &[&str] = &[
"### Added",
"### Changed",
"### Deprecated",
"### Removed",
"### Fixed",
"### Security",
];
const VALID_ENTRY_TYPES: &[&str] = &[
"added",
"changed",
"deprecated",
"removed",
"fixed",
"security",
];
fn validate_entry_type(entry_type: &str) -> Result<String> {
let normalized = entry_type.to_lowercase();
if VALID_ENTRY_TYPES.contains(&normalized.as_str()) {
Ok(normalized)
} else {
Err(Error::validation_invalid_argument(
"type",
format!(
"Invalid changelog entry type '{}'. Valid types: Added, Changed, Deprecated, Removed, Fixed, Security",
entry_type
),
None,
Some(vec![
"Use --type added for new features".to_string(),
"Use --type fixed for bug fixes".to_string(),
"Use --type changed for modifications".to_string(),
]),
))
}
}
fn subsection_header_from_type(entry_type: &str) -> String {
let capitalized = entry_type
.chars()
.next()
.map(|c| c.to_uppercase().collect::<String>())
.unwrap_or_default()
+ &entry_type[1..];
format!("### {}", capitalized)
}
#[derive(Debug, Clone)]
pub struct EffectiveChangelogSettings {
pub next_section_label: String,
pub next_section_aliases: Vec<String>,
}
pub fn resolve_effective_settings(component: Option<&Component>) -> EffectiveChangelogSettings {
let project_settings = component
.and_then(|c| component::projects_using(&c.id).ok())
.and_then(|projects| {
if projects.len() == 1 {
project::load(&projects[0]).ok()
} else {
None
}
});
let next_section_label = component
.and_then(|c| c.changelog_next_section_label.clone())
.or_else(|| {
project_settings
.as_ref()
.and_then(|p| p.changelog_next_section_label.clone())
})
.unwrap_or_else(|| DEFAULT_NEXT_SECTION_LABEL.to_string());
let mut next_section_aliases = component
.and_then(|c| c.changelog_next_section_aliases.clone())
.or_else(|| project_settings.and_then(|p| p.changelog_next_section_aliases.clone()))
.unwrap_or_default();
if next_section_aliases.is_empty() {
next_section_aliases.extend([
next_section_label.clone(),
format!("[{}]", next_section_label),
]);
} else {
let label_alias = next_section_label.trim();
let bracketed_alias = format!("[{}]", label_alias);
let mut has_label = false;
let mut has_bracketed = false;
for alias in &next_section_aliases {
let trimmed_alias = alias.trim();
if trimmed_alias == label_alias {
has_label = true;
}
if trimmed_alias == bracketed_alias {
has_bracketed = true;
}
}
if !has_label {
next_section_aliases.push(next_section_label.clone());
}
if !has_bracketed {
next_section_aliases.push(format!("[{}]", next_section_label));
}
}
EffectiveChangelogSettings {
next_section_label,
next_section_aliases,
}
}
pub fn resolve_changelog_path(component: &Component) -> Result<PathBuf> {
let target = component.changelog_target.as_ref().ok_or_else(|| {
Error::validation_invalid_argument(
"component.changelog_target",
"No changelog target configured for component",
None,
Some(vec![
format!(
"Configure: homeboy component set {} --changelog-target \"CHANGELOG.md\"",
component.id
),
format!(
"Create and configure: homeboy changelog init {} --configure",
component.id
),
]),
)
})?;
resolve_target_path(&component.local_path, target)
}
fn resolve_target_path(local_path: &str, file: &str) -> Result<PathBuf> {
let path = if file.starts_with('/') {
PathBuf::from(file)
} else {
Path::new(local_path).join(file)
};
Ok(path)
}
pub fn add_next_section_item(
changelog_content: &str,
next_section_aliases: &[String],
message: &str,
) -> Result<(String, bool)> {
let trimmed_message = message.trim();
if trimmed_message.is_empty() {
return Err(Error::validation_invalid_argument(
"message",
"Changelog message cannot be empty",
None,
None,
));
}
let (with_section, section_changed) =
ensure_next_section(changelog_content, next_section_aliases)?;
let (with_item, item_changed) =
append_item_to_next_section(&with_section, next_section_aliases, trimmed_message)?;
Ok((with_item, section_changed || item_changed))
}
pub fn add_next_section_items(
changelog_content: &str,
next_section_aliases: &[String],
messages: &[String],
) -> Result<(String, bool, usize)> {
if messages.is_empty() {
return Err(Error::validation_invalid_argument(
"messages",
"Changelog messages cannot be empty",
None,
None,
));
}
let (mut content, mut changed) = ensure_next_section(changelog_content, next_section_aliases)?;
let mut items_added = 0;
for message in messages {
let trimmed_message = message.trim();
if trimmed_message.is_empty() {
return Err(Error::validation_invalid_argument(
"messages",
"Changelog messages cannot include empty values",
None,
None,
));
}
let (next, item_changed) =
append_item_to_next_section(&content, next_section_aliases, trimmed_message)?;
if item_changed {
items_added += 1;
changed = true;
}
content = next;
}
Ok((content, changed, items_added))
}
pub fn read_and_add_next_section_item(
component: &Component,
settings: &EffectiveChangelogSettings,
message: &str,
) -> Result<(PathBuf, bool)> {
let path = resolve_changelog_path(component)?;
let content = fs::read_to_string(&path)
.map_err(|e| Error::internal_io(e.to_string(), Some("read changelog".to_string())))?;
let (new_content, changed) =
add_next_section_item(&content, &settings.next_section_aliases, message)?;
if changed {
fs::write(&path, new_content)
.map_err(|e| Error::internal_io(e.to_string(), Some("write changelog".to_string())))?;
}
Ok((path, changed))
}
pub fn read_and_add_next_section_items(
component: &Component,
settings: &EffectiveChangelogSettings,
messages: &[String],
) -> Result<(PathBuf, bool, usize)> {
let path = resolve_changelog_path(component)?;
let content = fs::read_to_string(&path)
.map_err(|e| Error::internal_io(e.to_string(), Some("read changelog".to_string())))?;
let (new_content, changed, items_added) =
add_next_section_items(&content, &settings.next_section_aliases, messages)?;
if changed {
fs::write(&path, new_content)
.map_err(|e| Error::internal_io(e.to_string(), Some("write changelog".to_string())))?;
}
Ok((path, changed, items_added))
}
pub fn read_and_add_next_section_items_typed(
component: &Component,
settings: &EffectiveChangelogSettings,
messages: &[String],
entry_type: &str,
) -> Result<(PathBuf, bool, usize)> {
let path = resolve_changelog_path(component)?;
let content = fs::read_to_string(&path)
.map_err(|e| Error::internal_io(e.to_string(), Some("read changelog".to_string())))?;
let (with_section, _) = ensure_next_section(&content, &settings.next_section_aliases)?;
let mut current_content = with_section;
let mut items_added = 0;
let mut changed = false;
for message in messages {
let trimmed_message = message.trim();
if trimmed_message.is_empty() {
return Err(Error::validation_invalid_argument(
"messages",
"Changelog messages cannot include empty values",
None,
None,
));
}
let (new_content, item_changed) = append_item_to_subsection(
¤t_content,
&settings.next_section_aliases,
trimmed_message,
entry_type,
)?;
if item_changed {
items_added += 1;
changed = true;
}
current_content = new_content;
}
if changed {
fs::write(&path, ¤t_content)
.map_err(|e| Error::internal_io(e.to_string(), Some("write changelog".to_string())))?;
}
Ok((path, changed, items_added))
}
#[derive(Debug, PartialEq)]
enum SectionContentStatus {
Valid, SubsectionsOnly, Empty, }
fn validate_section_content(body_lines: &[&str]) -> SectionContentStatus {
let mut has_subsection_headers = false;
let mut has_bullets = false;
for line in body_lines {
let trimmed = line.trim();
if trimmed.starts_with("## ") {
break;
}
if KEEP_A_CHANGELOG_SUBSECTIONS
.iter()
.any(|h| trimmed.starts_with(h))
{
has_subsection_headers = true;
}
if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
has_bullets = true;
}
}
if has_bullets {
SectionContentStatus::Valid
} else if has_subsection_headers {
SectionContentStatus::SubsectionsOnly
} else {
SectionContentStatus::Empty
}
}
pub fn check_next_section_content(
changelog_content: &str,
next_section_aliases: &[String],
) -> Result<Option<String>> {
let lines: Vec<&str> = changelog_content.lines().collect();
let start = match find_next_section_start(&lines, next_section_aliases) {
Some(idx) => idx,
None => return Ok(None),
};
let end = find_section_end(&lines, start);
let body_lines = &lines[start + 1..end];
let content_status = validate_section_content(body_lines);
match content_status {
SectionContentStatus::Valid => Ok(None),
SectionContentStatus::SubsectionsOnly => Ok(Some(String::from("subsection_headers_only"))),
SectionContentStatus::Empty => Ok(Some(String::from("empty"))),
}
}
pub fn finalize_next_section(
changelog_content: &str,
next_section_aliases: &[String],
new_version: &str,
allow_empty: bool,
) -> Result<(String, bool)> {
if new_version.trim().is_empty() {
return Err(Error::validation_invalid_argument(
"newVersion",
"New version label cannot be empty",
None,
None,
));
}
let lines: Vec<&str> = changelog_content.lines().collect();
let start = find_next_section_start(&lines, next_section_aliases).ok_or_else(|| {
Error::validation_invalid_argument(
"changelog",
"No changelog items found (cannot finalize)",
None,
None,
)
.with_hint("Add changelog items with: `homeboy changelog add <componentId> -m \"...\"`")
.with_hint("Ensure changelog contains all changes since the last version update.")
})?;
let end = find_section_end(&lines, start);
let body_lines = &lines[start + 1..end];
let content_status = validate_section_content(body_lines);
if content_status != SectionContentStatus::Valid {
if allow_empty {
return Ok((changelog_content.to_string(), false));
}
let message = match content_status {
SectionContentStatus::SubsectionsOnly => {
"Changelog has subsection headers but no bullet items"
}
SectionContentStatus::Empty => "Changelog has no items",
_ => unreachable!(),
};
return Err(Error::validation_invalid_argument(
"changelog",
message,
None,
None,
)
.with_hint("Add changelog items with: `homeboy changelog add <componentId> -m \"...\"`")
.with_hint("Ensure changelog contains all changes since the last version update."));
}
let mut out_lines: Vec<String> = Vec::new();
for line in &lines[..start] {
out_lines.push((*line).to_string());
}
if out_lines.last().is_some_and(|l| !l.trim().is_empty()) {
out_lines.push(String::new());
}
let today = Local::now().format("%Y-%m-%d");
out_lines.push(format!("## [{}] - {}", new_version.trim(), today));
out_lines.push(String::new());
let mut started = false;
for line in &lines[start + 1..] {
if !started {
if line.trim().is_empty() {
continue;
}
started = true;
}
out_lines.push((*line).to_string());
}
for idx in 0..out_lines.len().saturating_sub(1) {
let is_bullet = out_lines[idx].trim_start().starts_with("- ");
let next_is_heading = out_lines[idx + 1].trim_start().starts_with("## ");
if is_bullet && next_is_heading {
out_lines.insert(idx + 1, String::new());
break;
}
}
let mut out = out_lines.join("\n");
if !out.ends_with('\n') {
out.push('\n');
}
Ok((out, true))
}
fn normalize_heading_label(label: &str) -> String {
label.trim().trim_matches(['[', ']']).trim().to_string()
}
fn is_matching_next_section_heading(line: &str, aliases: &[String]) -> bool {
let trimmed = line.trim();
if !trimmed.starts_with("##") {
return false;
}
let raw_label = trimmed.trim_start_matches('#').trim();
let normalized = normalize_heading_label(raw_label);
aliases
.iter()
.any(|a| normalize_heading_label(a) == normalized)
}
fn find_next_section_start(lines: &[&str], aliases: &[String]) -> Option<usize> {
lines
.iter()
.position(|line| is_matching_next_section_heading(line, aliases))
}
fn find_section_end(lines: &[&str], start: usize) -> usize {
let mut index = start + 1;
while index < lines.len() {
let trimmed = lines[index].trim();
if trimmed.starts_with("## ") || trimmed == "##" {
break;
}
index += 1;
}
index
}
fn ensure_next_section(content: &str, aliases: &[String]) -> Result<(String, bool)> {
let lines: Vec<&str> = content.lines().collect();
if find_next_section_start(&lines, aliases).is_some() {
return Ok((content.to_string(), false));
}
let default_label = aliases.first().map(|s| s.as_str()).unwrap_or("Unreleased");
let mut insert_at = 0usize;
while insert_at < lines.len() {
let line = lines[insert_at];
if insert_at == 0 && line.trim().starts_with('#') {
insert_at += 1;
continue;
}
if line.trim().starts_with("##") {
break;
}
insert_at += 1;
}
let mut out = String::new();
for (idx, line) in lines.iter().enumerate() {
if idx == insert_at {
if !out.ends_with('\n') && !out.is_empty() {
out.push('\n');
}
if !out.ends_with("\n\n") && !out.is_empty() {
out.push('\n');
}
out.push_str("## ");
out.push_str(default_label);
out.push_str("\n\n");
}
out.push_str(line);
out.push('\n');
}
if insert_at >= lines.len() {
if !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str("## ");
out.push_str(default_label);
out.push('\n');
}
Ok((out, true))
}
fn extract_version_from_heading(label: &str) -> Option<String> {
let semver_pattern = regex::Regex::new(r"\[?(\d+\.\d+\.\d+)\]?").ok()?;
semver_pattern
.captures(label)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
}
pub fn get_latest_finalized_version(content: &str) -> Option<String> {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("## ") {
let label = trimmed.trim_start_matches("## ").trim();
if let Some(version) = extract_version_from_heading(label) {
return Some(version);
}
}
}
None
}
fn append_item_to_next_section(
content: &str,
aliases: &[String],
message: &str,
) -> Result<(String, bool)> {
let lines: Vec<&str> = content.lines().collect();
let start = find_next_section_start(&lines, aliases).ok_or_else(|| {
Error::internal_unexpected("Next changelog section not found (unexpected)".to_string())
})?;
let section_end = find_section_end(&lines, start);
let bullet = format!("- {}", message);
for line in &lines[start + 1..section_end] {
if line.trim() == bullet {
return Ok((content.to_string(), false));
}
}
let has_subsections = lines[start + 1..section_end].iter().any(|l| {
KEEP_A_CHANGELOG_SUBSECTIONS
.iter()
.any(|h| l.trim().starts_with(h))
});
let mut insert_after = start;
let mut has_bullets = false;
let mut first_subsection_idx: Option<usize> = None;
for (i, line) in lines.iter().enumerate().take(section_end).skip(start + 1) {
let trimmed = line.trim();
if first_subsection_idx.is_none()
&& KEEP_A_CHANGELOG_SUBSECTIONS
.iter()
.any(|h| trimmed.starts_with(h))
{
first_subsection_idx = Some(i);
}
if trimmed.starts_with('-') || trimmed.starts_with('*') {
insert_after = i;
has_bullets = true;
} else if !has_bullets && !has_subsections && trimmed.is_empty() {
insert_after = i;
}
}
if has_subsections && !has_bullets {
if let Some(idx) = first_subsection_idx {
insert_after = idx;
}
}
let mut out = String::new();
for (idx, line) in lines.iter().enumerate() {
if has_bullets
&& !has_subsections
&& idx > insert_after
&& idx < section_end
&& lines[idx].trim().is_empty()
{
continue;
}
out.push_str(line);
out.push('\n');
if idx == insert_after {
out.push_str(&bullet);
out.push('\n');
if !has_subsections && section_end < lines.len() {
out.push('\n');
}
}
}
Ok((out, true))
}
fn append_item_to_subsection(
content: &str,
aliases: &[String],
message: &str,
entry_type: &str,
) -> Result<(String, bool)> {
let lines: Vec<&str> = content.lines().collect();
let start = find_next_section_start(&lines, aliases).ok_or_else(|| {
Error::internal_unexpected("Next changelog section not found (unexpected)".to_string())
})?;
let section_end = find_section_end(&lines, start);
let bullet = format!("- {}", message);
let target_header = subsection_header_from_type(entry_type);
for line in &lines[start + 1..section_end] {
if line.trim() == bullet {
return Ok((content.to_string(), false));
}
}
let mut target_subsection_idx: Option<usize> = None;
let mut target_subsection_end: Option<usize> = None;
let mut insert_new_subsection_at: Option<usize> = None;
let mut found_any_subsection = false;
let mut subsection_positions: Vec<(usize, &str)> = Vec::new();
for (i, line) in lines.iter().enumerate().take(section_end).skip(start + 1) {
let trimmed = line.trim();
for header in KEEP_A_CHANGELOG_SUBSECTIONS {
if trimmed.starts_with(header) {
found_any_subsection = true;
subsection_positions.push((i, *header));
if trimmed.starts_with(&target_header) {
target_subsection_idx = Some(i);
}
break;
}
}
}
if let Some(target_idx) = target_subsection_idx {
target_subsection_end = Some(section_end);
for (i, line) in lines
.iter()
.enumerate()
.take(section_end)
.skip(target_idx + 1)
{
let trimmed = line.trim();
if KEEP_A_CHANGELOG_SUBSECTIONS
.iter()
.any(|h| trimmed.starts_with(h))
{
target_subsection_end = Some(i);
break;
}
}
} else if found_any_subsection {
let target_order = KEEP_A_CHANGELOG_SUBSECTIONS
.iter()
.position(|h| h.starts_with(&target_header))
.unwrap_or(0);
for (pos, header) in &subsection_positions {
let header_order = KEEP_A_CHANGELOG_SUBSECTIONS
.iter()
.position(|h| header.starts_with(h))
.unwrap_or(0);
if header_order > target_order {
insert_new_subsection_at = Some(*pos);
break;
}
}
if insert_new_subsection_at.is_none() {
insert_new_subsection_at = Some(section_end);
}
}
let mut out = String::new();
if let Some(target_idx) = target_subsection_idx {
let subsection_end = target_subsection_end.unwrap_or(section_end);
let mut insert_after = target_idx;
for i in (target_idx + 1)..subsection_end {
let trimmed = lines[i].trim();
if trimmed.starts_with('-') || trimmed.starts_with('*') {
insert_after = i;
}
}
for (idx, line) in lines.iter().enumerate() {
out.push_str(line);
out.push('\n');
if idx == insert_after {
out.push_str(&bullet);
out.push('\n');
}
}
} else if let Some(insert_at) = insert_new_subsection_at {
for (idx, line) in lines.iter().enumerate() {
if idx == insert_at {
if !out.ends_with("\n\n") && !out.is_empty() {
out.push('\n');
}
out.push_str(&target_header);
out.push('\n');
out.push_str(&bullet);
out.push_str("\n\n");
}
out.push_str(line);
out.push('\n');
}
if insert_at >= lines.len() {
if !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str(&target_header);
out.push('\n');
out.push_str(&bullet);
out.push('\n');
}
} else {
for (idx, line) in lines.iter().enumerate() {
out.push_str(line);
out.push('\n');
if idx == start {
out.push('\n');
out.push_str(&target_header);
out.push('\n');
out.push_str(&bullet);
out.push('\n');
}
}
}
Ok((out, true))
}
#[derive(Debug, Clone, Serialize)]
pub struct AddItemsOutput {
pub component_id: String,
pub changelog_path: String,
pub next_section_label: String,
pub messages: Vec<String>,
pub items_added: usize,
pub changed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub subsection_type: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(into = "NormalizedAddItemsInput")]
struct AddItemsInput {
component_id: String,
#[serde(default)]
messages: Vec<String>,
#[serde(default, alias = "message")]
message: Option<String>,
}
#[derive(Debug)]
struct NormalizedAddItemsInput {
component_id: String,
messages: Vec<String>,
}
impl From<AddItemsInput> for NormalizedAddItemsInput {
fn from(input: AddItemsInput) -> Self {
let messages = if input.message.is_some() {
input.message.into_iter().collect()
} else {
input.messages
};
Self {
component_id: input.component_id,
messages,
}
}
}
pub fn add_items_bulk(json_spec: &str) -> Result<AddItemsOutput> {
let raw = read_json_spec_to_string(json_spec)?;
let input: AddItemsInput = serde_json::from_str(&raw).map_err(|e| {
Error::validation_invalid_json(
e,
Some("parse changelog add input".to_string()),
Some(raw.chars().take(200).collect::<String>()),
)
.with_hint(r#"Example: {"component_id": "my-component", "messages": ["Fixed: bug"]}"#)
})?;
let normalized: NormalizedAddItemsInput = input.into();
add_items(Some(&normalized.component_id), &normalized.messages, None)
}
pub fn add_items(
component_id: Option<&str>,
messages: &[String],
entry_type: Option<&str>,
) -> Result<AddItemsOutput> {
if let Some(input) = component_id {
if crate::config::is_json_input(input) {
return add_items_bulk(input);
}
}
let id = component_id.ok_or_else(|| {
Error::validation_invalid_argument(
"componentId",
"Missing componentId",
None,
Some(vec![
"Provide a component ID: homeboy changelog add <component-id> -m \"message\""
.to_string(),
"List available components: homeboy component list".to_string(),
]),
)
})?;
if messages.is_empty() {
return Err(Error::validation_invalid_argument(
"message",
"Missing message",
None,
None,
));
}
let validated_type = entry_type.map(validate_entry_type).transpose()?;
let component = component::load(id)?;
let settings = resolve_effective_settings(Some(&component));
let (path, changed, items_added) = if let Some(ref entry_type_val) = validated_type {
read_and_add_next_section_items_typed(&component, &settings, messages, entry_type_val)?
} else {
read_and_add_next_section_items(&component, &settings, messages)?
};
Ok(AddItemsOutput {
component_id: id.to_string(),
changelog_path: path.to_string_lossy().to_string(),
next_section_label: settings.next_section_label,
messages: messages.to_vec(),
items_added,
changed,
subsection_type: validated_type,
})
}
#[derive(Debug, Clone, Serialize)]
pub struct ShowOutput {
pub component_id: String,
pub changelog_path: String,
pub content: String,
}
pub fn show(component_id: &str) -> Result<ShowOutput> {
let component = component::load(component_id)?;
let changelog_path = resolve_changelog_path(&component)?;
let content = fs::read_to_string(&changelog_path).map_err(|e| {
Error::internal_io(
e.to_string(),
Some(format!("read changelog at {}", changelog_path.display())),
)
})?;
Ok(ShowOutput {
component_id: component_id.to_string(),
changelog_path: changelog_path.to_string_lossy().to_string(),
content,
})
}
#[derive(Debug, Clone, Serialize)]
pub struct InitOutput {
pub component_id: String,
pub changelog_path: String,
pub initial_version: String,
pub next_section_label: String,
pub created: bool,
pub changed: bool,
pub configured: bool,
}
fn generate_template(initial_version: &str, next_label: &str) -> String {
let today = Local::now().format("%Y-%m-%d");
format!(
"# Changelog\n\n## {}\n\n## [{}] - {}\n- Initial release\n",
next_label, initial_version, today
)
}
pub fn init(component_id: &str, path: Option<&str>, configure: bool) -> Result<InitOutput> {
let component = component::load(component_id)?;
let settings = resolve_effective_settings(Some(&component));
let relative_path = path.unwrap_or("CHANGELOG.md");
let changelog_path = resolve_target_path(&component.local_path, relative_path)?;
if let Some(ref configured_target) = component.changelog_target {
let configured_path = resolve_target_path(&component.local_path, configured_target)?;
if path.is_none() || path == Some(configured_target) {
if configured_path.exists() {
return Err(Error::validation_invalid_argument(
"changelog",
"Changelog already exists for this component",
None,
Some(vec![
format!("Existing changelog at: {}", configured_path.display()),
format!("View with: homeboy changelog show {}", component_id),
format!("Or use --path to specify a different location"),
]),
));
}
}
} else {
let changelog_candidates = [
"CHANGELOG.md",
"changelog.md",
"docs/CHANGELOG.md",
"docs/changelog.md",
"HISTORY.md",
];
let local_path = Path::new(&component.local_path);
for candidate in &changelog_candidates {
let candidate_path = local_path.join(candidate);
if candidate_path.exists() {
return Err(Error::validation_invalid_argument(
"changelog",
"Found existing changelog file",
None,
Some(vec![
format!("Existing changelog at: {}", candidate_path.display()),
format!("Configure and use it: homeboy changelog init {} --path \"{}\" --configure", component_id, candidate),
format!("View with: homeboy changelog show {}", component_id),
]),
));
}
}
}
let configured = if configure {
component::set_changelog_target(component_id, relative_path)?;
true
} else {
false
};
if changelog_path.exists() {
let content = fs::read_to_string(&changelog_path)
.map_err(|e| Error::internal_io(e.to_string(), Some("read changelog".to_string())))?;
let (new_content, changed) = ensure_next_section(&content, &settings.next_section_aliases)?;
if changed {
local_files::local().write(&changelog_path, &new_content)?;
}
return Ok(InitOutput {
component_id: component_id.to_string(),
changelog_path: changelog_path.to_string_lossy().to_string(),
initial_version: String::new(),
next_section_label: settings.next_section_label,
created: false,
changed,
configured,
});
}
let version_info = version::read_version(Some(component_id))?;
let initial_version = version_info.version;
if let Some(parent) = changelog_path.parent() {
local_files::local().ensure_dir(parent)?;
}
let content = generate_template(&initial_version, &settings.next_section_label);
local_files::local().write(&changelog_path, &content)?;
Ok(InitOutput {
component_id: component_id.to_string(),
changelog_path: changelog_path.to_string_lossy().to_string(),
initial_version,
next_section_label: settings.next_section_label,
created: true,
changed: true,
configured,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_next_section_items_appends_multiple_in_order() {
let content = "# Changelog\n\n## Unreleased\n\n## 0.1.0\n";
let aliases = vec!["Unreleased".to_string(), "[Unreleased]".to_string()];
let messages = vec!["First".to_string(), "Second".to_string()];
let (out, changed, items_added) =
add_next_section_items(content, &aliases, &messages).unwrap();
assert!(changed);
assert_eq!(items_added, 2);
assert!(out.contains("## Unreleased\n\n- First\n- Second\n\n## 0.1.0"));
}
#[test]
fn add_next_section_items_dedupes_exact_bullets() {
let content = "# Changelog\n\n## Unreleased\n\n- First\n\n## 0.1.0\n";
let aliases = vec!["Unreleased".to_string(), "[Unreleased]".to_string()];
let messages = vec!["First".to_string(), "Second".to_string()];
let (out, changed, items_added) =
add_next_section_items(content, &aliases, &messages).unwrap();
assert!(changed);
assert_eq!(items_added, 1);
assert!(out.contains("- First"));
assert!(out.contains("- Second"));
}
#[test]
fn finalize_moves_body_to_new_version_and_omits_empty_next_section() {
let content = "# Changelog\n\n## Unreleased\n\n- First\n- Second\n\n## 0.1.0\n\n- Old\n";
let aliases = vec!["Unreleased".to_string(), "[Unreleased]".to_string()];
let (out, changed) = finalize_next_section(content, &aliases, "0.2.0", false).unwrap();
assert!(changed);
assert!(!out.contains("## Unreleased\n\n## [0.2.0]"));
assert!(out.contains("## [0.2.0] - "));
assert!(out.contains("- First\n- Second"));
assert!(out.contains("## 0.1.0"));
}
#[test]
fn finalize_errors_on_empty_next_section_by_default() {
let content = "# Changelog\n\n## Unreleased\n\n\n## 0.1.0\n\n- Old\n";
let aliases = vec!["Unreleased".to_string(), "[Unreleased]".to_string()];
let err = finalize_next_section(content, &aliases, "0.2.0", false).unwrap_err();
assert_eq!(err.code.as_str(), "validation.invalid_argument");
assert!(err.message.contains("Invalid"));
}
#[test]
fn get_latest_finalized_version_finds_first_semver() {
let content = "# Changelog\n\n## Unreleased\n\n## 0.2.16\n\n- Item\n\n## 0.2.15\n";
assert_eq!(
get_latest_finalized_version(content),
Some("0.2.16".to_string())
);
}
#[test]
fn get_latest_finalized_version_parses_bracketed_format() {
let content = "# Changelog\n\n## Unreleased\n\n## [1.0.0]\n\n## 0.2.16\n";
assert_eq!(
get_latest_finalized_version(content),
Some("1.0.0".to_string())
);
}
#[test]
fn get_latest_finalized_version_parses_dated_format() {
let content = "# Changelog\n\n## Unreleased\n\n## [1.0.0] - 2025-01-14\n\n## 0.2.16\n";
assert_eq!(
get_latest_finalized_version(content),
Some("1.0.0".to_string())
);
}
#[test]
fn get_latest_finalized_version_returns_none_when_no_versions() {
let content = "# Changelog\n\n## Unreleased\n\n- Item\n";
assert_eq!(get_latest_finalized_version(content), None);
}
#[test]
fn validate_section_content_with_direct_bullets() {
let lines = vec!["- Item one", "- Item two"];
assert_eq!(
validate_section_content(&lines),
SectionContentStatus::Valid
);
}
#[test]
fn validate_section_content_with_subsection_bullets() {
let lines = vec![
"### Added",
"",
"- New feature",
"",
"### Fixed",
"",
"- Bug fix",
];
assert_eq!(
validate_section_content(&lines),
SectionContentStatus::Valid
);
}
#[test]
fn validate_section_content_subsections_only() {
let lines = vec!["### Added", "", "### Changed", ""];
assert_eq!(
validate_section_content(&lines),
SectionContentStatus::SubsectionsOnly
);
}
#[test]
fn validate_section_content_empty() {
let lines = vec!["", ""];
assert_eq!(
validate_section_content(&lines),
SectionContentStatus::Empty
);
}
#[test]
fn finalize_preserves_subsection_structure() {
let content =
"# Changelog\n\n## Unreleased\n\n### Added\n\n- Feature\n\n### Fixed\n\n- Bug\n\n## 0.1.0\n";
let aliases = vec!["Unreleased".to_string()];
let (out, changed) = finalize_next_section(content, &aliases, "0.2.0", false).unwrap();
assert!(changed);
assert!(out.contains("## [0.2.0]"));
assert!(out.contains("### Added"));
assert!(out.contains("### Fixed"));
assert!(out.contains("- Feature"));
assert!(out.contains("- Bug"));
}
#[test]
fn finalize_errors_on_empty_subsections() {
let content = "# Changelog\n\n## Unreleased\n\n### Added\n\n### Changed\n\n## 0.1.0\n";
let aliases = vec!["Unreleased".to_string()];
let result = finalize_next_section(content, &aliases, "0.2.0", false);
assert!(result.is_err());
let err = result.unwrap_err();
let problem = err
.details
.get("problem")
.and_then(|v| v.as_str())
.unwrap_or("");
assert!(
problem.contains("subsection"),
"Error should mention subsection headers: {}",
problem
);
}
#[test]
fn append_item_works_with_subsection_structure() {
let content = "# Changelog\n\n## Unreleased\n\n### Added\n\n- Existing\n\n## 0.1.0\n";
let aliases = vec!["Unreleased".to_string()];
let (out, changed) = append_item_to_next_section(content, &aliases, "New item").unwrap();
assert!(changed);
assert!(out.contains("- New item"));
assert!(out.contains("- Existing\n- New item"));
}
#[test]
fn append_item_to_empty_subsection() {
let content = "# Changelog\n\n## Unreleased\n\n### Added\n\n### Fixed\n\n## 0.1.0\n";
let aliases = vec!["Unreleased".to_string()];
let (out, changed) = append_item_to_next_section(content, &aliases, "New item").unwrap();
assert!(changed);
assert!(out.contains("- New item"));
assert!(out.contains("### Added\n- New item"));
}
#[test]
fn append_item_preserves_multiple_subsections() {
let content =
"# Changelog\n\n## Unreleased\n\n### Added\n\n- Feature 1\n\n### Fixed\n\n- Bug 1\n\n## 0.1.0\n";
let aliases = vec!["Unreleased".to_string()];
let (out, changed) = append_item_to_next_section(content, &aliases, "New item").unwrap();
assert!(changed);
assert!(out.contains("- New item"));
assert!(out.contains("### Added"));
assert!(out.contains("### Fixed"));
assert!(out.contains("- Feature 1"));
assert!(out.contains("- Bug 1"));
}
#[test]
fn validate_entry_type_accepts_valid_types() {
assert!(validate_entry_type("added").is_ok());
assert!(validate_entry_type("Added").is_ok());
assert!(validate_entry_type("FIXED").is_ok());
assert!(validate_entry_type("changed").is_ok());
assert!(validate_entry_type("deprecated").is_ok());
assert!(validate_entry_type("removed").is_ok());
assert!(validate_entry_type("security").is_ok());
}
#[test]
fn validate_entry_type_rejects_invalid_types() {
assert!(validate_entry_type("invalid").is_err());
assert!(validate_entry_type("feature").is_err());
assert!(validate_entry_type("bugfix").is_err());
}
#[test]
fn append_item_to_subsection_adds_to_existing() {
let content = "# Changelog\n\n## Unreleased\n\n### Fixed\n\n- Existing fix\n\n## 0.1.0\n";
let aliases = vec!["Unreleased".to_string()];
let (out, changed) =
append_item_to_subsection(content, &aliases, "New bug fix", "fixed").unwrap();
assert!(changed);
assert!(out.contains("- Existing fix"));
assert!(out.contains("- New bug fix"));
assert!(out.contains("- Existing fix\n- New bug fix"));
}
#[test]
fn append_item_to_subsection_creates_new_subsection() {
let content = "# Changelog\n\n## Unreleased\n\n### Added\n\n- Feature\n\n## 0.1.0\n";
let aliases = vec!["Unreleased".to_string()];
let (out, changed) =
append_item_to_subsection(content, &aliases, "Bug fix", "fixed").unwrap();
assert!(changed);
assert!(out.contains("### Fixed"));
assert!(out.contains("- Bug fix"));
assert!(out.contains("### Added"));
assert!(out.contains("- Feature"));
}
#[test]
fn append_item_to_subsection_creates_first_subsection() {
let content = "# Changelog\n\n## Unreleased\n\n## 0.1.0\n";
let aliases = vec!["Unreleased".to_string()];
let (out, changed) =
append_item_to_subsection(content, &aliases, "New feature", "added").unwrap();
assert!(changed);
assert!(out.contains("### Added"));
assert!(out.contains("- New feature"));
}
#[test]
fn append_item_to_subsection_maintains_canonical_order() {
let content = "# Changelog\n\n## Unreleased\n\n### Fixed\n\n- Bug fix\n\n## 0.1.0\n";
let aliases = vec!["Unreleased".to_string()];
let (out, changed) =
append_item_to_subsection(content, &aliases, "New feature", "added").unwrap();
assert!(changed);
assert!(out.contains("### Added"));
assert!(out.contains("- New feature"));
let added_pos = out.find("### Added").unwrap();
let fixed_pos = out.find("### Fixed").unwrap();
assert!(
added_pos < fixed_pos,
"Added should come before Fixed in canonical order"
);
}
#[test]
fn append_item_to_subsection_dedupes_existing() {
let content = "# Changelog\n\n## Unreleased\n\n### Fixed\n\n- Bug fix\n\n## 0.1.0\n";
let aliases = vec!["Unreleased".to_string()];
let (out, changed) =
append_item_to_subsection(content, &aliases, "Bug fix", "fixed").unwrap();
assert!(!changed);
assert_eq!(out.matches("- Bug fix").count(), 1);
}
}