rok-cli 0.3.6

Developer CLI for rok-based Axum applications
use std::process::Command;

type ChangelogItem = (String, String, String);
type ChangelogGroup<'a> = (&'a str, &'a Vec<&'a ChangelogItem>);

pub fn run() {
    println!(
        "{}",
        console::style("╔══════════════════════════════════════╗").bold()
    );
    println!(
        "{}",
        console::style("║      rok changelog — unreleased      ║").bold()
    );
    println!(
        "{}",
        console::style("╚══════════════════════════════════════╝").bold()
    );
    println!();

    // Get the last tag
    let last_tag = Command::new("git")
        .args(["describe", "--tags", "--abbrev=0"])
        .output();

    let since = match last_tag {
        Ok(out) if out.status.success() => {
            let tag = String::from_utf8_lossy(&out.stdout).trim().to_string();
            println!("  {} Last tag: {}", console::style("").cyan(), &tag);
            tag
        }
        _ => {
            println!(
                "  {} No tags found — showing all commits",
                console::style("").cyan()
            );
            String::new()
        }
    };

    println!();

    // Get commits since last tag
    let mut args: Vec<String> = vec![
        "log".into(),
        "--pretty=format:%s||%h||%an".into(),
        "--no-merges".into(),
    ];
    if !since.is_empty() {
        args.push(format!("{}..HEAD", since));
    } else {
        args.push("-30".into()); // last 30 if no tags
    }

    let log = Command::new("git").args(&args).output();

    let commits = match log {
        Ok(out) if out.status.success() => {
            let stdout = String::from_utf8_lossy(&out.stdout);
            stdout
                .lines()
                .map(|l| {
                    let parts: Vec<&str> = l.splitn(3, "||").collect();
                    let msg = parts.first().unwrap_or(&"");
                    let hash = parts.get(1).unwrap_or(&"");
                    let author = parts.get(2).unwrap_or(&"");
                    (msg.to_string(), hash.to_string(), author.to_string())
                })
                .collect::<Vec<_>>()
        }
        _ => {
            println!("  {} Could not get git log", console::style("").red());
            return;
        }
    };

    if commits.is_empty() {
        println!("  {} No unreleased commits", console::style("").cyan());
        return;
    }

    // Group by conventional commit type
    let mut added: Vec<&(String, String, String)> = Vec::new();
    let mut fixed: Vec<&(String, String, String)> = Vec::new();
    let mut changed: Vec<&(String, String, String)> = Vec::new();
    let mut removed: Vec<&(String, String, String)> = Vec::new();
    let mut other: Vec<&(String, String, String)> = Vec::new();

    for commit in &commits {
        let msg = &commit.0;
        if msg.starts_with("feat")
            || msg.starts_with("feature")
            || msg.contains("Add")
            || msg.contains("add")
        {
            added.push(commit);
        } else if msg.starts_with("fix") || msg.contains("Fix") || msg.contains("fix") {
            fixed.push(commit);
        } else if msg.starts_with("refactor") || msg.starts_with("perf") || msg.starts_with("style")
        {
            changed.push(commit);
        } else if msg.starts_with("revert")
            || msg.starts_with("remove")
            || msg.starts_with("delete")
        {
            removed.push(commit);
        } else {
            other.push(commit);
        }
    }

    // Print grouped changelog
    let groups: Vec<ChangelogGroup<'_>> = vec![
        ("Added", &added),
        ("Fixed", &fixed),
        ("Changed", &changed),
        ("Removed", &removed),
    ];

    let has_content = groups.iter().any(|(_, items)| !items.is_empty());

    if has_content {
        for (title, items) in &groups {
            if items.is_empty() {
                continue;
            }
            println!("  {}:", console::style(title).green().bold());
            for (msg, hash, author) in *items {
                let short_msg = msg
                    .trim()
                    .trim_start_matches("feat:")
                    .trim_start_matches("fix:")
                    .trim_start_matches("refactor:")
                    .trim()
                    .trim_start_matches('(')
                    .trim();
                println!(
                    "    {}  {}  ({})",
                    console::style(&hash[..7]).dim(),
                    short_msg,
                    console::style(author).dim()
                );
            }
            println!();
        }
    }

    if !other.is_empty() {
        println!("  {}:", console::style("Other").yellow().bold());
        for (msg, hash, author) in &other {
            println!(
                "    {}  {}  ({})",
                console::style(&hash[..7]).dim(),
                msg.trim(),
                console::style(author).dim()
            );
        }
        println!();
    }

    println!(
        "  {} {} unreleased commits",
        console::style("Summary:").bold(),
        commits.len()
    );

    // Show date range
    let range_arg = if !since.is_empty() {
        format!("{}..HEAD", since)
    } else {
        "-30".to_string()
    };
    let date_range = Command::new("git")
        .args(["log", "--pretty=format:%as", "--no-merges", &range_arg])
        .output();
    if let Ok(out) = date_range {
        if out.status.success() {
            let stdout = String::from_utf8_lossy(&out.stdout).to_string();
            let dates: Vec<&str> = stdout.lines().collect();
            if let (Some(first), Some(last)) = (dates.last(), dates.first()) {
                println!(
                    "  {} {}{} ({} commits)",
                    console::style("Date range:").dim(),
                    first,
                    last,
                    commits.len()
                );
            }
        }
    }
}