use std::io::Write;
use anyhow::{
Context,
Result,
};
use clap::Parser;
#[derive(Parser, Debug)]
pub struct ReleasePageArgs {
#[arg(long)]
pub since_tag: Option<String>,
#[arg(long)]
pub range: Option<String>,
#[arg(long)]
pub for_version: Option<String>,
#[arg(short, long)]
pub output: Option<String>,
#[arg(long)]
pub no_network: bool,
#[arg(long)]
pub owner: Option<String>,
#[arg(long)]
pub repo: Option<String>,
}
pub fn release_page(args: ReleasePageArgs) -> Result<()> {
let rt = tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
rt.block_on(release_page_async(args))
}
async fn release_page_async(args: ReleasePageArgs) -> Result<()> {
let mut logger = cargo_plugin_utils::logger::Logger::new();
logger.status("Generating", "release page");
let package = super::badge::find_package().await?;
let mut output = Vec::new();
logger.status("Generating", "badges");
let version_display = if let Some(ref version) = args.for_version {
if version.starts_with('v') || version.starts_with('V') {
version.clone()
} else {
format!("v{}", version)
}
} else {
format!("v{}", package.version)
};
writeln!(&mut output, "# {} {}\n", package.name, version_display)?;
if let Some(description) = &package.description {
writeln!(&mut output, "{}\n", description)?;
}
if let Some(repository) = &package.repository {
if repository.starts_with("https://github.com/") {
writeln!(&mut output, "[View on GitHub]({})\n", repository)?;
} else if repository.starts_with("http") {
writeln!(&mut output, "[View Repository]({})\n", repository)?;
}
}
super::badge::badge_all(&mut output, &package, args.no_network).await?;
writeln!(&mut output)?;
logger.status("Generating", "PR log");
match generate_pr_log(&mut output, &args).await {
Ok(_) => {
writeln!(&mut output)?;
}
Err(_) => {
logger.warning("Skipping", "PR log (not yet implemented)");
}
}
logger.status("Generating", "changelog");
writeln!(&mut output, "## What's Changed\n")?;
generate_changelog(&mut output, &args)?;
if let Some(repository) = &package.repository
&& repository.starts_with("https://github.com/")
{
if let Some(range) = &args.range {
let parts: Vec<&str> = range.split("..").collect();
if parts.len() == 2 {
let start_tag = parts[0].trim();
let end_tag = parts[1].trim();
writeln!(
&mut output,
"\n**Full Changelog**: [{}/compare/{}...{}]({}/compare/{}...{})\n",
repository, start_tag, end_tag, repository, start_tag, end_tag
)?;
}
} else if let Some(tag) = &args.since_tag {
writeln!(
&mut output,
"\n**Full Changelog**: [{}/compare/{}...HEAD]({}/compare/{}...HEAD)\n",
repository, tag, repository, tag
)?;
}
}
logger.finish();
if let Some(output_path) = args.output {
std::fs::write(&output_path, output)
.with_context(|| format!("Failed to write release page to {}", output_path))?;
logger.status("Written", &output_path);
} else {
std::io::stdout().write_all(&output)?;
}
Ok(())
}
async fn generate_pr_log(_writer: &mut dyn Write, args: &ReleasePageArgs) -> Result<()> {
let pr_log_args = crate::commands::PrLogArgs {
since_tag: args.since_tag.clone(),
output: None, owner: args.owner.clone(),
repo: args.repo.clone(),
};
crate::commands::pr_log(pr_log_args)?;
Ok(())
}
fn generate_changelog(writer: &mut dyn Write, args: &ReleasePageArgs) -> Result<()> {
let changelog_args = crate::commands::ChangelogArgs {
at: args.since_tag.clone(),
range: args.range.clone(),
for_version: args.for_version.clone(), output: None, owner: args.owner.clone(),
repo: args.repo.clone(),
};
let mut changelog_buffer = Vec::new();
crate::commands::changelog::generate_changelog_to_writer(
&mut changelog_buffer,
changelog_args,
)?;
let changelog_str =
String::from_utf8(changelog_buffer).context("Changelog output is not valid UTF-8")?;
let cleaned_changelog = if changelog_str.starts_with("# Changelog") {
if let Some(pos) = changelog_str.find("\n\n") {
changelog_str[pos + 2..].to_string()
} else {
changelog_str
}
} else {
changelog_str
};
write!(writer, "{}", cleaned_changelog)?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::process::Command;
use tempfile::TempDir;
use super::*;
fn create_test_cargo_project() -> TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
r#"
[package]
name = "test-package"
version = "1.0.0"
description = "Test package"
repository = "https://github.com/test/repo"
"#,
)
.unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::write(src_dir.join("lib.rs"), "// Test library\n").unwrap();
Command::new("git")
.arg("init")
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(dir.path())
.output()
.unwrap();
std::fs::write(dir.path().join("README.md"), "# Test\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "chore: initial commit"])
.current_dir(dir.path())
.output()
.unwrap();
dir
}
#[tokio::test]
#[serial_test::serial]
#[cfg_attr(target_os = "windows", ignore)] async fn test_release_page_with_for_version() {
let _dir = create_test_cargo_project();
let dir_path = _dir.path().to_path_buf();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir_path).unwrap();
let output_file = tempfile::NamedTempFile::new().unwrap();
let output_path = output_file.path().to_string_lossy().to_string();
let args = ReleasePageArgs {
since_tag: None,
range: None,
for_version: Some("v0.2.0".to_string()),
output: Some(output_path.clone()),
no_network: true, owner: Some("test".to_string()),
repo: Some("repo".to_string()),
};
let result = release_page_async(args).await;
std::env::set_current_dir(original_dir).unwrap();
assert!(result.is_ok(), "Release page generation should succeed");
let content = std::fs::read_to_string(output_path).unwrap();
assert!(
content.contains("test-package v0.2.0"),
"Header should include for_version"
);
}
#[tokio::test]
#[serial_test::serial]
#[cfg_attr(target_os = "windows", ignore)] async fn test_release_page_with_for_version_no_v_prefix() {
let _dir = create_test_cargo_project();
let dir_path = _dir.path().to_path_buf();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir_path).unwrap();
let output_file = tempfile::NamedTempFile::new().unwrap();
let output_path = output_file.path().to_string_lossy().to_string();
let args = ReleasePageArgs {
since_tag: None,
range: None,
for_version: Some("0.2.0".to_string()), output: Some(output_path.clone()),
no_network: true,
owner: Some("test".to_string()),
repo: Some("repo".to_string()),
};
let result = release_page_async(args).await;
std::env::set_current_dir(original_dir).unwrap();
assert!(result.is_ok(), "Release page generation should succeed");
let content = std::fs::read_to_string(output_path).unwrap();
assert!(
content.contains("test-package v0.2.0"),
"Header should normalize version with v prefix"
);
}
#[tokio::test]
#[serial_test::serial]
#[cfg_attr(target_os = "windows", ignore)] async fn test_release_page_without_for_version_uses_package_version() {
let _dir = create_test_cargo_project();
let dir_path = _dir.path().to_path_buf();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir_path).unwrap();
let args = ReleasePageArgs {
since_tag: None,
range: None,
for_version: None, output: None,
no_network: true,
owner: Some("test".to_string()),
repo: Some("repo".to_string()),
};
let output_file = tempfile::NamedTempFile::new().unwrap();
let output_path = output_file.path().to_string_lossy().to_string();
let mut args_with_output = args;
args_with_output.output = Some(output_path.clone());
let result = release_page_async(args_with_output).await;
std::env::set_current_dir(original_dir).unwrap();
assert!(result.is_ok(), "Release page generation should succeed");
let content = std::fs::read_to_string(output_path).unwrap();
assert!(
content.contains("test-package v1.0.0"),
"Header should use package version from Cargo.toml when for_version not specified"
);
}
}