use semver::Version;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub(crate) struct ReleaseDocEntry {
pub(crate) tag: String,
pub(crate) version: Version,
pub(crate) date: String,
}
pub(crate) fn sync_release_docs(
project_root: &Path,
owner: &str,
repo: &str,
) -> Result<Vec<PathBuf>, String> {
let entries: Vec<ReleaseDocEntry> = release_doc_entries(project_root)?;
let updated_paths = vec![
sync_changelog(project_root, owner, repo, &entries)?,
sync_security_policy(project_root, &entries)?,
];
Ok(updated_paths)
}
fn sync_changelog(
project_root: &Path,
owner: &str,
repo: &str,
entries: &[ReleaseDocEntry],
) -> Result<PathBuf, String> {
let changelog: String = render_changelog(owner, repo, entries);
let path: PathBuf = project_root.join("CHANGELOG.md");
fs::write(&path, changelog)
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
Ok(path)
}
fn sync_security_policy(
project_root: &Path,
entries: &[ReleaseDocEntry],
) -> Result<PathBuf, String> {
let policy: String = render_security_policy(entries);
let path: PathBuf = project_root.join("SECURITY.md");
fs::write(&path, policy).map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
Ok(path)
}
fn release_doc_entries(project_root: &Path) -> Result<Vec<ReleaseDocEntry>, String> {
let output: String = super::run_git_command(
project_root,
&[
"for-each-ref",
"--sort=-creatordate",
"--format=%(refname:strip=2)|%(creatordate:short)",
"refs/tags",
],
)?;
let mut entries: Vec<ReleaseDocEntry> = Vec::new();
for line in output.lines() {
let trimmed: &str = line.trim();
if trimmed.is_empty() {
continue;
}
let Some((tag_raw, date_raw)) = trimmed.split_once('|') else {
continue;
};
let tag: String = tag_raw.trim().to_string();
let date: String = date_raw.trim().to_string();
let Ok(version) = super::parse_version(&tag) else {
continue;
};
entries.push(ReleaseDocEntry { tag, version, date });
}
Ok(entries)
}
pub(crate) fn release_channel(version: &Version) -> &'static str {
let label: String = version.pre.to_string().to_ascii_lowercase();
if label.is_empty() {
"stable"
} else if label.contains("nightly") {
"nightly"
} else {
"experimental"
}
}
pub(crate) fn render_changelog(owner: &str, repo: &str, entries: &[ReleaseDocEntry]) -> String {
let mut out: String = String::new();
out.push_str("# Changelog\n\n");
out.push_str("## Unreleased\n\n");
out.push_str("### Notes\n\n");
out.push_str("- _No unreleased changes yet._\n\n");
for (idx, entry) in entries.iter().enumerate() {
let version_label: String = entry.version.to_string();
let line = if idx + 1 < entries.len() {
let prev_tag: &str = &entries[idx + 1].tag;
format!(
"## [{}](https://github.com/{}/{}/compare/{}...{}) ({})\n\n",
version_label, owner, repo, prev_tag, entry.tag, entry.date
)
} else {
format!(
"## [{}](https://github.com/{}/{}/releases/tag/{}) ({})\n\n",
version_label, owner, repo, entry.tag, entry.date
)
};
out.push_str(&line);
out.push_str("- Release channel: ");
out.push_str(release_channel(&entry.version));
out.push('\n');
out.push_str("- Tag: `");
out.push_str(&entry.tag);
out.push_str("`\n\n");
}
out
}
pub(crate) fn render_security_policy(entries: &[ReleaseDocEntry]) -> String {
let mut out: String = String::new();
out.push_str("# Security Policy\n\n");
out.push_str("## Supported Versions\n\n");
out.push_str("| Version | Channel | Supported |\n");
out.push_str("| ------- | ------- | --------- |\n");
for entry in entries {
out.push_str("| ");
out.push_str(&entry.version.to_string());
out.push_str(" | ");
out.push_str(release_channel(&entry.version));
out.push_str(" | :white_check_mark: |\n");
}
if entries.is_empty() {
out.push_str("| _none yet_ | n/a | :x: |\n");
}
out.push_str("\n## Reporting a Vulnerability\n\n");
out.push_str(
"Report vulnerabilities privately to the maintainers. Include reproduction steps, impact, and affected versions. You will receive an acknowledgement and triage update as soon as possible.\n",
);
out
}