apiforge 0.2.6

Production-grade API release automation CLI. From merged code to healthy pods in production — one command.
Documentation
use crate::error::Result;
use crate::integrations::git::{CommitInfo, GitRepo};
use crate::steps::{Step, StepContext, StepOutput};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use std::fs;
use std::path::PathBuf;

pub struct ChangelogStep {
    version: String,
    previous_tag: Option<String>,
}

impl ChangelogStep {
    pub fn new(version: String, previous_tag: Option<String>) -> Self {
        Self {
            version,
            previous_tag,
        }
    }

    fn format_changelog(
        version: &str,
        commits: &[CommitInfo],
        previous_tag: Option<&str>,
    ) -> String {
        let mut output = String::new();
        let now: DateTime<Utc> = Utc::now();

        output.push_str(&format!(
            "## [{}] - {}\n\n",
            version,
            now.format("%Y-%m-%d")
        ));

        if commits.is_empty() {
            output.push_str("No changes recorded.\n\n");
            return output;
        }

        let mut features = Vec::new();
        let mut fixes = Vec::new();
        let mut other = Vec::new();

        for commit in commits {
            let msg = commit.message.lines().next().unwrap_or("").trim();
            if msg.is_empty() {
                continue;
            }

            if msg.starts_with("feat:") || msg.starts_with("feature:") {
                features.push(
                    msg.trim_start_matches("feat:")
                        .trim_start_matches("feature:")
                        .trim(),
                );
            } else if msg.starts_with("fix:") {
                fixes.push(msg.trim_start_matches("fix:").trim());
            } else {
                other.push(msg);
            }
        }

        if !features.is_empty() {
            output.push_str("### Features\n");
            for feature in features {
                output.push_str(&format!("- {}\n", feature));
            }
            output.push('\n');
        }

        if !fixes.is_empty() {
            output.push_str("### Bug Fixes\n");
            for fix in fixes {
                output.push_str(&format!("- {}\n", fix));
            }
            output.push('\n');
        }

        if !other.is_empty() {
            output.push_str("### Other Changes\n");
            for change in other {
                output.push_str(&format!("- {}\n", change));
            }
            output.push('\n');
        }

        if let Some(prev) = previous_tag {
            output.push_str(&format!("**Full Changelog**: {}...{}\n\n", prev, version));
        }

        output
    }

    fn get_changelog_path(&self) -> Result<PathBuf> {
        let repo = GitRepo::open()?;
        Ok(repo.root_path().join("CHANGELOG.md"))
    }

    fn prepend_to_changelog(&self, path: &PathBuf, new_content: &str) -> Result<()> {
        let existing = if path.exists() {
            fs::read_to_string(path)?
        } else {
            "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n".to_string()
        };

        // Find the end of the header section (first double newline)
        // If not found, treat entire content as rest (new file case)
        let updated = if let Some(header_end) = existing.find("\n\n") {
            // Safe to split: header_end + 2 is guaranteed to be valid since we found "\n\n"
            let (header, rest) = existing.split_at(header_end + 2);
            format!("{}{}{}", header, new_content, rest)
        } else {
            // No double newline found - append to existing content
            format!("{}\n\n{}", existing.trim_end(), new_content)
        };

        fs::write(path, updated)?;

        Ok(())
    }

    fn categorize_commits(commits: &[String]) -> (usize, usize, usize) {
        let mut features = 0;
        let mut fixes = 0;
        let mut other = 0;

        for commit in commits {
            let msg = commit.trim();
            if msg.is_empty() {
                continue;
            }
            if msg.starts_with("feat:") || msg.starts_with("feature:") {
                features += 1;
            } else if msg.starts_with("fix:") {
                fixes += 1;
            } else {
                other += 1;
            }
        }

        (features, fixes, other)
    }
}

#[async_trait]
impl Step for ChangelogStep {
    fn name(&self) -> &str {
        "changelog"
    }

    fn description(&self) -> &str {
        "Generate changelog from commits"
    }

    async fn validate(&self, _ctx: &StepContext) -> Result<()> {
        GitRepo::open()?;
        Ok(())
    }

    async fn execute(&self, _ctx: &StepContext) -> Result<StepOutput> {
        let repo = GitRepo::open()?;

        let commits = if let Some(ref prev_tag) = self.previous_tag {
            repo.get_commits_between(prev_tag, "HEAD")?
        } else {
            Vec::new()
        };

        let changelog_content =
            Self::format_changelog(&self.version, &commits, self.previous_tag.as_deref());

        let path = self.get_changelog_path()?;
        self.prepend_to_changelog(&path, &changelog_content)?;

        Ok(StepOutput::ok(format!(
            "Generated changelog with {} commits",
            commits.len()
        )))
    }

    async fn dry_run(&self, _ctx: &StepContext) -> Result<StepOutput> {
        let repo = GitRepo::open()?;

        let commits = if let Some(ref prev_tag) = self.previous_tag {
            repo.get_commits_between(prev_tag, "HEAD")?
        } else {
            Vec::new()
        };

        // Generate preview
        let preview_content =
            Self::format_changelog(&self.version, &commits, self.previous_tag.as_deref());

        // Count categorized commits
        let commit_messages: Vec<String> = commits
            .iter()
            .map(|c| c.message.lines().next().unwrap_or("").to_string())
            .collect();
        let (features, fixes, other) = Self::categorize_commits(&commit_messages);

        let details = crate::steps::DryRunDetails {
            file_changes: vec![crate::steps::FileChange {
                path: "CHANGELOG.md".to_string(),
                operation: crate::steps::FileOperation::Modify,
                diff: None,
            }],
            docker_preview: None,
            notes: vec![
                format!("Found {} new commits", commits.len()),
                format!(
                    "  - {} features, {} fixes, {} other",
                    features, fixes, other
                ),
                if commits.is_empty() {
                    "⚠ No commits since last tag - changelog will be empty".to_string()
                } else {
                    format!(
                        "Preview:\n{}",
                        preview_content
                            .lines()
                            .take(20)
                            .collect::<Vec<_>>()
                            .join("\n")
                    )
                },
            ],
        };

        Ok(StepOutput::ok(format!(
            "Would generate changelog with {} commits",
            commits.len()
        ))
        .with_dry_run_details(details))
    }

    async fn rollback(&self, _ctx: &StepContext) -> Result<()> {
        let repo = GitRepo::open()?;
        let path = self.get_changelog_path()?;
        let rel_path = path.strip_prefix(repo.root_path()).map_err(|_| {
            crate::error::ApiForgError::Config("Invalid changelog path".to_string())
        })?;
        repo.checkout_file(rel_path)?;
        tracing::info!("Rolled back changelog changes");
        Ok(())
    }
}