use semver::Version;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use super::adapters::parse_release_version_target;
use super::git_ops::{
parse_canonical_scoped_release_tag_version, release_tag_family, RELEASE_FLAG_SUFFIXES,
};
#[derive(Debug, Clone)]
pub(crate) struct ReleaseDocEntry {
pub(crate) tag: String,
pub(crate) version: Version,
pub(crate) version_label: String,
pub(crate) date: String,
pub(crate) service: String,
pub(crate) channel: &'static str,
}
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)
}
#[allow(dead_code)]
pub(crate) fn heal_security_policy(project_root: &Path) -> Result<PathBuf, String> {
let entries = release_doc_entries(project_root)?;
sync_security_policy(project_root, &entries)
}
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 Some(entry) = classify_release_tag(&tag, &date) else {
continue;
};
entries.push(entry);
}
Ok(entries)
}
pub(crate) fn classify_release_tag(tag: &str, date: &str) -> Option<ReleaseDocEntry> {
let tag = tag.trim();
if tag.is_empty() {
return None;
}
if looks_like_scoped_tag(tag) {
let family_raw = release_tag_family(tag);
let (family, service) = normalize_tag_family(&family_raw);
if !service.is_empty() && service != "v" {
if let Some(version) = parse_scoped_tag_version(tag, &family) {
return Some(ReleaseDocEntry {
tag: tag.to_string(),
version_label: display_version_label(&version),
channel: release_channel(&version),
version,
date: date.to_string(),
service,
});
}
if let Ok((version, _)) = parse_release_version_target(tag) {
return Some(ReleaseDocEntry {
tag: tag.to_string(),
version_label: display_version_label(&version),
channel: release_channel(&version),
version,
date: date.to_string(),
service,
});
}
}
}
let normalized = tag.strip_prefix('v').unwrap_or(tag);
let version = Version::parse(normalized).ok()?;
if let Some(service) = inverted_service_slug(&version) {
let mut core = Version::new(version.major, version.minor, version.patch);
core.build = version.build.clone();
return Some(ReleaseDocEntry {
tag: tag.to_string(),
version_label: display_version_label(&core),
channel: release_channel(&core),
version: core,
date: date.to_string(),
service,
});
}
Some(ReleaseDocEntry {
tag: tag.to_string(),
version_label: display_version_label(&version),
channel: release_channel(&version),
version,
date: date.to_string(),
service: "repository".to_string(),
})
}
fn looks_like_scoped_tag(tag: &str) -> bool {
let family = release_tag_family(tag);
if family.is_empty() || family == "v" || tag.len() <= family.len() {
return false;
}
let rest = &tag[family.len()..];
rest.chars()
.next()
.map(|c| c.is_ascii_digit() || c == 'v')
.unwrap_or(false)
}
fn normalize_tag_family(family_raw: &str) -> (String, String) {
if family_raw.len() > 1 && family_raw.ends_with('v') {
let without_v = &family_raw[..family_raw.len() - 1];
let service = without_v.trim_end_matches('-').to_string();
if !service.is_empty() {
return (family_raw.to_string(), service);
}
}
(
family_raw.to_string(),
family_raw.trim_end_matches('-').to_string(),
)
}
fn parse_scoped_tag_version(tag: &str, family: &str) -> Option<Version> {
if let Some(v) = parse_canonical_scoped_release_tag_version(tag, family) {
return Some(v);
}
if family.ends_with('v') && family.len() > 1 {
let alt = &family[..family.len() - 1];
if let Some(v) = parse_canonical_scoped_release_tag_version(tag, alt) {
return Some(v);
}
if let Some(mut rest) = tag.strip_prefix(alt) {
rest = rest.strip_prefix('v').unwrap_or(rest);
if let Ok(v) = Version::parse(rest) {
return Some(v);
}
}
}
let mut rest = tag.strip_prefix(family)?;
rest = rest.strip_prefix('v').unwrap_or(rest);
for flag in RELEASE_FLAG_SUFFIXES {
let suffix = format!("-{flag}");
if let Some(core) = rest.strip_suffix(suffix.as_str()) {
if let Ok(mut version) = Version::parse(core) {
if version.build.is_empty() {
version.build = semver::BuildMetadata::new(flag).ok()?;
}
return Some(version);
}
}
}
Version::parse(rest).ok()
}
fn inverted_service_slug(version: &Version) -> Option<String> {
if version.pre.is_empty() {
return None;
}
let pre = version.pre.as_str();
if is_channel_prerelease(pre) {
return None;
}
if !pre.chars().any(|c| c.is_ascii_alphabetic()) {
return None;
}
Some(pre.to_string())
}
fn is_channel_prerelease(pre: &str) -> bool {
let lower = pre.to_ascii_lowercase();
let head = lower.split('.').next().unwrap_or(&lower);
RELEASE_FLAG_SUFFIXES
.iter()
.any(|flag| head == *flag || head.starts_with(&format!("{flag}.")))
|| matches!(
head,
"rc" | "preview" | "pre" | "snapshot" | "canary" | "next"
)
}
fn display_version_label(version: &Version) -> String {
version.to_string()
}
pub(crate) fn release_channel(version: &Version) -> &'static str {
let label: String = version.pre.to_string().to_ascii_lowercase();
let build: String = version.build.as_str().to_ascii_lowercase();
if label.is_empty() && build.is_empty() {
"stable"
} else if label.contains("nightly") || build.contains("nightly") {
"nightly"
} else if !label.is_empty() || !build.is_empty() {
if build == "stable" && label.is_empty() {
"stable"
} else {
"experimental"
}
} else {
"stable"
}
}
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: &str = &entry.version_label;
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("- Service: `");
out.push_str(&entry.service);
out.push_str("`\n");
out.push_str("- Release channel: ");
out.push_str(entry.channel);
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(
"Security support is tracked **per service / package release family**. \
Each table lists versions that currently receive security updates for that unit.\n\n",
);
let grouped = group_entries_by_service(entries);
if grouped.is_empty() {
out.push_str("### repository\n\n");
out.push_str("| Version | Channel | Supported |\n");
out.push_str("| ------- | ------- | --------- |\n");
out.push_str("| _none yet_ | n/a | :x: |\n\n");
} else {
for (service, rows) in grouped {
out.push_str("### ");
out.push_str(&service);
out.push_str("\n\n");
out.push_str("| Version | Channel | Supported |\n");
out.push_str("| ------- | ------- | --------- |\n");
for row in rows {
out.push_str("| ");
out.push_str(&row.version_label);
out.push_str(" | ");
out.push_str(row.channel);
out.push_str(" | :white_check_mark: |\n");
}
out.push('\n');
}
}
out.push_str("## Reporting a Vulnerability\n\n");
out.push_str(
"Report vulnerabilities privately to the maintainers. Include reproduction steps, impact, and affected versions (service + version). You will receive an acknowledgement and triage update as soon as possible.\n",
);
out
}
fn group_entries_by_service(
entries: &[ReleaseDocEntry],
) -> BTreeMap<String, Vec<ReleaseDocEntry>> {
let mut map: BTreeMap<String, Vec<ReleaseDocEntry>> = BTreeMap::new();
for entry in entries {
map.entry(entry.service.clone())
.or_default()
.push(entry.clone());
}
for rows in map.values_mut() {
rows.sort_by(|a, b| {
b.version
.cmp(&a.version)
.then_with(|| b.tag.cmp(&a.tag))
});
let mut seen = std::collections::BTreeSet::new();
rows.retain(|row| seen.insert(row.version_label.clone()));
}
let mut ordered: BTreeMap<String, Vec<ReleaseDocEntry>> = BTreeMap::new();
if let Some(rows) = map.remove("repository") {
ordered.insert("repository".to_string(), rows);
}
for (k, v) in map {
ordered.insert(k, v);
}
ordered
}
#[cfg(test)]
mod classify_tests {
use super::*;
#[test]
fn classifies_repository_v_tag() {
let e = classify_release_tag("v4.0.0", "2026-07-01").expect("entry");
assert_eq!(e.service, "repository");
assert_eq!(e.version_label, "4.0.0");
assert_eq!(e.channel, "stable");
}
#[test]
fn classifies_scoped_package_tag() {
let e = classify_release_tag("athena-js-3.0.4", "2026-07-01").expect("entry");
assert_eq!(e.service, "athena-js");
assert_eq!(e.version_label, "3.0.4");
assert_eq!(e.channel, "stable");
}
#[test]
fn classifies_docs_v_prefix_tag() {
let e = classify_release_tag("docs-v0.1.1-alpha.1", "2026-07-01").expect("entry");
assert_eq!(e.service, "docs");
assert_eq!(e.version_label, "0.1.1-alpha.1");
assert_eq!(e.channel, "experimental");
}
#[test]
fn classifies_inverted_legacy_service_pre() {
let e = classify_release_tag("1.14.0-athena-auth", "2026-07-01").expect("entry");
assert_eq!(e.service, "athena-auth");
assert_eq!(e.version_label, "1.14.0");
assert_eq!(e.channel, "stable");
}
#[test]
fn classifies_inverted_ui_pre() {
let e = classify_release_tag("1.13.2-athena-auth-ui", "2026-07-01").expect("entry");
assert_eq!(e.service, "athena-auth-ui");
assert_eq!(e.version_label, "1.13.2");
}
#[test]
fn security_policy_has_per_service_tables() {
let entries = vec![
classify_release_tag("v4.0.0", "2026-07-02").unwrap(),
classify_release_tag("athena-js-3.0.4", "2026-07-01").unwrap(),
classify_release_tag("1.14.0-athena-auth", "2026-06-01").unwrap(),
];
let security = render_security_policy(&entries);
assert!(security.contains("### repository"));
assert!(security.contains("### athena-auth"));
assert!(security.contains("### athena-js"));
assert!(security.contains("| 4.0.0 | stable | :white_check_mark: |"));
assert!(security.contains("| 3.0.4 | stable | :white_check_mark: |"));
assert!(security.contains("| 1.14.0 | stable | :white_check_mark: |"));
assert!(!security.contains("| 1.14.0-athena-auth |"));
}
}