Skip to main content

anodizer_cli/
lib.rs

1use clap::{Parser, Subcommand};
2use clap_complete::Shell;
3use std::path::PathBuf;
4
5#[derive(Parser)]
6#[command(name = "anodizer", version, about = "Release Rust projects with ease")]
7pub struct Cli {
8    #[arg(
9        long,
10        short = 'f',
11        global = true,
12        help = "Path to config file (overrides auto-detection)"
13    )]
14    pub config: Option<PathBuf>,
15    #[arg(long, global = true, help = "Enable verbose output")]
16    pub verbose: bool,
17    #[arg(long, global = true, help = "Enable debug output")]
18    pub debug: bool,
19    #[arg(long, short = 'q', global = true, help = "Suppress non-error output")]
20    pub quiet: bool,
21    #[arg(
22        long,
23        global = true,
24        help = "Strict mode: configured features that silently skip become hard errors"
25    )]
26    pub strict: bool,
27    // Optional so `anodizer` with no args prints help and exits 0. A required
28    // subcommand (non-Option) makes clap emit a "usage" error and exit with
29    // code 2, which package-manager validators (winget's, chocolatey's) treat
30    // as install failure since they smoke-test the installed binary with no
31    // args.
32    #[command(subcommand)]
33    pub command: Option<Commands>,
34}
35
36#[derive(Subcommand)]
37// The `Release` variant carries one field per CLI flag (~40 fields) so its
38// size dwarfs the other subcommands. Boxing every flag bag would just hide
39// the same fields behind an extra allocation per parse with no callsite
40// win; the enum is allocated once per invocation. Local allow only.
41#[allow(clippy::large_enum_variant)]
42pub enum Commands {
43    /// Run the full release pipeline
44    Release {
45        #[arg(long = "crate", visible_alias = "id", action = clap::ArgAction::Append, help = "Release a specific crate (repeatable; --id is accepted as a GoReleaser-compat alias)")]
46        crate_names: Vec<String>,
47        #[arg(long, help = "Release all crates with unreleased changes")]
48        all: bool,
49        #[arg(long, help = "Force release even without unreleased changes")]
50        force: bool,
51        #[arg(long, help = "Build without publishing (snapshot mode)")]
52        snapshot: bool,
53        #[arg(long, help = "Create a nightly release with date-based version")]
54        nightly: bool,
55        #[arg(long, help = "Run full pipeline without side effects")]
56        dry_run: bool,
57        #[arg(long, help = "Remove dist directory before starting")]
58        clean: bool,
59        #[arg(
60            long,
61            value_delimiter = ',',
62            help = "Skip stages (comma-separated, e.g. docker,announce)"
63        )]
64        skip: Vec<String>,
65        #[arg(
66            long,
67            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
68        )]
69        token: Option<String>,
70        #[arg(
71            long,
72            default_value = "60m",
73            help = "Pipeline timeout duration (e.g., 60m, 1h, 5s)"
74        )]
75        timeout: String,
76        #[arg(
77            long,
78            short = 'p',
79            help = "Maximum number of parallel build jobs (default: number of CPUs)"
80        )]
81        parallelism: Option<usize>,
82        #[arg(long, help = "Automatically set --snapshot if the git repo is dirty")]
83        auto_snapshot: bool,
84        #[arg(long, help = "Build only for the host target triple")]
85        single_target: bool,
86        #[arg(
87            long,
88            value_name = "csv",
89            conflicts_with = "single_target",
90            help = "Restrict the build to a comma-separated subset of configured target triples (e.g. x86_64-apple-darwin,aarch64-apple-darwin). Used by the Determinism Harness's sharded job matrix; conflicts with --single-target."
91        )]
92        targets: Option<String>,
93        #[arg(
94            long = "host-targets",
95            conflicts_with = "single_target",
96            conflicts_with = "targets",
97            help = "Build every configured target this host can build, skipping cross-compile-only targets (apple targets on a non-macOS host). Only valid with --snapshot or --dry-run. Used by `task prepush` to do a real host-scoped build without aborting on un-buildable targets."
98        )]
99        host_targets: bool,
100        #[arg(
101            long,
102            help = "Path to a custom release notes file (overrides changelog)"
103        )]
104        release_notes: Option<PathBuf>,
105        #[arg(
106            long,
107            conflicts_with = "crate_names",
108            help = "Release a specific workspace in a monorepo config"
109        )]
110        workspace: Option<String>,
111        #[arg(
112            long,
113            conflicts_with = "no_preflight",
114            help = "Run pre-flight publisher-state check and exit (don't start the pipeline)"
115        )]
116        preflight: bool,
117        #[arg(
118            long,
119            conflicts_with = "preflight",
120            help = "Skip the automatic pre-flight publisher-state check"
121        )]
122        no_preflight: bool,
123        #[arg(
124            long,
125            help = "Alias for --strict (also treats Unknown publisher state as a blocker during pre-flight)"
126        )]
127        strict_preflight: bool,
128        #[arg(long, help = "Set the release as a draft")]
129        draft: bool,
130        #[arg(long, help = "Path to a file containing custom release header text")]
131        release_header: Option<PathBuf>,
132        #[arg(
133            long,
134            help = "Path to a template file for release header (rendered with template variables)"
135        )]
136        release_header_tmpl: Option<PathBuf>,
137        #[arg(long, help = "Path to a file containing custom release footer text")]
138        release_footer: Option<PathBuf>,
139        #[arg(
140            long,
141            help = "Path to a template file for release footer (rendered with template variables)"
142        )]
143        release_footer_tmpl: Option<PathBuf>,
144        #[arg(
145            long,
146            help = "Path to a template file for release notes (rendered with template variables, overrides --release-notes)"
147        )]
148        release_notes_tmpl: Option<PathBuf>,
149        #[arg(long, help = "Abort immediately on first error during publishing")]
150        fail_fast: bool,
151        #[arg(
152            long = "no-gate-submitter",
153            help = "Disable the Submitter gate: dispatch Submitter publishers even when required Assets/Manager publishers failed"
154        )]
155        no_gate_submitter: bool,
156        #[arg(
157            long = "rollback",
158            value_name = "none|best-effort",
159            help = "Rollback policy after publish stage. Defaults to best-effort when preflight is clean, none otherwise."
160        )]
161        rollback: Option<String>,
162        #[arg(
163            long = "simulate-failure",
164            value_name = "publisher",
165            action = clap::ArgAction::Append,
166            hide = true,
167            help = "(TEST HARNESS) Force a named publisher to fail. Gated by ANODIZE_TEST_HARNESS=1."
168        )]
169        simulate_failure: Vec<String>,
170        #[arg(
171            long = "rollback-only",
172            requires = "from_run",
173            conflicts_with = "clean",
174            help = "Skip publish; re-attempt rollback from a prior run report. Requires --from-run=<id>."
175        )]
176        rollback_only: bool,
177        #[arg(
178            long = "from-run",
179            value_name = "id",
180            requires = "rollback_only",
181            value_parser = parse_run_id,
182            help = "Prior run id whose state to load when running --rollback-only. \
183                    Loads <dist>/run-<id>/rollback.json if present (a prior replay's state), \
184                    otherwise <dist>/run-<id>/report.json. Delete rollback.json to force a \
185                    full re-roll. Must match the run_id format written by the release pipeline \
186                    (alphanumeric, dot, dash, underscore; no path separators)."
187        )]
188        from_run: Option<String>,
189        #[arg(
190            long = "allow-rerun",
191            conflicts_with = "rollback_only",
192            help = "DANGEROUS: force publish to proceed even when a prior \
193                    dist/run-<id>/report.json exists for this tag. PR-based publishers \
194                    (homebrew, scoop, nix, krew, MCP) will open DUPLICATE pull requests. \
195                    Recover from partial failures with --rollback-only --from-run=<id> first. \
196                    Cannot be combined with --rollback-only (which has its own idempotency)."
197        )]
198        allow_rerun: bool,
199        #[arg(
200            long = "show-skipped",
201            help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
202                    (normally only visible with --debug). Use to diagnose why a publisher didn't \
203                    run for a given crate."
204        )]
205        show_skipped: bool,
206        #[arg(
207            long = "allow-nondeterministic",
208            value_name = "name=reason",
209            action = clap::ArgAction::Append,
210            help = "Runtime non-determinism opt-out for a specific artifact (repeatable). Mutually exclusive with --strict."
211        )]
212        allow_nondeterministic: Vec<String>,
213        #[arg(
214            long = "summary-json",
215            value_name = "path",
216            help = "Write the per-publisher run summary JSON to this path. Without it, real (non-snapshot, non-dry-run) releases write <dist>/run-<id>/summary.json — even when a stage fails — so recovery tooling always has machine-readable publish state."
217        )]
218        summary_json: Option<PathBuf>,
219        #[arg(
220            long = "allow-ai-failure",
221            help = "If `changelog.ai` is configured and the AI provider fails, log a warning and keep the pre-AI release notes instead of aborting the release."
222        )]
223        allow_ai_failure: bool,
224        #[arg(
225            long = "allow-snapshot-publish",
226            help = "DANGEROUS: allow publishing a non-release version (snapshot / dirty / 0.0.0-sentinel, e.g. 0.0.0~SNAPSHOT-<sha>) to external publishers. By default the publish, blob, and announce stages refuse such versions — several indexes (crates.io, Cloudsmith, Chocolatey, winget, AUR) are one-way doors. Use ONLY for a private/test channel."
227        )]
228        allow_snapshot_publish: bool,
229        #[arg(
230            long,
231            conflicts_with = "merge",
232            help = "Run only the build stage for split CI fan-out (outputs artifacts JSON to dist/)"
233        )]
234        split: bool,
235        #[arg(
236            long,
237            conflicts_with = "split",
238            help = "Merge artifacts from split build jobs and resume the pipeline from post-build stages"
239        )]
240        merge: bool,
241        #[arg(
242            long = "publish-only",
243            conflicts_with_all = ["split", "merge", "prepare", "announce_only", "snapshot", "rollback_only", "clean"],
244            help = "Load artifacts from dist/ (preserved by `anodize check determinism --preserve-dist`) and run only the sign + publish pipeline. Skips build/archive/nfpm/sbom/checksum — those stages' outputs must already be present in dist/."
245        )]
246        publish_only: bool,
247        #[arg(
248            long,
249            alias = "prepare-only",
250            conflicts_with_all = ["publish_only", "announce_only", "rollback_only"],
251            help = "Run local build + archive + sign + checksum + sbom stages but skip release / publish / announce (GoReleaser Pro parity). Artifacts stay in dist/ for inspection. `--prepare-only` is accepted as an alias for GR-imported scripts."
252        )]
253        prepare: bool,
254        #[arg(
255            long = "announce-only",
256            conflicts_with_all = ["prepare", "publish_only", "snapshot", "rollback_only", "split", "merge", "clean"],
257            help = "Re-fire announcers only. Loads `<dist>/run-<id>/report.json` written by a prior run, skips every pipeline stage except announce (which itself short-circuits on nightly), then runs after-hooks. Use this to retry a transient announcer failure (Slack 502, Discord 5xx) without re-creating the GitHub release or re-publishing to package managers. Fails fast when no `<dist>/run-<id>/report.json` is present."
258        )]
259        announce_only: bool,
260        #[arg(
261            long,
262            help = "Resume into an existing release left over from a prior failed attempt; bypasses the safety check that bails on partial assets."
263        )]
264        resume_release: bool,
265        #[arg(
266            long,
267            help = "Force release.replace_existing_artifacts: true regardless of config (overwrite conflicting assets on retry)."
268        )]
269        replace_existing: bool,
270        #[arg(
271            long = "no-post-publish-poll",
272            help = "Skip post-publish polling for chocolatey moderation / winget PR validation; report NotPolled for affected publishers."
273        )]
274        no_post_publish_poll: bool,
275    },
276    /// Build binaries only (always runs in snapshot mode)
277    Build {
278        #[arg(long = "crate", action = clap::ArgAction::Append, help = "Build a specific crate (repeatable)")]
279        crate_names: Vec<String>,
280        #[arg(
281            long,
282            default_value = "60m",
283            help = "Pipeline timeout duration (e.g., 60m, 1h, 5s)"
284        )]
285        timeout: String,
286        #[arg(
287            long,
288            short = 'p',
289            help = "Maximum number of parallel build jobs (default: number of CPUs)"
290        )]
291        parallelism: Option<usize>,
292        #[arg(long, help = "Build only for the host target triple")]
293        single_target: bool,
294        #[arg(
295            long,
296            conflicts_with = "crate_names",
297            help = "Build a specific workspace in a monorepo config"
298        )]
299        workspace: Option<String>,
300        #[arg(
301            long,
302            short = 'o',
303            help = "Copy the built binary to this path (requires --single-target and single crate)"
304        )]
305        output: Option<PathBuf>,
306        #[arg(
307            long,
308            value_delimiter = ',',
309            help = "Skip stages (comma-separated: pre-hooks, post-hooks, validate, before)"
310        )]
311        skip: Vec<String>,
312    },
313    /// Validate configuration and run determinism checks
314    Check {
315        #[command(subcommand)]
316        cmd: CheckCmd,
317    },
318    /// Generate starter config, or enroll version-bearing files
319    Init {
320        #[arg(
321            long,
322            help = "Discover repo files that embed the current version and enroll the selection into version_files in .anodizer.yaml"
323        )]
324        version_files: bool,
325        #[arg(
326            long,
327            value_delimiter = ',',
328            requires = "version_files",
329            help = "Glob(s) to drop from discovered candidates (repeatable or comma-separated); only with --version-files"
330        )]
331        exclude: Vec<String>,
332        #[arg(
333            long,
334            short = 'y',
335            requires = "version_files",
336            help = "Non-interactive: enroll all discovered candidates without prompting"
337        )]
338        yes: bool,
339    },
340    /// Manage CHANGELOG.md: refresh the pending section, or render notes/JSON
341    Changelog {
342        #[arg(
343            value_name = "tag|range",
344            help = "Commit range to render: a single tag (predecessor-resolved against its crate), an explicit `from..to` range, or omitted to refresh each crate's pending section against its last tag"
345        )]
346        range: Option<String>,
347        #[arg(
348            long,
349            value_enum,
350            default_value = "keep-a-changelog",
351            help = "Output format: keep-a-changelog (refresh the [Unreleased] section), release-notes (grouped-bullet GitHub body to stdout), or json"
352        )]
353        format: ChangelogFormat,
354        #[arg(
355            long,
356            help = "Apply the regenerated [Unreleased] section to the configured CHANGELOG.md file(s) in place (keep-a-changelog only)"
357        )]
358        write: bool,
359        #[arg(long = "crate", help = "Restrict to a specific crate in a workspace")]
360        crate_name: Option<String>,
361        #[arg(
362            long,
363            help = "Preview as a snapshot release (release-notes format only)"
364        )]
365        snapshot: bool,
366    },
367    /// Generate shell completions
368    Completion {
369        #[arg(value_enum, help = "Shell to generate completions for")]
370        shell: Shell,
371    },
372    /// Check availability of required external tools
373    Healthcheck,
374    /// Verify the environment can run the configured release: required
375    /// tools, env vars/secrets (presence only — values are never printed),
376    /// endpoint reachability, docker daemon, and loadable key material,
377    /// all derived from the resolved config. Every failure is reported in
378    /// one pass and the exit code is non-zero when anything is missing.
379    /// The same checks run automatically at the start of `anodizer release`.
380    Preflight {
381        #[arg(long, help = "Output the report as JSON")]
382        json: bool,
383        #[arg(
384            long,
385            help = "Check only the publish-time surface (the stages `release --publish-only` runs), not artifact-producing stages"
386        )]
387        publish_only: bool,
388        #[arg(
389            long,
390            value_delimiter = ',',
391            help = "Skip requirement collection for these stages (comma-separated, same names as release --skip)"
392        )]
393        skip: Vec<String>,
394        #[arg(
395            long,
396            hide_env_values = true,
397            help = "GitHub token override; when set, GitHub token env-var requirements are treated as satisfied"
398        )]
399        token: Option<String>,
400    },
401    /// Generate man pages to stdout
402    Man,
403    /// Output JSON Schema for .anodizer.yaml
404    Jsonschema,
405    /// Resolve a git tag to its matching crate in the config
406    ResolveTag {
407        #[arg(help = "Tag to resolve (e.g. 'v1.2.3', 'core-v0.2.3')")]
408        tag: String,
409        #[arg(long, help = "Output as JSON")]
410        json: bool,
411    },
412    /// Emit the configured build targets as a GitHub Actions matrix.
413    ///
414    /// Derives `{os, target, artifact}` entries from `.anodizer.yaml`.
415    /// Consumed by `anodizer-action`'s `split-matrix` output to feed a
416    /// `strategy.matrix` dynamically (via `fromJson`).
417    Targets {
418        #[arg(long, help = "Output as JSON (include-form matrix)")]
419        json: bool,
420        #[arg(long = "crate", action = clap::ArgAction::Append, help = "Restrict to specific crate(s)")]
421        crate_names: Vec<String>,
422    },
423    /// Auto-tag based on commit message directives
424    Tag {
425        #[arg(long, help = "Show what tag would be created without pushing")]
426        dry_run: bool,
427        #[arg(long, help = "Override bump logic with a specific tag value")]
428        custom_tag: Option<String>,
429        /// Tag exactly this semver version, bypassing autotag derivation and the
430        /// Cargo.toml-ahead guard.
431        ///
432        /// Accepts `1.2.3` or `v1.2.3` (the `v`/configured prefix is normalized).
433        /// The version is applied to the tag AND synced into the relevant
434        /// `Cargo.toml` / `version_files` (single-crate, `--crate`, and lockstep
435        /// modes). In per-crate workspace mode it is rejected unless `--crate
436        /// <name>` selects a single crate — one version across independently
437        /// versioned crates would corrupt their cadences. Intended for release
438        /// recovery where the operator must pin a precise version.
439        #[arg(long = "version", value_name = "VERSION")]
440        version_override: Option<String>,
441        #[arg(long, help = "Override default bump type (patch/minor/major)")]
442        default_bump: Option<String>,
443        #[arg(long = "crate", help = "Tag a specific crate in a workspace")]
444        crate_name: Option<String>,
445        #[arg(
446            long,
447            help = "Push the version-sync bump commit to the release branch atomically with the tag"
448        )]
449        push: bool,
450        #[arg(
451            long,
452            conflicts_with = "push",
453            help = "Push the tag only, leaving the version-sync bump commit local"
454        )]
455        no_push: bool,
456        #[arg(
457            long,
458            value_name = "NAME",
459            help = "Remote to push to (default: origin)"
460        )]
461        push_remote: Option<String>,
462        #[arg(
463            long,
464            help = "Create the tag + bump commit locally but only print (not run) the git push commands --push would use; pass --dry-run to also preview tagging"
465        )]
466        push_dry_run: bool,
467        #[arg(
468            long = "changelog",
469            help = "Refresh CHANGELOG.md as part of this tag (requires a `changelog:` config block)"
470        )]
471        changelog: bool,
472        /// `anodize tag rollback [...]` — failure-recovery counterpart.
473        ///
474        /// Subcommand is optional: bare `anodize tag` keeps its
475        /// existing autotag behavior; only `anodize tag rollback`
476        /// invokes the rollback flow.
477        #[command(subcommand)]
478        sub: Option<TagSub>,
479    },
480    /// Resume a release after a transient failure or after `--prepare`/`--split`
481    ///
482    /// With `--merge`: load every per-target `context.json` under `dist/` (one
483    /// per split-build worker) and run the full post-build pipeline
484    /// (sign / checksum / sbom / release / publish / announce).
485    ///
486    /// Without `--merge`: load existing `dist/` artifacts and run the
487    /// publish-only pipeline (release / publish / blob). Use this to resume
488    /// a single-host release that stalled during publish (e.g. expired
489    /// token, transient 5xx) without rebuilding.
490    ///
491    /// `continue` vs `publish`: both consume a populated `dist/` and run
492    /// the release / publish / blob chain. `continue` is the recommended
493    /// alias for "resume a stalled single-host release" — the
494    /// `continue` command and the in-repo `--prepare` → `continue`
495    /// flow. `publish` is the lower-level entry point that does the same
496    /// thing without the resume framing; prefer `continue` unless you're
497    /// invoking the publish chain on a dist that was never paused. Neither
498    /// is being deprecated.
499    Continue {
500        #[arg(
501            long,
502            help = "Merge artifacts from split build jobs and run post-build stages"
503        )]
504        merge: bool,
505        #[arg(long, help = "Custom dist directory (overrides config)")]
506        dist: Option<PathBuf>,
507        #[arg(long, help = "Run full pipeline without side effects")]
508        dry_run: bool,
509        #[arg(
510            long,
511            value_delimiter = ',',
512            help = "Skip stages (comma-separated, e.g. docker,announce)"
513        )]
514        skip: Vec<String>,
515        #[arg(
516            long,
517            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
518        )]
519        token: Option<String>,
520    },
521    /// Run only the publish stages (release, publish, blob) from a completed dist/
522    ///
523    /// `publish` vs `continue`: both consume a populated `dist/` and run
524    /// the same release / publish / blob chain. `publish` is the
525    /// lower-level entry point — no resume framing, no after-hooks /
526    /// milestone closure. `continue` is the recommended alias when
527    /// resuming a stalled single-host release (the
528    /// `continue` command); it additionally invokes the announce
529    /// stage and treats the dist as a paused-release surface. Prefer
530    /// `continue` unless you specifically want the unframed publish
531    /// chain. `--dist` overrides the configured dist directory;
532    /// `release` has no `--dist` because it produces dist.
533    Publish {
534        #[arg(long, help = "Run full pipeline without side effects")]
535        dry_run: bool,
536        #[arg(
537            long,
538            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
539        )]
540        token: Option<String>,
541        #[arg(long, help = "Custom dist directory (overrides config)")]
542        dist: Option<PathBuf>,
543        #[arg(
544            long,
545            help = "Merge artifacts from `release --split` workers (dist/<subdir>/context.json) before running the publish-only pipeline. Mirrors `goreleaser publish --merge`."
546        )]
547        merge: bool,
548        #[arg(
549            long,
550            help = "Force re-publish even when a prior report.json exists. \
551                    WARNING: PR-based publishers will open duplicate pull requests."
552        )]
553        allow_rerun: bool,
554        #[arg(
555            long = "show-skipped",
556            help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
557                    (normally only visible with --debug). Use to diagnose why a publisher didn't \
558                    run for a given crate."
559        )]
560        show_skipped: bool,
561    },
562    /// Bump crate versions (Conventional Commits → semver level)
563    ///
564    /// Infers the per-crate level from commits since each crate's last tag
565    /// when no positional argument is given. `patch|minor|major`, an explicit
566    /// version, or `release` (strip prerelease) are also accepted.
567    Bump {
568        #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
569        level_or_version: Option<String>,
570        #[arg(
571            long,
572            short = 'p',
573            visible_alias = "crate",
574            action = clap::ArgAction::Append,
575            help = "Bump a specific crate (repeatable)"
576        )]
577        package: Vec<String>,
578        #[arg(
579            long,
580            alias = "all",
581            conflicts_with = "package",
582            help = "Bump every workspace member (excluding publish=false)"
583        )]
584        workspace: bool,
585        #[arg(
586            long,
587            action = clap::ArgAction::Append,
588            help = "Exclude a crate from --workspace (repeatable)"
589        )]
590        exclude: Vec<String>,
591        #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
592        pre: Option<String>,
593        #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
594        exact: bool,
595        #[arg(
596            long,
597            help = "Proceed even if the working tree has uncommitted changes"
598        )]
599        allow_dirty: bool,
600        #[arg(long, short = 'y', help = "Skip confirmation prompt")]
601        yes: bool,
602        #[arg(long, help = "Print the plan without editing any files")]
603        dry_run: bool,
604        #[arg(long, help = "Stage edits and create a single commit")]
605        commit: bool,
606        #[arg(
607            long = "changelog",
608            requires = "commit",
609            help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
610        )]
611        changelog: bool,
612        #[arg(
613            long,
614            requires = "commit",
615            help = "GPG-sign the commit (requires --commit)"
616        )]
617        sign: bool,
618        #[arg(long, help = "Override the default commit message template")]
619        commit_message: Option<String>,
620        #[arg(
621            long,
622            default_value = "text",
623            help = "Output format: text | json (json requires --dry-run)"
624        )]
625        output: String,
626    },
627    /// Run only the announce stage from a completed dist/
628    ///
629    /// Counterpart to `release --announce-only`: both re-fire announcers
630    /// against a populated dist without re-publishing. The subcommand
631    /// form (`anodizer announce`) accepts `--dist` to point at a
632    /// non-default tree (e.g. preserved by `--preserve-dist`); the flag
633    /// form (`release --announce-only`) operates on the dist configured
634    /// in `.anodizer.yaml`. Both honor nightly short-circuit.
635    Announce {
636        #[arg(long, help = "Run full pipeline without side effects")]
637        dry_run: bool,
638        #[arg(long, help = "Custom dist directory (overrides config)")]
639        dist: Option<PathBuf>,
640        #[arg(
641            long,
642            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
643        )]
644        token: Option<String>,
645        #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
646        skip: Vec<String>,
647        #[arg(
648            long,
649            help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
650        )]
651        merge: bool,
652    },
653    /// Send a notification through configured announce integrations.
654    ///
655    /// Fires configured announce integrations (slack, discord, webhook, …) with
656    /// a Tera-rendered message. Unlike `announce`, this command does not require
657    /// a `dist/` directory — it is intended for ad-hoc notifications outside the
658    /// release pipeline (e.g. CI status alerts, deployment notices).
659    Notify {
660        /// Message template to send. Supports standard Tera template vars
661        /// (e.g. `{{ ProjectName }}`, `{{ Tag }}`, `{{ Version }}`).
662        message: String,
663        /// Comma-separated list of integration names to fire (default: all).
664        /// Valid names: discord, discourse, slack, webhook, telegram, teams,
665        /// mattermost, reddit, twitter, mastodon, bluesky, linkedin.
666        #[arg(long = "publishers", value_delimiter = ',')]
667        publishers: Vec<String>,
668        /// Comma-separated list of integration names to omit.
669        #[arg(long = "skip", value_delimiter = ',')]
670        skip: Vec<String>,
671        /// Send the message literally, without Tera template rendering. Use
672        /// when the message contains untrusted text (e.g. error output in an
673        /// on_error hook).
674        #[arg(long)]
675        raw: bool,
676        /// Send secrets in the message body verbatim (disable outbound
677        /// redaction). For trusted private channels only; log output stays
678        /// redacted.
679        #[arg(long = "allow-secrets")]
680        allow_secrets: bool,
681        /// Run without sending (dry-run mode).
682        #[arg(long)]
683        dry_run: bool,
684    },
685}
686
687/// Output format for `anodizer changelog`.
688#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
689pub enum ChangelogFormat {
690    /// Regenerate the `## [Unreleased]` section(s) of the configured
691    /// `CHANGELOG.md` file(s) (the default). Previews to stdout; writes in
692    /// place with `--write`.
693    #[default]
694    #[value(name = "keep-a-changelog", alias = "kac")]
695    KeepAChangelog,
696    /// GitHub-release-body markdown (grouped bullets) for the resolved range,
697    /// to stdout. The historical `anodizer changelog` behavior.
698    ReleaseNotes,
699    /// Machine-readable JSON array of `{ crate, from, to, groups }` objects,
700    /// one per selected crate, sorted by crate name.
701    Json,
702}
703
704/// `anodize tag` parent subcommand.
705///
706/// Bare `anodize tag` keeps its existing autotag behavior (handled
707/// by the `Tag` variant directly). `anodize tag rollback` opts into
708/// the failure-recovery flow described in
709/// [`commands::tag::rollback`].
710#[derive(Subcommand)]
711pub enum TagSub {
712    /// Rollback anodize-managed tags at a SHA, then revert (or reset
713    /// past) the bump commit they point at.
714    Rollback {
715        #[arg(
716            value_name = "sha",
717            help = "Commit SHA to roll back from. Defaults to HEAD."
718        )]
719        sha: Option<String>,
720        #[arg(long, help = "Print what would happen without mutating anything")]
721        dry_run: bool,
722        #[arg(
723            long = "no-push",
724            help = "Skip remote tag delete and branch push (local-only)"
725        )]
726        no_push: bool,
727        #[arg(
728            long,
729            help = "Override the published-state guard: roll back even when the tag's run summary shows a one-way-door publisher (crates.io, chocolatey, winget, snapcraft, ...) accepted the version, or — when no summary exists — when a published (non-draft) GitHub release exists for the tag. Without it, rollback refuses because those registries never accept the same version twice: the version is burned and the only clean recovery is fixing forward"
730        )]
731        force: bool,
732        #[arg(
733            long,
734            default_value = "all",
735            help = "Tag-shape filter: all | lockstep | per-crate"
736        )]
737        scope: String,
738        #[arg(
739            long,
740            default_value = "revert",
741            help = "Rollback strategy: revert (default; history-preserving) | reset (opt-in; rewrites history, requires --force-with-lease to push)"
742        )]
743        mode: String,
744        #[arg(
745            long,
746            value_name = "name",
747            help = "Branch name to push the revert commit to. Required when HEAD is detached and no local branch points at it (typical CI tag-push context, where GITHUB_REF_NAME is the tag — not the bump-commit branch). Pass --branch master (or whichever branch the bump commit was created on)."
748        )]
749        branch: Option<String>,
750    },
751}
752
753/// `anodize check` parent subcommand.
754///
755/// `Config` is the historic `check` body (validate `.anodizer.yaml`); the
756/// determinism harness is plumbed here so the flag set ships with this
757/// commit, but the body lands in a follow-up task.
758#[derive(Subcommand)]
759pub enum CheckCmd {
760    /// Validate the workspace's anodize config.
761    Config {
762        #[arg(long, help = "Validate a specific workspace in a monorepo config")]
763        workspace: Option<String>,
764    },
765    /// Run the determinism harness (build pipeline twice, diff artifacts).
766    Determinism(CheckDeterminismArgs),
767    /// Check that enrolled `version_files` still match each crate's current version.
768    VersionFiles,
769}
770
771#[derive(clap::Args)]
772pub struct CheckDeterminismArgs {
773    #[arg(
774        long,
775        default_value = "2",
776        help = "Number of from-clean rebuilds to diff"
777    )]
778    pub runs: u32,
779    #[arg(
780        long,
781        value_name = "stages",
782        help = "Optional stage subset (build,source,upx,archive,nfpm,makeself,snapcraft,sbom,sign,checksum,cargo-package,docker,msi,nsis,dmg,pkg,srpm,appbundle, plus the `installers` family selector expanding to nfpm,makeself,srpm,msi,nsis,dmg,pkg). The list is also the build filter: stages NOT named here are added to the child release's `--skip=` set, so a stage must be requested to be byte-verified. `cargo-package` is harness-only — drives `cargo package --no-verify --allow-dirty` per workspace member to probe `.crate` byte-stability without hitting a registry. `docker` is harness-only — drives `docker buildx build --output=type=oci,rewrite-timestamp=true,dest=…` against `<repo>/Dockerfile` to probe OCI image byte-stability without pushing to a registry; skipped when `docker buildx` is unavailable or no Dockerfile exists. Installer stages (msi/nsis/dmg/pkg/srpm) are skipped at the gate when their backing tool is absent; `appbundle` is pure file assembly and always runs when requested."
783    )]
784    pub stages: Option<String>,
785    #[arg(
786        long,
787        value_name = "csv",
788        help = "Restrict the harness to a comma-separated subset of configured target triples. Used by the sharded release workflow so each runner only validates targets it can natively build (Linux runner skips macOS targets, etc.). Forwarded to the child `anodize release --snapshot` subprocess."
789    )]
790    pub targets: Option<String>,
791    #[arg(
792        long,
793        value_name = "path",
794        help = "JSON report path; default dist/run-<id>/determinism.json"
795    )]
796    pub report: Option<PathBuf>,
797    #[arg(
798        long,
799        conflicts_with = "no_snapshot",
800        help = "Force snapshot mode on the child release subprocess (artifacts get a `-SNAPSHOT-<sha>` suffix). Default: auto — snapshot off when HEAD is at a tag, on otherwise."
801    )]
802    pub snapshot: bool,
803    #[arg(
804        long = "no-snapshot",
805        conflicts_with = "snapshot",
806        help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
807    )]
808    pub no_snapshot: bool,
809    #[arg(
810        long = "inject-drift",
811        value_name = "stage",
812        hide = true,
813        help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
814    )]
815    pub inject_drift: Option<String>,
816    #[arg(
817        long = "preserve-dist",
818        value_name = "path",
819        help = "When the harness greens, copy run-0's `<worktree>/dist/**` to <path> and emit `<path>/context.json` describing the artifact set. The release workflow's publish-only path consumes this to ship the determinism step's output directly (eliminates the redundant `build:` recompilation). Local operators can pass this too — useful for inspecting a hermetic dist tree without re-running the release pipeline."
820    )]
821    pub preserve_dist: Option<PathBuf>,
822    #[arg(
823        long = "crate",
824        value_name = "name",
825        help = "When --preserve-dist is set, write the preserved dist tree to \
826                <dest>/<name>/ instead of directly into <dest>/. Used by the \
827                sharded matrix to produce per-crate subdirectories so a \
828                `release --publish-only` job can merge all crates into a single \
829                dist/ without context.json collision."
830    )]
831    pub crate_name: Option<String>,
832}
833
834/// Clap `value_parser` for `--from-run=<id>`.
835///
836/// `run_id` is operator-controlled and is joined directly into a
837/// filesystem path (`<dist>/run-<id>/{report,rollback}.json`) by the
838/// `--rollback-only` replay code. Without this validator,
839/// `--from-run=../../etc/passwd` would resolve to a traversed path on
840/// both read (`report.json`) and write (`rollback.json`) — operator
841/// data-loss potential.
842///
843/// Delegates to [`anodizer_stage_publish::rollback_only::validate_run_id`]
844/// so the rule has a single source of truth (the same validator runs at
845/// the `run_with_publishers` entry point as a defense-in-depth guard).
846fn parse_run_id(s: &str) -> Result<String, String> {
847    anodizer_stage_publish::rollback_only::validate_run_id(s)
848        .map(|()| s.to_string())
849        .map_err(|err| format!("{:#}", err))
850}
851
852/// Detect the host target triple by parsing `rustc -vV` output.
853/// Delegates to `anodizer_core::partial::detect_host_target()`.
854pub fn detect_host_target() -> anyhow::Result<String> {
855    anodizer_core::partial::detect_host_target()
856}
857
858/// Return a sensible default parallelism value (number of logical CPUs, minimum 1).
859pub fn num_cpus() -> usize {
860    std::thread::available_parallelism()
861        .map(|n| n.get())
862        .unwrap_or(4)
863}
864
865/// Build the clap `Command` tree for CLI introspection.
866pub fn build_cli() -> clap::Command {
867    <Cli as clap::CommandFactory>::command()
868}