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 = "allow-nondeterministic",
201            value_name = "name=reason",
202            action = clap::ArgAction::Append,
203            help = "Runtime non-determinism opt-out for a specific artifact (repeatable). Mutually exclusive with --strict."
204        )]
205        allow_nondeterministic: Vec<String>,
206        #[arg(
207            long = "summary-json",
208            value_name = "path",
209            help = "Write the per-publisher run summary JSON to this path."
210        )]
211        summary_json: Option<PathBuf>,
212        #[arg(
213            long = "allow-ai-failure",
214            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."
215        )]
216        allow_ai_failure: bool,
217        #[arg(
218            long,
219            conflicts_with = "merge",
220            help = "Run only the build stage for split CI fan-out (outputs artifacts JSON to dist/)"
221        )]
222        split: bool,
223        #[arg(
224            long,
225            conflicts_with = "split",
226            help = "Merge artifacts from split build jobs and resume the pipeline from post-build stages"
227        )]
228        merge: bool,
229        #[arg(
230            long = "publish-only",
231            conflicts_with_all = ["split", "merge", "prepare", "announce_only", "snapshot", "rollback_only", "clean"],
232            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/."
233        )]
234        publish_only: bool,
235        #[arg(
236            long,
237            alias = "prepare-only",
238            conflicts_with_all = ["publish_only", "announce_only", "rollback_only"],
239            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."
240        )]
241        prepare: bool,
242        #[arg(
243            long = "announce-only",
244            conflicts_with_all = ["prepare", "publish_only", "snapshot", "rollback_only", "split", "merge", "clean"],
245            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."
246        )]
247        announce_only: bool,
248        #[arg(
249            long,
250            help = "Resume into an existing release left over from a prior failed attempt; bypasses the safety check that bails on partial assets."
251        )]
252        resume_release: bool,
253        #[arg(
254            long,
255            help = "Force release.replace_existing_artifacts: true regardless of config (overwrite conflicting assets on retry)."
256        )]
257        replace_existing: bool,
258        #[arg(
259            long = "no-post-publish-poll",
260            help = "Skip post-publish polling for chocolatey moderation / winget PR validation; report NotPolled for affected publishers."
261        )]
262        no_post_publish_poll: bool,
263    },
264    /// Build binaries only (always runs in snapshot mode)
265    Build {
266        #[arg(long = "crate", action = clap::ArgAction::Append, help = "Build a specific crate (repeatable)")]
267        crate_names: Vec<String>,
268        #[arg(
269            long,
270            default_value = "60m",
271            help = "Pipeline timeout duration (e.g., 60m, 1h, 5s)"
272        )]
273        timeout: String,
274        #[arg(
275            long,
276            short = 'p',
277            help = "Maximum number of parallel build jobs (default: number of CPUs)"
278        )]
279        parallelism: Option<usize>,
280        #[arg(long, help = "Build only for the host target triple")]
281        single_target: bool,
282        #[arg(
283            long,
284            conflicts_with = "crate_names",
285            help = "Build a specific workspace in a monorepo config"
286        )]
287        workspace: Option<String>,
288        #[arg(
289            long,
290            short = 'o',
291            help = "Copy the built binary to this path (requires --single-target and single crate)"
292        )]
293        output: Option<PathBuf>,
294        #[arg(
295            long,
296            value_delimiter = ',',
297            help = "Skip stages (comma-separated: pre-hooks, post-hooks, validate, before)"
298        )]
299        skip: Vec<String>,
300    },
301    /// Validate configuration and run determinism checks
302    Check {
303        #[command(subcommand)]
304        cmd: CheckCmd,
305    },
306    /// Generate starter config, or enroll version-bearing files
307    Init {
308        #[arg(
309            long,
310            help = "Discover repo files that embed the current version and enroll the selection into version_files in .anodizer.yaml"
311        )]
312        version_files: bool,
313        #[arg(
314            long,
315            value_delimiter = ',',
316            requires = "version_files",
317            help = "Glob(s) to drop from discovered candidates (repeatable or comma-separated); only with --version-files"
318        )]
319        exclude: Vec<String>,
320        #[arg(
321            long,
322            short = 'y',
323            requires = "version_files",
324            help = "Non-interactive: enroll all discovered candidates without prompting"
325        )]
326        yes: bool,
327    },
328    /// Manage CHANGELOG.md: refresh the pending section, or render notes/JSON
329    Changelog {
330        #[arg(
331            value_name = "tag|range",
332            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"
333        )]
334        range: Option<String>,
335        #[arg(
336            long,
337            value_enum,
338            default_value = "keep-a-changelog",
339            help = "Output format: keep-a-changelog (refresh the [Unreleased] section), release-notes (grouped-bullet GitHub body to stdout), or json"
340        )]
341        format: ChangelogFormat,
342        #[arg(
343            long,
344            help = "Apply the regenerated [Unreleased] section to the configured CHANGELOG.md file(s) in place (keep-a-changelog only)"
345        )]
346        write: bool,
347        #[arg(long = "crate", help = "Restrict to a specific crate in a workspace")]
348        crate_name: Option<String>,
349        #[arg(
350            long,
351            help = "Preview as a snapshot release (release-notes format only)"
352        )]
353        snapshot: bool,
354    },
355    /// Generate shell completions
356    Completion {
357        #[arg(value_enum, help = "Shell to generate completions for")]
358        shell: Shell,
359    },
360    /// Check availability of required external tools
361    Healthcheck,
362    /// Generate man pages to stdout
363    Man,
364    /// Output JSON Schema for .anodizer.yaml
365    Jsonschema,
366    /// Resolve a git tag to its matching crate in the config
367    ResolveTag {
368        #[arg(help = "Tag to resolve (e.g. 'v1.2.3', 'core-v0.2.3')")]
369        tag: String,
370        #[arg(long, help = "Output as JSON")]
371        json: bool,
372    },
373    /// Emit the configured build targets as a GitHub Actions matrix.
374    ///
375    /// Derives `{os, target, artifact}` entries from `.anodizer.yaml`.
376    /// Consumed by `anodizer-action`'s `split-matrix` output to feed a
377    /// `strategy.matrix` dynamically (via `fromJson`).
378    Targets {
379        #[arg(long, help = "Output as JSON (include-form matrix)")]
380        json: bool,
381        #[arg(long = "crate", action = clap::ArgAction::Append, help = "Restrict to specific crate(s)")]
382        crate_names: Vec<String>,
383    },
384    /// Auto-tag based on commit message directives
385    Tag {
386        #[arg(long, help = "Show what tag would be created without pushing")]
387        dry_run: bool,
388        #[arg(long, help = "Override bump logic with a specific tag value")]
389        custom_tag: Option<String>,
390        /// Tag exactly this semver version, bypassing autotag derivation and the
391        /// Cargo.toml-ahead guard.
392        ///
393        /// Accepts `1.2.3` or `v1.2.3` (the `v`/configured prefix is normalized).
394        /// The version is applied to the tag AND synced into the relevant
395        /// `Cargo.toml` / `version_files` (single-crate, `--crate`, and lockstep
396        /// modes). In per-crate workspace mode it is rejected unless `--crate
397        /// <name>` selects a single crate — one version across independently
398        /// versioned crates would corrupt their cadences. Intended for release
399        /// recovery where the operator must pin a precise version.
400        #[arg(long = "version", value_name = "VERSION")]
401        version_override: Option<String>,
402        #[arg(long, help = "Override default bump type (patch/minor/major)")]
403        default_bump: Option<String>,
404        #[arg(long = "crate", help = "Tag a specific crate in a workspace")]
405        crate_name: Option<String>,
406        #[arg(
407            long,
408            help = "Push the version-sync bump commit to the release branch atomically with the tag"
409        )]
410        push: bool,
411        #[arg(
412            long,
413            conflicts_with = "push",
414            help = "Push the tag only, leaving the version-sync bump commit local"
415        )]
416        no_push: bool,
417        #[arg(
418            long,
419            value_name = "NAME",
420            help = "Remote to push to (default: origin)"
421        )]
422        push_remote: Option<String>,
423        #[arg(
424            long,
425            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"
426        )]
427        push_dry_run: bool,
428        #[arg(
429            long = "changelog",
430            help = "Refresh CHANGELOG.md as part of this tag (requires a `changelog:` config block)"
431        )]
432        changelog: bool,
433        /// `anodize tag rollback [...]` — failure-recovery counterpart.
434        ///
435        /// Subcommand is optional: bare `anodize tag` keeps its
436        /// existing autotag behavior; only `anodize tag rollback`
437        /// invokes the rollback flow.
438        #[command(subcommand)]
439        sub: Option<TagSub>,
440    },
441    /// Resume a release after a transient failure or after `--prepare`/`--split`
442    ///
443    /// With `--merge`: load every per-target `context.json` under `dist/` (one
444    /// per split-build worker) and run the full post-build pipeline
445    /// (sign / checksum / sbom / release / publish / announce).
446    ///
447    /// Without `--merge`: load existing `dist/` artifacts and run the
448    /// publish-only pipeline (release / publish / blob). Use this to resume
449    /// a single-host release that stalled during publish (e.g. expired
450    /// token, transient 5xx) without rebuilding.
451    ///
452    /// `continue` vs `publish`: both consume a populated `dist/` and run
453    /// the release / publish / blob chain. `continue` is the recommended
454    /// alias for "resume a stalled single-host release" — the
455    /// `continue` command and the in-repo `--prepare` → `continue`
456    /// flow. `publish` is the lower-level entry point that does the same
457    /// thing without the resume framing; prefer `continue` unless you're
458    /// invoking the publish chain on a dist that was never paused. Neither
459    /// is being deprecated.
460    Continue {
461        #[arg(
462            long,
463            help = "Merge artifacts from split build jobs and run post-build stages"
464        )]
465        merge: bool,
466        #[arg(long, help = "Custom dist directory (overrides config)")]
467        dist: Option<PathBuf>,
468        #[arg(long, help = "Run full pipeline without side effects")]
469        dry_run: bool,
470        #[arg(
471            long,
472            value_delimiter = ',',
473            help = "Skip stages (comma-separated, e.g. docker,announce)"
474        )]
475        skip: Vec<String>,
476        #[arg(
477            long,
478            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
479        )]
480        token: Option<String>,
481    },
482    /// Run only the publish stages (release, publish, blob) from a completed dist/
483    ///
484    /// `publish` vs `continue`: both consume a populated `dist/` and run
485    /// the same release / publish / blob chain. `publish` is the
486    /// lower-level entry point — no resume framing, no after-hooks /
487    /// milestone closure. `continue` is the recommended alias when
488    /// resuming a stalled single-host release (the
489    /// `continue` command); it additionally invokes the announce
490    /// stage and treats the dist as a paused-release surface. Prefer
491    /// `continue` unless you specifically want the unframed publish
492    /// chain. `--dist` overrides the configured dist directory;
493    /// `release` has no `--dist` because it produces dist.
494    Publish {
495        #[arg(long, help = "Run full pipeline without side effects")]
496        dry_run: bool,
497        #[arg(
498            long,
499            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
500        )]
501        token: Option<String>,
502        #[arg(long, help = "Custom dist directory (overrides config)")]
503        dist: Option<PathBuf>,
504        #[arg(
505            long,
506            help = "Merge artifacts from `release --split` workers (dist/<subdir>/context.json) before running the publish-only pipeline. Mirrors `goreleaser publish --merge`."
507        )]
508        merge: bool,
509        #[arg(
510            long,
511            help = "Force re-publish even when a prior report.json exists. \
512                    WARNING: PR-based publishers will open duplicate pull requests."
513        )]
514        allow_rerun: bool,
515    },
516    /// Bump crate versions (Conventional Commits → semver level)
517    ///
518    /// Infers the per-crate level from commits since each crate's last tag
519    /// when no positional argument is given. `patch|minor|major`, an explicit
520    /// version, or `release` (strip prerelease) are also accepted.
521    Bump {
522        #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
523        level_or_version: Option<String>,
524        #[arg(
525            long,
526            short = 'p',
527            visible_alias = "crate",
528            action = clap::ArgAction::Append,
529            help = "Bump a specific crate (repeatable)"
530        )]
531        package: Vec<String>,
532        #[arg(
533            long,
534            alias = "all",
535            conflicts_with = "package",
536            help = "Bump every workspace member (excluding publish=false)"
537        )]
538        workspace: bool,
539        #[arg(
540            long,
541            action = clap::ArgAction::Append,
542            help = "Exclude a crate from --workspace (repeatable)"
543        )]
544        exclude: Vec<String>,
545        #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
546        pre: Option<String>,
547        #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
548        exact: bool,
549        #[arg(
550            long,
551            help = "Proceed even if the working tree has uncommitted changes"
552        )]
553        allow_dirty: bool,
554        #[arg(long, short = 'y', help = "Skip confirmation prompt")]
555        yes: bool,
556        #[arg(long, help = "Print the plan without editing any files")]
557        dry_run: bool,
558        #[arg(long, help = "Stage edits and create a single commit")]
559        commit: bool,
560        #[arg(
561            long = "changelog",
562            requires = "commit",
563            help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
564        )]
565        changelog: bool,
566        #[arg(
567            long,
568            requires = "commit",
569            help = "GPG-sign the commit (requires --commit)"
570        )]
571        sign: bool,
572        #[arg(long, help = "Override the default commit message template")]
573        commit_message: Option<String>,
574        #[arg(
575            long,
576            default_value = "text",
577            help = "Output format: text | json (json requires --dry-run)"
578        )]
579        output: String,
580    },
581    /// Run only the announce stage from a completed dist/
582    ///
583    /// Counterpart to `release --announce-only`: both re-fire announcers
584    /// against a populated dist without re-publishing. The subcommand
585    /// form (`anodizer announce`) accepts `--dist` to point at a
586    /// non-default tree (e.g. preserved by `--preserve-dist`); the flag
587    /// form (`release --announce-only`) operates on the dist configured
588    /// in `.anodizer.yaml`. Both honor nightly short-circuit.
589    Announce {
590        #[arg(long, help = "Run full pipeline without side effects")]
591        dry_run: bool,
592        #[arg(long, help = "Custom dist directory (overrides config)")]
593        dist: Option<PathBuf>,
594        #[arg(
595            long,
596            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
597        )]
598        token: Option<String>,
599        #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
600        skip: Vec<String>,
601        #[arg(
602            long,
603            help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
604        )]
605        merge: bool,
606    },
607    /// Send a notification through configured announce integrations.
608    ///
609    /// Fires configured announce integrations (slack, discord, webhook, …) with
610    /// a Tera-rendered message. Unlike `announce`, this command does not require
611    /// a `dist/` directory — it is intended for ad-hoc notifications outside the
612    /// release pipeline (e.g. CI status alerts, deployment notices).
613    Notify {
614        /// Message template to send. Supports standard Tera template vars
615        /// (e.g. `{{ ProjectName }}`, `{{ Tag }}`, `{{ Version }}`).
616        message: String,
617        /// Comma-separated list of integration names to fire (default: all).
618        /// Valid names: discord, discourse, slack, webhook, telegram, teams,
619        /// mattermost, reddit, twitter, mastodon, bluesky, linkedin.
620        #[arg(long = "publishers", value_delimiter = ',')]
621        publishers: Vec<String>,
622        /// Comma-separated list of integration names to omit.
623        #[arg(long = "skip", value_delimiter = ',')]
624        skip: Vec<String>,
625        /// Run without sending (dry-run mode).
626        #[arg(long)]
627        dry_run: bool,
628    },
629}
630
631/// Output format for `anodizer changelog`.
632#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
633pub enum ChangelogFormat {
634    /// Regenerate the `## [Unreleased]` section(s) of the configured
635    /// `CHANGELOG.md` file(s) (the default). Previews to stdout; writes in
636    /// place with `--write`.
637    #[default]
638    #[value(name = "keep-a-changelog", alias = "kac")]
639    KeepAChangelog,
640    /// GitHub-release-body markdown (grouped bullets) for the resolved range,
641    /// to stdout. The historical `anodizer changelog` behavior.
642    ReleaseNotes,
643    /// Machine-readable JSON array of `{ crate, from, to, groups }` objects,
644    /// one per selected crate, sorted by crate name.
645    Json,
646}
647
648/// `anodize tag` parent subcommand.
649///
650/// Bare `anodize tag` keeps its existing autotag behavior (handled
651/// by the `Tag` variant directly). `anodize tag rollback` opts into
652/// the failure-recovery flow described in
653/// [`commands::tag::rollback`].
654#[derive(Subcommand)]
655pub enum TagSub {
656    /// Rollback anodize-managed tags at a SHA, then revert (or reset
657    /// past) the bump commit they point at.
658    Rollback {
659        #[arg(
660            value_name = "sha",
661            help = "Commit SHA to roll back from. Defaults to HEAD."
662        )]
663        sha: Option<String>,
664        #[arg(long, help = "Print what would happen without mutating anything")]
665        dry_run: bool,
666        #[arg(
667            long = "no-push",
668            help = "Skip remote tag delete and branch push (local-only)"
669        )]
670        no_push: bool,
671        #[arg(
672            long,
673            default_value = "all",
674            help = "Tag-shape filter: all | lockstep | per-crate"
675        )]
676        scope: String,
677        #[arg(
678            long,
679            default_value = "revert",
680            help = "Rollback strategy: revert (default; history-preserving) | reset (opt-in; rewrites history, requires --force-with-lease to push)"
681        )]
682        mode: String,
683        #[arg(
684            long,
685            value_name = "name",
686            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)."
687        )]
688        branch: Option<String>,
689    },
690}
691
692/// `anodize check` parent subcommand.
693///
694/// `Config` is the historic `check` body (validate `.anodizer.yaml`); the
695/// determinism harness is plumbed here so the flag set ships with this
696/// commit, but the body lands in a follow-up task.
697#[derive(Subcommand)]
698pub enum CheckCmd {
699    /// Validate the workspace's anodize config.
700    Config {
701        #[arg(long, help = "Validate a specific workspace in a monorepo config")]
702        workspace: Option<String>,
703    },
704    /// Run the determinism harness (build pipeline twice, diff artifacts).
705    Determinism(CheckDeterminismArgs),
706    /// Check that enrolled `version_files` still match each crate's current version.
707    VersionFiles,
708}
709
710#[derive(clap::Args)]
711pub struct CheckDeterminismArgs {
712    #[arg(
713        long,
714        default_value = "2",
715        help = "Number of from-clean rebuilds to diff"
716    )]
717    pub runs: u32,
718    #[arg(
719        long,
720        value_name = "stages",
721        help = "Optional stage subset (build,archive,sbom,sign,checksum,cargo-package,docker). `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."
722    )]
723    pub stages: Option<String>,
724    #[arg(
725        long,
726        value_name = "csv",
727        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."
728    )]
729    pub targets: Option<String>,
730    #[arg(
731        long,
732        value_name = "path",
733        help = "JSON report path; default dist/run-<id>/determinism.json"
734    )]
735    pub report: Option<PathBuf>,
736    #[arg(
737        long,
738        conflicts_with = "no_snapshot",
739        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."
740    )]
741    pub snapshot: bool,
742    #[arg(
743        long = "no-snapshot",
744        conflicts_with = "snapshot",
745        help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
746    )]
747    pub no_snapshot: bool,
748    #[arg(
749        long = "inject-drift",
750        value_name = "stage",
751        hide = true,
752        help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
753    )]
754    pub inject_drift: Option<String>,
755    #[arg(
756        long = "preserve-dist",
757        value_name = "path",
758        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."
759    )]
760    pub preserve_dist: Option<PathBuf>,
761    #[arg(
762        long = "crate",
763        value_name = "name",
764        help = "When --preserve-dist is set, write the preserved dist tree to \
765                <dest>/<name>/ instead of directly into <dest>/. Used by the \
766                sharded matrix to produce per-crate subdirectories so a \
767                `release --publish-only` job can merge all crates into a single \
768                dist/ without context.json collision."
769    )]
770    pub crate_name: Option<String>,
771}
772
773/// Clap `value_parser` for `--from-run=<id>`.
774///
775/// `run_id` is operator-controlled and is joined directly into a
776/// filesystem path (`<dist>/run-<id>/{report,rollback}.json`) by the
777/// `--rollback-only` replay code. Without this validator,
778/// `--from-run=../../etc/passwd` would resolve to a traversed path on
779/// both read (`report.json`) and write (`rollback.json`) — operator
780/// data-loss potential.
781///
782/// Delegates to [`anodizer_stage_publish::rollback_only::validate_run_id`]
783/// so the rule has a single source of truth (the same validator runs at
784/// the `run_with_publishers` entry point as a defense-in-depth guard).
785fn parse_run_id(s: &str) -> Result<String, String> {
786    anodizer_stage_publish::rollback_only::validate_run_id(s)
787        .map(|()| s.to_string())
788        .map_err(|err| format!("{:#}", err))
789}
790
791/// Detect the host target triple by parsing `rustc -vV` output.
792/// Delegates to `anodizer_core::partial::detect_host_target()`.
793pub fn detect_host_target() -> anyhow::Result<String> {
794    anodizer_core::partial::detect_host_target()
795}
796
797/// Return a sensible default parallelism value (number of logical CPUs, minimum 1).
798pub fn num_cpus() -> usize {
799    std::thread::available_parallelism()
800        .map(|n| n.get())
801        .unwrap_or(4)
802}
803
804/// Build the clap `Command` tree for CLI introspection.
805pub fn build_cli() -> clap::Command {
806    <Cli as clap::CommandFactory>::command()
807}