anodizer-stage-release 0.11.2

Release stage for the anodizer release tool — creates GitHub releases and uploads artifacts
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
//! Backend input/output types and pure decision helpers for the GitHub
//! release run.
//!
//! Hosts the argument-cluster structs ([`BackendEnv`], [`GithubReleaseSpec`],
//! [`UploadOpts`]) consumed by [`super::backend::run_github_backend`] plus the
//! I/O-free classifiers ([`classify_already_exists`],
//! [`check_existing_assets_block_upload`], [`nightly_releases_to_prune`],
//! [`upload_retry_locals`]) so the branching logic is unit-testable without a
//! live octocrab client.

use anodizer_core::context::Context;
use anodizer_core::log::StageLogger;
use octocrab::repos::releases::MakeLatest;

/// Runtime / context infrastructure for [`run_github_backend`].
///
/// Bundles the four "ambient" handles every backend call needs: the
/// shared tokio runtime, the global anodizer [`Context`], the per-stage
/// logger, and the resolved GitHub token. Pulling them into a struct
/// drains four positional arguments off the call site.
pub(crate) struct BackendEnv<'a> {
    pub rt: &'a tokio::runtime::Runtime,
    pub ctx: &'a Context,
    pub log: &'a StageLogger,
    pub token: &'a Option<String>,
}

/// Per-release attributes consumed by [`run_github_backend`].
///
/// Mirrors `GitlabReleaseSpec` / `GiteaReleaseSpec` from the sibling
/// `gitlab.rs` / `gitea.rs` backends. Field names line up with
/// [`crate::release_body::ReleaseJsonSpec`] so the `build_release_json`
/// call site is a near-direct field forward.
#[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>,
}

/// Cluster controlling upload + retention semantics for [`run_github_backend`].
#[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,
    /// `--resume-release`: bypass the leftover-assets pre-check so the
    /// upload loop runs against an existing release left by a prior failed
    /// attempt.
    pub resume_release: bool,
    /// Nightly retention: keep the N newest nightly releases (matched by the
    /// rendered nightly name) and delete the rest AFTER the new release is
    /// created and published, including the git tags anodizer created for them.
    /// `keep_last: 1` is the rolling-single-release case (`keep_single_release`);
    /// `None` disables the sweep. Operates on [`Self::publish_repo_override`]
    /// when set. Resolution of the legacy `keep_single_release` alias vs the
    /// `retention:` block happens upstream in
    /// [`anodizer_core::config::NightlyConfig::resolved_keep_last`], so this
    /// field is the single source of truth for the backend.
    pub retention_keep_last: Option<usize>,
    /// Nightly `publish_repo`: redirect the release create, asset upload, AND
    /// retention delete calls to a DIFFERENT `(owner, repo)` than the source
    /// repo resolved from `release.github`. `None` = source repo, unchanged.
    pub publish_repo_override: Option<(String, String)>,
}

/// Outcome for the upload-asset 422 `already_exists` decision branch.
/// Extracted from the body of [`run_github_backend`] so the logic can be
/// unit-tested without standing up a fake octocrab.
///
/// 422 upload-conflict decision rule:
///
/// ```text
/// if resp.StatusCode == http.StatusUnprocessableEntity {
///     if !ctx.Config.Release.ReplaceExistingArtifacts {
///         return retryx.Unrecoverable(err)
///     }
///     // delete + retry
/// }
/// ```
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum AlreadyExistsAction {
    /// Local + remote bytes match: treat as a no-op (idempotency); a
    /// prior attempt in this same release already uploaded the file.
    SkipIdempotent,
    /// `replace_existing_artifacts: false` and bytes differ: bail with
    /// the conflict instead of overwriting.
    BailReplaceForbidden,
    /// Different bytes and the user opted in via
    /// `replace_existing_artifacts: true`: delete the stale asset and
    /// retry the upload.
    DeleteAndRetry,
}

/// Check whether an existing release's assets block a retry when
/// `replace_existing_artifacts` is false. Returns the list of asset names
/// that would conflict, or `None` when uploads may proceed.
///
/// Pure function so the pre-check logic can be unit-tested without I/O.
/// Returns `None` (uploads proceed) when ANY of:
///   - `skip_upload` is true (nothing will be uploaded),
///   - `resume_release` is true (the user explicitly opted into continuing
///     into a leftover release via `--resume-release`),
///   - `replace_existing_artifacts` is true (overwrites are permitted), or
///   - no assets exist on the release yet.
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())
}

