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!();
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!();
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()); }
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;
}
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);
}
}
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()
);
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()
);
}
}
}
}