use anodizer_core::context::Context;
use anodizer_core::log::StageLogger;
use octocrab::repos::releases::MakeLatest;
pub(crate) struct BackendEnv<'a> {
pub rt: &'a tokio::runtime::Runtime,
pub ctx: &'a Context,
pub log: &'a StageLogger,
pub token: &'a Option<String>,
}
#[derive(Clone, Copy)]
pub(crate) struct GithubReleaseSpec<'a> {
pub tag: &'a str,
pub name: &'a str,
pub body: &'a str,
pub mode: &'a str,
pub draft: bool,
pub prerelease: bool,
pub make_latest: &'a Option<MakeLatest>,
pub target_commitish: &'a Option<String>,
pub discussion_category: &'a Option<String>,
}
#[derive(Clone)]
pub(crate) struct UploadOpts {
pub skip_upload: bool,
pub replace_existing_draft: bool,
pub replace_existing_artifacts: bool,
pub use_existing_draft: bool,
pub resume_release: bool,
pub retention_keep_last: Option<usize>,
pub publish_repo_override: Option<(String, String)>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum AlreadyExistsAction {
SkipIdempotent,
BailReplaceForbidden,
DeleteAndRetry,
}
pub(crate) fn check_existing_assets_block_upload(
skip_upload: bool,
resume_release: bool,
replace_existing_artifacts: bool,
existing_asset_names: &[&str],
) -> Option<Vec<String>> {
if skip_upload
|| resume_release
|| replace_existing_artifacts
|| existing_asset_names.is_empty()
{
return None;
}
Some(existing_asset_names.iter().map(|s| s.to_string()).collect())
}
pub(crate) fn classify_already_exists(
replace_existing_artifacts: bool,
remote: Option<super::assets::RemoteAssetProbe>,
local_size: u64,
) -> AlreadyExistsAction {
if remote.is_some_and(|p| !p.uploaded) {
return AlreadyExistsAction::DeleteAndRetry;
}
let remote_size = remote.map(|p| p.size);
match crate::classify_asset_conflict(replace_existing_artifacts, true, remote_size, local_size)
{
crate::AssetConflict::IdenticalSkip => AlreadyExistsAction::SkipIdempotent,
crate::AssetConflict::ReplaceDiffering => AlreadyExistsAction::DeleteAndRetry,
crate::AssetConflict::ConflictForbidden | crate::AssetConflict::NoConflict => {
AlreadyExistsAction::BailReplaceForbidden
}
}
}
pub(crate) fn nightly_releases_to_prune(
releases: &[(u64, String)],
keep_last: usize,
protect_id: u64,
) -> Vec<(u64, String)> {
let keep_last = keep_last.max(1);
let mut sorted = releases.to_vec();
sorted.sort_by_key(|r| std::cmp::Reverse(r.0));
sorted
.into_iter()
.skip(keep_last)
.filter(|(id, _)| *id != protect_id)
.collect()
}
pub(crate) fn upload_retry_locals(
policy: &anodizer_core::retry::RetryPolicy,
) -> (u32, std::time::Duration, std::time::Duration) {
(policy.max_attempts, policy.base_delay, policy.max_delay)
}
pub(crate) fn resolve_upload_pace<E: anodizer_core::EnvSource + ?Sized>(
release_cfg: &anodizer_core::config::ReleaseConfig,
env: &E,
) -> std::time::Duration {
if let Some(raw) = env.var("ANODIZER_GITHUB_UPLOAD_PACE_MS")
&& let Ok(ms) = raw.trim().parse::<u64>()
{
return std::time::Duration::from_millis(ms);
}
release_cfg.resolved_upload_pace()
}
#[cfg(test)]
mod already_exists_tests {
use super::super::assets::RemoteAssetProbe;
use super::*;
fn uploaded(size: u64) -> Option<RemoteAssetProbe> {
Some(RemoteAssetProbe {
size,
uploaded: true,
})
}
fn partial(size: u64) -> Option<RemoteAssetProbe> {
Some(RemoteAssetProbe {
size,
uploaded: false,
})
}
#[test]
fn idempotent_when_remote_matches_local_regardless_of_flag() {
assert_eq!(
classify_already_exists(false, uploaded(100), 100),
AlreadyExistsAction::SkipIdempotent,
);
assert_eq!(
classify_already_exists(true, uploaded(100), 100),
AlreadyExistsAction::SkipIdempotent,
);
}
#[test]
fn bails_when_replace_forbidden_and_sizes_differ() {
assert_eq!(
classify_already_exists(false, uploaded(100), 200),
AlreadyExistsAction::BailReplaceForbidden,
);
assert_eq!(
classify_already_exists(false, None, 200),
AlreadyExistsAction::BailReplaceForbidden,
);
}
#[test]
fn deletes_and_retries_when_replace_allowed_and_sizes_differ() {
assert_eq!(
classify_already_exists(true, uploaded(100), 200),
AlreadyExistsAction::DeleteAndRetry,
);
assert_eq!(
classify_already_exists(true, None, 200),
AlreadyExistsAction::DeleteAndRetry,
);
}
#[test]
fn partial_asset_deletes_and_retries_regardless_of_replace_flag() {
assert_eq!(
classify_already_exists(false, partial(100), 200),
AlreadyExistsAction::DeleteAndRetry,
);
assert_eq!(
classify_already_exists(true, partial(100), 200),
AlreadyExistsAction::DeleteAndRetry,
);
assert_eq!(
classify_already_exists(false, partial(200), 200),
AlreadyExistsAction::DeleteAndRetry,
"size-equal partial must NOT be treated as idempotent",
);
}
}
#[cfg(test)]
mod existing_assets_precheck_tests {
use super::*;
#[test]
fn no_conflict_when_release_has_no_assets() {
let result = check_existing_assets_block_upload(false, false, false, &[]);
assert!(result.is_none(), "empty asset list must not block");
}
#[test]
fn no_conflict_when_replace_existing_is_true() {
let result = check_existing_assets_block_upload(false, false, true, &["foo.tar.gz"]);
assert!(
result.is_none(),
"replace_existing_artifacts=true permits overwrite"
);
}
#[test]
fn no_conflict_when_skip_upload_is_true() {
let result = check_existing_assets_block_upload(true, false, false, &["foo.tar.gz"]);
assert!(result.is_none(), "skip_upload=true means nothing to upload");
}
#[test]
fn no_conflict_when_resume_release_is_true() {
let result =
check_existing_assets_block_upload(false, true, false, &["foo.tar.gz", "bar.zip"]);
assert!(
result.is_none(),
"--resume-release must bypass the pre-check"
);
}
#[test]
fn no_conflict_when_replace_existing_cli_override_is_true() {
let result =
check_existing_assets_block_upload(false, false, true, &["foo.tar.gz", "bar.zip"]);
assert!(
result.is_none(),
"--replace-existing must bypass the pre-check via replace_existing_artifacts=true"
);
}
#[test]
fn conflicts_when_assets_present_and_replace_forbidden() {
let assets = &["app_linux_amd64.tar.gz", "checksums.txt"];
let result = check_existing_assets_block_upload(false, false, false, assets);
let names = result.expect("should detect conflict");
assert_eq!(names.len(), 2);
assert!(names.contains(&"app_linux_amd64.tar.gz".to_string()));
assert!(names.contains(&"checksums.txt".to_string()));
}
#[test]
fn conflict_list_preserves_input_order() {
let assets = &["a.tar.gz", "b.zip", "c.sig"];
let names = check_existing_assets_block_upload(false, false, false, assets)
.expect("conflict present");
assert_eq!(
names,
vec![
"a.tar.gz".to_string(),
"b.zip".to_string(),
"c.sig".to_string()
]
);
}
#[test]
fn skip_upload_wins_even_with_assets_and_no_replace() {
let result = check_existing_assets_block_upload(true, false, false, &["x.tar.gz"]);
assert!(
result.is_none(),
"skip_upload short-circuits unconditionally"
);
}
}
#[cfg(test)]
mod upload_retry_locals_tests {
use super::*;
use anodizer_core::retry::RetryPolicy;
use std::time::Duration;
#[test]
fn returns_policy_fields_verbatim() {
let policy = RetryPolicy {
max_attempts: 7,
base_delay: Duration::from_millis(50),
max_delay: Duration::from_secs(30),
};
let (attempts, base, max) = upload_retry_locals(&policy);
assert_eq!(
attempts, 7,
"max_attempts mirrors RetryPolicy::max_attempts"
);
assert_eq!(base, Duration::from_millis(50));
assert_eq!(max, Duration::from_secs(30));
}
#[test]
fn surfaces_the_upload_canonical_policy_unchanged() {
let (attempts, base, max) = upload_retry_locals(&RetryPolicy::UPLOAD);
assert_eq!(attempts, 10);
assert_eq!(base, Duration::from_millis(50));
assert_eq!(max, Duration::from_secs(30));
}
#[test]
fn preserves_one_attempt_minimum_without_extra_clamp() {
let policy = RetryPolicy {
max_attempts: 1,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let (attempts, _, _) = upload_retry_locals(&policy);
assert_eq!(
attempts, 1,
"single-attempt policy must round-trip verbatim"
);
}
}
#[cfg(test)]
mod already_exists_action_derive_tests {
use super::*;
#[test]
fn variants_compare_equal_only_to_themselves() {
assert_eq!(
AlreadyExistsAction::SkipIdempotent,
AlreadyExistsAction::SkipIdempotent
);
assert_ne!(
AlreadyExistsAction::SkipIdempotent,
AlreadyExistsAction::BailReplaceForbidden
);
assert_ne!(
AlreadyExistsAction::BailReplaceForbidden,
AlreadyExistsAction::DeleteAndRetry
);
assert_ne!(
AlreadyExistsAction::DeleteAndRetry,
AlreadyExistsAction::SkipIdempotent
);
}
#[test]
fn debug_format_names_the_variant() {
assert_eq!(
format!("{:?}", AlreadyExistsAction::SkipIdempotent),
"SkipIdempotent"
);
assert_eq!(
format!("{:?}", AlreadyExistsAction::BailReplaceForbidden),
"BailReplaceForbidden"
);
assert_eq!(
format!("{:?}", AlreadyExistsAction::DeleteAndRetry),
"DeleteAndRetry"
);
}
}
#[cfg(test)]
mod spec_struct_surface_tests {
use super::*;
use octocrab::repos::releases::MakeLatest;
#[test]
fn github_release_spec_round_trips_all_fields() {
let make_latest = Some(MakeLatest::True);
let target = Some("main".to_string());
let category = Some("Announcements".to_string());
let spec = GithubReleaseSpec {
tag: "v1.2.3",
name: "Release 1.2.3",
body: "## Changes",
mode: "replace",
draft: true,
prerelease: false,
make_latest: &make_latest,
target_commitish: &target,
discussion_category: &category,
};
let copy = spec; assert_eq!(copy.tag, "v1.2.3");
assert_eq!(copy.name, "Release 1.2.3");
assert_eq!(copy.body, "## Changes");
assert_eq!(copy.mode, "replace");
assert!(copy.draft);
assert!(!copy.prerelease);
assert!(copy.make_latest.is_some());
assert_eq!(copy.target_commitish.as_deref(), Some("main"));
assert_eq!(copy.discussion_category.as_deref(), Some("Announcements"));
}
#[test]
fn upload_opts_round_trips_every_field() {
let opts = UploadOpts {
skip_upload: true,
replace_existing_draft: false,
replace_existing_artifacts: true,
use_existing_draft: false,
resume_release: true,
retention_keep_last: Some(10),
publish_repo_override: Some(("nushell".to_string(), "nightly".to_string())),
};
let copy = opts.clone();
assert!(copy.skip_upload);
assert!(!copy.replace_existing_draft);
assert!(copy.replace_existing_artifacts);
assert!(!copy.use_existing_draft);
assert!(copy.resume_release);
assert_eq!(copy.retention_keep_last, Some(10));
assert_eq!(
copy.publish_repo_override,
Some(("nushell".to_string(), "nightly".to_string()))
);
}
#[test]
fn upload_opts_all_false_is_constructible() {
let opts = UploadOpts {
skip_upload: false,
replace_existing_draft: false,
replace_existing_artifacts: false,
use_existing_draft: false,
resume_release: false,
retention_keep_last: None,
publish_repo_override: None,
};
assert!(!opts.skip_upload);
assert!(!opts.replace_existing_draft);
assert!(!opts.replace_existing_artifacts);
assert!(!opts.use_existing_draft);
assert!(!opts.resume_release);
assert_eq!(opts.retention_keep_last, None);
assert_eq!(opts.publish_repo_override, None);
}
#[test]
fn nightly_releases_to_prune_keep_last_one_prunes_all_but_new() {
let all = vec![
(4u64, "v1.2.3".to_string()), (3u64, "0.1.0-nightly.2".to_string()),
(2u64, "0.1.0-nightly.1".to_string()),
(1u64, "0.1.0-nightly.0".to_string()),
];
let pruned = nightly_releases_to_prune(&all, 1, 4);
assert_eq!(
pruned,
vec![
(3u64, "0.1.0-nightly.2".to_string()),
(2u64, "0.1.0-nightly.1".to_string()),
(1u64, "0.1.0-nightly.0".to_string()),
]
);
}
#[test]
fn nightly_releases_to_prune_never_prunes_the_new_release() {
let all = vec![
(4u64, "v1.2.3".to_string()),
(3u64, "t3".to_string()),
(2u64, "t2".to_string()),
(1u64, "t1".to_string()),
];
for keep in [1usize, 2, 3, 4, 10] {
let pruned = nightly_releases_to_prune(&all, keep, 4);
assert!(
!pruned.iter().any(|(id, _)| *id == 4),
"protect_id=4 must never be pruned (keep_last={keep}); got {pruned:?}",
);
}
}
#[test]
fn nightly_releases_to_prune_protects_new_even_if_lowest_id() {
let all = vec![
(3u64, "t3".to_string()),
(2u64, "t2".to_string()),
(1u64, "new".to_string()), ];
let pruned = nightly_releases_to_prune(&all, 1, 1);
assert!(
!pruned.iter().any(|(id, _)| *id == 1),
"the protected (just-created) release must not be pruned: {pruned:?}",
);
}
#[test]
fn nightly_releases_to_prune_keep_last_n_keeps_newest() {
let all = vec![
(4u64, "v1.2.3".to_string()),
(3u64, "t3".to_string()),
(2u64, "t2".to_string()),
(1u64, "t1".to_string()),
];
let pruned = nightly_releases_to_prune(&all, 2, 4);
assert_eq!(
pruned,
vec![(2u64, "t2".to_string()), (1u64, "t1".to_string())]
);
}
#[test]
fn nightly_releases_to_prune_keeps_all_when_under_budget() {
let all = vec![(2u64, "v1.2.3".to_string()), (1u64, "t1".to_string())];
assert!(nightly_releases_to_prune(&all, 10, 2).is_empty());
}
#[test]
fn nightly_releases_to_prune_floors_zero_to_one() {
let all = vec![(2u64, "v1.2.3".to_string()), (1u64, "t1".to_string())];
assert_eq!(
nightly_releases_to_prune(&all, 0, 2),
vec![(1u64, "t1".to_string())]
);
}
#[test]
fn nightly_releases_to_prune_sorts_out_of_order_input() {
let all = vec![
(1u64, "t1".to_string()),
(4u64, "v1.2.3".to_string()),
(3u64, "t3".to_string()),
(2u64, "t2".to_string()),
];
let pruned = nightly_releases_to_prune(&all, 2, 4);
assert_eq!(
pruned,
vec![(2u64, "t2".to_string()), (1u64, "t1".to_string())],
"must keep the highest-id releases regardless of input order",
);
}
}
#[cfg(test)]
mod upload_pace_tests {
use super::resolve_upload_pace;
use anodizer_core::MapEnvSource;
use anodizer_core::config::ReleaseConfig;
use std::time::Duration;
fn cfg_with_pace(s: &str) -> ReleaseConfig {
serde_yaml_ng::from_str(&format!("upload_pace: \"{s}\"")).expect("parse release cfg")
}
#[test]
fn defaults_to_200ms_when_unset() {
let cfg = ReleaseConfig::default();
let env = MapEnvSource::new();
assert_eq!(
resolve_upload_pace(&cfg, &env),
ReleaseConfig::DEFAULT_UPLOAD_PACE,
);
assert_eq!(resolve_upload_pace(&cfg, &env), Duration::from_millis(200));
}
#[test]
fn config_value_overrides_default() {
let cfg = cfg_with_pace("1s");
let env = MapEnvSource::new();
assert_eq!(resolve_upload_pace(&cfg, &env), Duration::from_secs(1));
}
#[test]
fn config_zero_disables_pacing() {
let cfg = cfg_with_pace("0s");
let env = MapEnvSource::new();
assert_eq!(resolve_upload_pace(&cfg, &env), Duration::ZERO);
}
#[test]
fn env_override_takes_precedence_over_config() {
let cfg = cfg_with_pace("1s");
let env = MapEnvSource::new().with("ANODIZER_GITHUB_UPLOAD_PACE_MS", "50");
assert_eq!(resolve_upload_pace(&cfg, &env), Duration::from_millis(50));
}
#[test]
fn env_zero_disables_pacing_even_with_config_set() {
let cfg = cfg_with_pace("1s");
let env = MapEnvSource::new().with("ANODIZER_GITHUB_UPLOAD_PACE_MS", "0");
assert_eq!(resolve_upload_pace(&cfg, &env), Duration::ZERO);
}
#[test]
fn garbage_env_falls_through_to_config() {
let cfg = cfg_with_pace("1s");
let env = MapEnvSource::new().with("ANODIZER_GITHUB_UPLOAD_PACE_MS", "not-a-number");
assert_eq!(resolve_upload_pace(&cfg, &env), Duration::from_secs(1));
}
}