/// Decide what to do when the GitHub upload-asset API returns
/// `422 already_exists`. Pure function so the
/// `replace_existing_artifacts: false` guard can be tested without I/O.
///
/// A partial asset (`remote.uploaded == false` — GitHub registered the
/// name but the upload never completed, e.g. a transient 401/5xx broke
/// the transfer mid-flight) is ALWAYS `DeleteAndRetry`, regardless of
/// `replace_existing_artifacts`: it is this release's own debris, not
/// published content, and it blocks every same-name retry with
/// `already_exists` until deleted. GoReleaser's upload path has the
/// same delete-before-retry recovery.
///
/// For a fully-uploaded asset, a `422 already_exists` means the asset
/// definitively exists, so the shared
/// [`classify_asset_conflict`](crate::classify_asset_conflict) is consulted
/// with `remote_present: true`; an unreadable probe (`None`) is
/// treated as a mismatch, matching the conservative size-compare rule. The
/// byte-identical-skip invariant lives in that shared classifier, not here.
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,
        // A `422 already_exists` guarantees the asset is present, so the shared
        // classifier never returns `NoConflict` here; both remaining variants
        // mean "differs, overwrite forbidden" -> bail rather than mutate
        // published bytes.
        crate::AssetConflict::ConflictForbidden | crate::AssetConflict::NoConflict => {
            AlreadyExistsAction::BailReplaceForbidden
        }
    }
}

/// Decide which nightly releases to prune so that exactly `keep_last` nightly
/// releases survive — run AFTER the new release is created and published.
///
/// `releases` is the full set of releases (`(id, tag)`) whose `name` matches the
/// nightly release name, INCLUDING the just-created `protect_id`. They are sorted
/// newest-first internally by release `id` descending — monotonic with creation
/// order on a single repo — so correctness does not depend on the order GitHub
/// returns them. The newest `keep_last` survive; everything older is pruned.
///
/// `protect_id` is the id of the release just created/published this run. It is
/// NEVER returned for pruning even if it somehow sorts outside the newest
/// `keep_last` window: deleting the release that was just made live would defeat
/// the retention sweep's irreversible-before-reversible ordering. The new release
/// is the highest id (creation is monotonic), so it normally tops the kept set;
/// the filter is the safety net.
///
/// For `keep_last = 1` this returns every release except `protect_id` — the
/// rolling-single-release semantics (only the just-created release survives).
/// This is the single function both the `keep_single_release` alias and
/// `retention.keep_last` route through; there is no parallel single-delete path.
///
/// Pure (no I/O) so the keep/delete arithmetic is unit-testable without octocrab.
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);
    // Sort newest-first by id descending so the keep/prune split is correct
    // regardless of the API response order.
    let mut sorted = releases.to_vec();
    sorted.sort_by_key(|r| std::cmp::Reverse(r.0));
    // The just-created release is counted in the kept set, so the newest
    // `keep_last` survive and everything older is pruned. The just-created
    // release is filtered out of the prune set unconditionally so a surprising
    // id ordering can never delete the release this run just published.
    sorted
        .into_iter()
        .skip(keep_last)
        .filter(|(id, _)| *id != protect_id)
        .collect()
}

/// Resolve the upload retry loop's per-iteration locals from a [`RetryPolicy`].
///
/// Returns `(max_upload_attempts, initial_retry_delay, max_retry_delay)` in
/// the order the upload loop binds them. The single point of translation
/// from policy to locals lives here so a future formula change is visible
/// in one place (and so tests can pin the formula against the backend without
/// re-deriving it inline).
///
/// `max_upload_attempts` mirrors [`RetryPolicy::max_attempts`] directly:
/// the `>= 1` invariant is enforced by [`anodizer_core::config::RetryConfig::to_policy`]
/// (clamps `attempts: 0` -> `1`) and `retry_async` / `retry_sync` (defensive
/// clamp at the loop boundary). No additional clamp is needed at the call
/// site.
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)
}

