use anodizer_core::config::{ContentSource, ExtraFileSpec, MakeLatestConfig};
use anodizer_core::context::Context;
use anyhow::{Context as _, Result};
pub(crate) fn resolve_header_footer<'a>(
release_value: Option<&'a str>,
changelog_value: Option<&'a str>,
) -> Option<&'a str> {
release_value.or(changelog_value)
}
pub(crate) fn build_release_body(
changelog_body: &str,
header: Option<&str>,
footer: Option<&str>,
) -> String {
let mut parts: Vec<&str> = Vec::new();
if let Some(h) = header
&& !h.is_empty()
{
parts.push(h);
}
if !changelog_body.is_empty() {
parts.push(changelog_body);
}
if let Some(f) = footer
&& !f.is_empty()
{
parts.push(f);
}
if parts.is_empty() {
String::new()
} else {
let mut s = parts.join("\n\n");
s.push('\n');
s
}
}
pub(crate) fn render_nondeterministic_exemptions_block(entries: &[(String, String)]) -> String {
if entries.is_empty() {
return String::new();
}
let mut out = String::from("Non-deterministic exemptions:\n");
for (name, reason) in entries {
out.push_str(&format!(" {} - {}\n", name, reason));
}
out
}
pub(crate) fn collect_extra_files(
specs: &[ExtraFileSpec],
ctx: &Context,
) -> anyhow::Result<Vec<(std::path::PathBuf, Option<String>)>> {
let mut results = Vec::new();
for spec in specs {
match spec {
ExtraFileSpec::Glob(pattern) => {
let entries = glob::glob(pattern).with_context(|| {
format!("release: invalid extra_files glob pattern '{}'", pattern)
})?;
let before = results.len();
for entry in entries {
let entry = entry.with_context(|| {
format!(
"release: extra_files glob '{}': IO error iterating matches",
pattern
)
})?;
if entry.is_file() {
results.push((entry, None));
}
}
if results.len() == before {
extra_files_zero_match(pattern, ctx)?;
}
}
ExtraFileSpec::Detailed {
glob: pattern,
name_template,
allow_empty,
} => {
let entries = glob::glob(pattern).with_context(|| {
format!("release: invalid extra_files glob pattern '{}'", pattern)
})?;
let before = results.len();
for entry in entries {
let entry = entry.with_context(|| {
format!(
"release: extra_files glob '{}': IO error iterating matches",
pattern
)
})?;
if entry.is_file() {
let name = match name_template.as_ref() {
Some(tmpl) => {
let filename =
entry.file_name().unwrap_or_default().to_string_lossy();
let mut vars = ctx.template_vars().clone();
vars.set("ArtifactName", &filename);
vars.set(
"ArtifactExt",
anodizer_core::template::extract_artifact_ext(&filename),
);
Some(
anodizer_core::template::render(tmpl, &vars).with_context(
|| {
format!(
"release: render extra_files name_template '{}' for '{}'",
tmpl,
entry.display()
)
},
)?,
)
}
None => None,
};
results.push((entry, name));
}
}
if results.len() == before && !*allow_empty {
extra_files_zero_match(pattern, ctx)?;
}
}
}
}
Ok(results)
}
fn extra_files_zero_match(pattern: &str, ctx: &Context) -> Result<()> {
if ctx.is_dry_run() {
ctx.logger("release").warn(&format!(
"extra_files glob '{pattern}' matched no files \
(dry-run: hooks were not executed; a live release fails here)"
));
return Ok(());
}
anyhow::bail!("release: extra_files glob '{pattern}' matched no files")
}
pub(crate) fn resolve_make_latest<F>(
config: &Option<MakeLatestConfig>,
render: F,
) -> Result<Option<octocrab::repos::releases::MakeLatest>>
where
F: Fn(&str) -> anyhow::Result<String>,
{
use octocrab::repos::releases::MakeLatest;
Ok(match config {
Some(MakeLatestConfig::Bool(true)) => Some(MakeLatest::True),
Some(MakeLatestConfig::Bool(false)) => Some(MakeLatest::False),
Some(MakeLatestConfig::Auto) => Some(MakeLatest::Legacy),
Some(MakeLatestConfig::String(tmpl)) => {
let rendered = render(tmpl)
.with_context(|| format!("release: render make_latest template '{tmpl}'"))?;
match rendered.trim() {
"true" | "1" => Some(MakeLatest::True),
"false" | "0" | "" => Some(MakeLatest::False),
"auto" => Some(MakeLatest::Legacy),
_ => Some(MakeLatest::True), }
}
None => None,
})
}
pub(crate) fn resolve_content_source(
source: &ContentSource,
ctx: &anodizer_core::context::Context,
) -> Result<String> {
anodizer_core::content_source::resolve(source, "release header/footer", ctx)
}
pub(crate) fn compose_body_for_mode(
mode: &str,
existing_body: Option<&str>,
new_body: &str,
) -> String {
match mode {
"keep-existing" => {
if let Some(existing) = existing_body
&& !existing.is_empty()
{
return existing.to_string();
}
new_body.to_string()
}
"append" => {
if let Some(existing) = existing_body
&& !existing.is_empty()
{
return format!("{}\n\n{}", existing, new_body);
}
new_body.to_string()
}
"prepend" => {
if let Some(existing) = existing_body
&& !existing.is_empty()
{
return format!("{}\n\n{}", new_body, existing);
}
new_body.to_string()
}
_ => new_body.to_string(),
}
}
pub(crate) const GITHUB_RELEASE_BODY_MAX_CHARS: usize = 125_000;
#[derive(Clone, Copy)]
pub(crate) struct ReleaseJsonSpec<'a> {
pub tag: &'a str,
pub name: &'a str,
pub body: &'a str,
pub draft: bool,
pub prerelease_flag: bool,
pub make_latest: &'a Option<octocrab::repos::releases::MakeLatest>,
pub target_commitish: &'a Option<String>,
pub discussion_category: &'a Option<String>,
}
pub(crate) fn build_release_json(spec: &ReleaseJsonSpec<'_>) -> serde_json::Value {
let ReleaseJsonSpec {
tag,
name,
body,
draft,
prerelease_flag,
make_latest,
target_commitish,
discussion_category,
} = *spec;
let mut json = serde_json::json!({
"tag_name": tag,
"name": name,
"draft": draft,
"prerelease": prerelease_flag,
});
if !body.is_empty() {
let truncated_body = if body.len() > GITHUB_RELEASE_BODY_MAX_CHARS {
let suffix = "...";
let max_content = GITHUB_RELEASE_BODY_MAX_CHARS - suffix.len();
let safe_end = body
.char_indices()
.map(|(i, c)| i + c.len_utf8())
.take_while(|&end| end <= max_content)
.last()
.unwrap_or(0);
format!("{}{}", &body[..safe_end], suffix)
} else {
body.to_string()
};
json["body"] = serde_json::Value::String(truncated_body);
}
if let Some(ml) = make_latest {
json["make_latest"] = serde_json::Value::String(ml.to_string());
}
if let Some(tc) = target_commitish {
json["target_commitish"] = serde_json::json!(tc);
}
if let Some(dc) = discussion_category {
json["discussion_category_name"] = serde_json::json!(dc);
}
json
}
pub(crate) fn build_publish_patch_body(
release_name: &str,
prerelease: bool,
make_latest: &Option<octocrab::repos::releases::MakeLatest>,
discussion_category: &Option<String>,
) -> serde_json::Value {
let mut body = serde_json::json!({ "draft": false });
if !release_name.is_empty() {
body["name"] = serde_json::Value::String(release_name.to_string());
}
if prerelease {
body["prerelease"] = serde_json::Value::Bool(true);
body["make_latest"] = serde_json::Value::String("false".to_string());
} else if let Some(ml) = make_latest {
body["make_latest"] = serde_json::Value::String(ml.to_string());
}
if let Some(dc) = discussion_category {
body["discussion_category_name"] = serde_json::json!(dc);
}
body
}
pub(crate) fn resolve_release_tag(
ctx: &Context,
tag_template: &str,
release_tag_override: Option<&str>,
crate_name: &str,
) -> Result<String> {
let (rendered, source) = if let Some(override_tmpl) = release_tag_override {
let rendered = ctx.render_template(override_tmpl).with_context(|| {
format!(
"release: render release.tag override for crate '{}'",
crate_name
)
})?;
(rendered, "release.tag")
} else {
let rendered = ctx
.render_template(tag_template)
.with_context(|| format!("release: render tag_template for crate '{}'", crate_name))?;
(rendered, "tag_template")
};
if rendered.is_empty() {
anyhow::bail!(
"release: {} for crate '{}' rendered to an empty tag. The GitHub / \
GitLab / Gitea Releases REST API requires a non-empty `tag_name`; \
posting an empty value returns a confusing 422 (`tag_name is too \
short`) that hides the real cause. Verify the template references \
a variable that is populated on this run (e.g. `{{{{ Tag }}}}` is \
unset during `--snapshot` without a `tag_template` fallback) or \
set an explicit `release.tag:` override.",
source,
crate_name
);
}
Ok(rendered)
}