/// Resolve the proactive upload pace — the minimum interval between successive
/// asset-upload *starts* — applying the env override, then the config value,
/// then the default.
///
/// Precedence (first match wins), mirroring the
/// `ANODIZER_GITHUB_UPLOAD_CONCURRENCY` -> `release.upload_concurrency` chain:
/// 1. `ANODIZER_GITHUB_UPLOAD_PACE_MS` — integer milliseconds. `0` disables
///    pacing (returns `Duration::ZERO`); a non-parsing value is ignored and
///    falls through to the config / default.
/// 2. `release.upload_pace` (a humantime string), via
///    [`anodizer_core::config::ReleaseConfig::resolved_upload_pace`].
/// 3. [`anodizer_core::config::ReleaseConfig::DEFAULT_UPLOAD_PACE`] (200 ms).
///
/// `Duration::ZERO` is the "pacing disabled" sentinel; the caller skips the
/// pace sleep entirely when it is returned. Pure (the env source is injected)
/// so the precedence is unit-testable without mutating the process env.
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() {
        // Even with `replace_existing_artifacts: false`, a byte-identical
        // remote asset is a no-op: the user's guard rail is "don't
        // overwrite different bytes", not "don't probe the API".
        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() {
        // `if !replace_existing_artifacts { return unrecoverable }`.
        // Surfaces the conflict instead of silently overwriting.
        assert_eq!(
            classify_already_exists(false, uploaded(100), 200),
            AlreadyExistsAction::BailReplaceForbidden,
        );
        // Probe `None` (422 says the asset exists but the list could not
        // see it) is treated as a size-mismatch: better to bail than
        // silently overwrite.
        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() {
        // An interrupted upload leaves the asset in a non-"uploaded"
        // state. It is never published content, so it must be deleted
        // and re-uploaded even when overwrites are forbidden — and even
        // when its registered size happens to equal the local size.
        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::*;

    // Argument order across the helper:
    //   (skip_upload, resume_release, replace_existing_artifacts, asset_names)

    #[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() {
        // `--resume-release` is the user's explicit opt-in to continue into
        // an existing release: the pre-check must NOT bail even when assets
        // are present and replace_existing_artifacts is false.
        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() {
        // The CLI override is plumbed via `replace_existing_artifacts: true`
        // in the helper signature (the caller ORs the config value with
        // ctx.options.replace_existing_artifacts before calling).
        // This pins that the helper treats the CLI-derived value the same
        // as the config-derived value.
        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() {
        // The scenario that was previously unrecoverable: partial assets
        // from a prior failed attempt exist, and replace_existing_artifacts
        // is false. The helper must surface them so the caller can bail.
        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() {
        // The helper returns the names in the order the caller supplied
        // them, so the resulting bail message lists assets in a predictable
        // (release-API) order. A future sort/dedupe regression would be
        // user-visible noise; pin the contract.
        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() {
        // skip_upload short-circuits BEFORE the asset-list inspection runs.
        // Pinning this so a future refactor doesn't reorder the early-return
        // and accidentally surface a conflict during a no-op upload pass.
        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 {
    //! Pin the policy-to-locals translation that the bespoke upload retry
    //! loop reads on every iteration. The formula is trivial today but the
    //! rustdoc claims "single point of translation"; if a future change
    //! adds a clamp / fudge factor / multiplier here, these tests force
    //! that change to be conscious (and visible in one place).
    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() {
        // Canonical upload policy: 10 attempts, 50ms base,
        // 30s cap. The locals helper must NOT mutate these on the way to the
        // upload loop — drift here is a user-visible behaviour change in the
        // retry envelope.
        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() {
        // The rustdoc claims the helper relies on RetryConfig::to_policy's
        // upstream clamp and adds none of its own. A `max_attempts: 1`
        // input must therefore round-trip unchanged (proving the helper
        // does not, say, force a minimum of 2 retries).
        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 {
    //! Pin the `Debug`/`PartialEq`/`Eq` derives on `AlreadyExistsAction`.
    //! The classifier returns these variants and downstream call sites in
    //! the upload retry loop `match` on them — a drift to a non-equality
    //! representation would silently break the upload loop's arm matching.
    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() {
        // The error-path log lines format the action via `{:?}` to identify
        // which branch the classifier picked. Pin the variant names so a
        // future rename (`SkipIdempotent` -> `Idempotent`) surfaces in the
        // log diff instead of silently breaking grep-based triage.
        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 {
    //! Pin the field surface of the three "context bundles" passed
    //! into `run_github_backend`. Each is `Clone + Copy` so a struct
    //! can be constructed, copied, and read field-by-field through
    //! the copy — a future field removal/rename breaks compilation
    //! here, not at the distant call site in `run.rs`.
    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; // exercises Copy
        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() {
        // Independent fields -> a drift in field order or a silent removal
        // would let the caller in `run.rs` send `replace_existing_draft`
        // where `skip_upload` was wanted. Pin each one by name.
        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() {
        // The "default-ish" shape (no opt-ins): the upload loop must see
        // every flag as `false` so the production code path runs as the
        // Canonical default. A drift to e.g. `Option<bool>` would break
        // this all-false literal.
        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() {
        // keep_last=1 (the keep_single_release alias): the prune list (which
        // now includes the just-created release id=4) keeps only the new
        // release; every older nightly is pruned.
        let all = vec![
            (4u64, "v1.2.3".to_string()), // the just-created release
            (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() {
        // The just-created release id MUST NOT appear in the prune list,
        // even at keep_last=1: deleting it would leave zero published nightly.
        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() {
        // Defensive: if the just-created release somehow has the LOWEST id
        // (an out-of-order/API surprise), the protect filter still keeps it
        // out of the prune set rather than deleting the live release.
        let all = vec![
            (3u64, "t3".to_string()),
            (2u64, "t2".to_string()),
            (1u64, "new".to_string()), // protected, but lowest id
        ];
        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() {
        // keep_last=2: with the new release (id=4) the newest, retain it plus
        // one older release; prune the rest.
        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() {
        // Fewer releases than keep_last: nothing to prune.
        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())];
        // keep_last=0 floored to 1 -> prune everything except the new release.
        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() {
        // API response order must not matter: feed ids out of order and
        // assert the newest (highest id) survives.
        let all = vec![
            (1u64, "t1".to_string()),
            (4u64, "v1.2.3".to_string()),
            (3u64, "t3".to_string()),
            (2u64, "t2".to_string()),
        ];
        // keep_last=2: keep new (id=4) + id=3; prune 2 and 1 newest-first.
        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() {
        // "0s" must resolve to the ZERO sentinel so the caller skips 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));
    }
}