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,
95            help = "Path to a custom release notes file (overrides changelog)"
96        )]
97        release_notes: Option<PathBuf>,
98        #[arg(
99            long,
100            conflicts_with = "crate_names",
101            help = "Release a specific workspace in a monorepo config"
102        )]
103        workspace: Option<String>,
104        #[arg(
105            long,
106            conflicts_with = "no_preflight",
107            help = "Run pre-flight publisher-state check and exit (don't start the pipeline)"
108        )]
109        preflight: bool,
110        #[arg(
111            long,
112            conflicts_with = "preflight",
113            help = "Skip the automatic pre-flight publisher-state check"
114        )]
115        no_preflight: bool,
116        #[arg(
117            long,
118            help = "Alias for --strict (also treats Unknown publisher state as a blocker during pre-flight)"
119        )]
120        strict_preflight: bool,
121        #[arg(long, help = "Set the release as a draft")]
122        draft: bool,
123        #[arg(long, help = "Path to a file containing custom release header text")]
124        release_header: Option<PathBuf>,
125        #[arg(
126            long,
127            help = "Path to a template file for release header (rendered with template variables)"
128        )]
129        release_header_tmpl: Option<PathBuf>,
130        #[arg(long, help = "Path to a file containing custom release footer text")]
131        release_footer: Option<PathBuf>,
132        #[arg(
133            long,
134            help = "Path to a template file for release footer (rendered with template variables)"
135        )]
136        release_footer_tmpl: Option<PathBuf>,
137        #[arg(
138            long,
139            help = "Path to a template file for release notes (rendered with template variables, overrides --release-notes)"
140        )]
141        release_notes_tmpl: Option<PathBuf>,
142        #[arg(long, help = "Abort immediately on first error during publishing")]
143        fail_fast: bool,
144        #[arg(
145            long = "no-gate-submitter",
146            help = "Disable the Submitter gate: dispatch Submitter publishers even when required Assets/Manager publishers failed"
147        )]
148        no_gate_submitter: bool,
149        #[arg(
150            long = "rollback",
151            value_name = "none|best-effort",
152            help = "Rollback policy after publish stage. Defaults to best-effort when preflight is clean, none otherwise."
153        )]
154        rollback: Option<String>,
155        #[arg(
156            long = "simulate-failure",
157            value_name = "publisher",
158            action = clap::ArgAction::Append,
159            hide = true,
160            help = "(TEST HARNESS) Force a named publisher to fail. Gated by ANODIZE_TEST_HARNESS=1."
161        )]
162        simulate_failure: Vec<String>,
163        #[arg(
164            long = "rollback-only",
165            requires = "from_run",
166            help = "Skip publish; re-attempt rollback from a prior run report. Requires --from-run=<id>."
167        )]
168        rollback_only: bool,
169        #[arg(
170            long = "from-run",
171            value_name = "id",
172            requires = "rollback_only",
173            value_parser = parse_run_id,
174            help = "Prior run id whose state to load when running --rollback-only. \
175                    Loads <dist>/run-<id>/rollback.json if present (a prior replay's state), \
176                    otherwise <dist>/run-<id>/report.json. Delete rollback.json to force a \
177                    full re-roll. Must match the run_id format written by the release pipeline \
178                    (alphanumeric, dot, dash, underscore; no path separators)."
179        )]
180        from_run: Option<String>,
181        #[arg(
182            long = "allow-rerun",
183            conflicts_with = "rollback_only",
184            help = "DANGEROUS: force publish to proceed even when a prior \
185                    dist/run-<id>/report.json exists for this tag. PR-based publishers \
186                    (homebrew, scoop, nix, krew, MCP) will open DUPLICATE pull requests. \
187                    Recover from partial failures with --rollback-only --from-run=<id> first. \
188                    Cannot be combined with --rollback-only (which has its own idempotency)."
189        )]
190        allow_rerun: bool,
191        #[arg(
192            long = "allow-nondeterministic",
193            value_name = "name=reason",
194            action = clap::ArgAction::Append,
195            help = "Runtime non-determinism opt-out for a specific artifact (repeatable). Mutually exclusive with --strict."
196        )]
197        allow_nondeterministic: Vec<String>,
198        #[arg(
199            long = "summary-json",
200            value_name = "path",
201            help = "Write the per-publisher run summary JSON to this path."
202        )]
203        summary_json: Option<PathBuf>,
204        #[arg(
205            long,
206            conflicts_with = "merge",
207            help = "Run only the build stage for split CI fan-out (outputs artifacts JSON to dist/)"
208        )]
209        split: bool,
210        #[arg(
211            long,
212            conflicts_with = "split",
213            help = "Merge artifacts from split build jobs and resume the pipeline from post-build stages"
214        )]
215        merge: bool,
216        #[arg(
217            long = "publish-only",
218            conflicts_with_all = ["split", "merge"],
219            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/."
220        )]
221        publish_only: bool,
222        #[arg(
223            long,
224            help = "Run local build + archive + sign + checksum + sbom stages but skip release / publish / announce (GoReleaser Pro parity). Artifacts stay in dist/ for inspection."
225        )]
226        prepare: bool,
227        #[arg(
228            long,
229            help = "Resume into an existing release left over from a prior failed attempt; bypasses the safety check that bails on partial assets."
230        )]
231        resume_release: bool,
232        #[arg(
233            long,
234            help = "Force release.replace_existing_artifacts: true regardless of config (overwrite conflicting assets on retry)."
235        )]
236        replace_existing: bool,
237        #[arg(
238            long = "no-post-publish-poll",
239            help = "Skip post-publish polling for chocolatey moderation / winget PR validation; report NotPolled for affected publishers."
240        )]
241        no_post_publish_poll: bool,
242    },
243    /// Build binaries only (always runs in snapshot mode)
244    Build {
245        #[arg(long = "crate", action = clap::ArgAction::Append, help = "Build a specific crate (repeatable)")]
246        crate_names: Vec<String>,
247        #[arg(
248            long,
249            default_value = "60m",
250            help = "Pipeline timeout duration (e.g., 60m, 1h, 5s)"
251        )]
252        timeout: String,
253        #[arg(
254            long,
255            short = 'p',
256            help = "Maximum number of parallel build jobs (default: number of CPUs)"
257        )]
258        parallelism: Option<usize>,
259        #[arg(long, help = "Build only for the host target triple")]
260        single_target: bool,
261        #[arg(
262            long,
263            conflicts_with = "crate_names",
264            help = "Build a specific workspace in a monorepo config"
265        )]
266        workspace: Option<String>,
267        #[arg(
268            long,
269            short = 'o',
270            help = "Copy the built binary to this path (requires --single-target and single crate)"
271        )]
272        output: Option<PathBuf>,
273        #[arg(
274            long,
275            value_delimiter = ',',
276            help = "Skip stages (comma-separated: pre-hooks, post-hooks, validate, before)"
277        )]
278        skip: Vec<String>,
279    },
280    /// Validate configuration and run determinism checks
281    Check {
282        #[command(subcommand)]
283        cmd: CheckCmd,
284    },
285    /// Generate starter config
286    Init,
287    /// Generate changelog only
288    Changelog {
289        #[arg(long = "crate", help = "Generate changelog for a specific crate")]
290        crate_name: Option<String>,
291    },
292    /// Generate shell completions
293    Completion {
294        #[arg(value_enum, help = "Shell to generate completions for")]
295        shell: Shell,
296    },
297    /// Check availability of required external tools
298    Healthcheck,
299    /// Generate man pages to stdout
300    Man,
301    /// Output JSON Schema for .anodizer.yaml
302    Jsonschema,
303    /// Resolve a git tag to its matching crate in the config
304    ResolveTag {
305        #[arg(help = "Tag to resolve (e.g. 'v1.2.3', 'core-v0.2.3')")]
306        tag: String,
307        #[arg(long, help = "Output as JSON")]
308        json: bool,
309    },
310    /// Emit the configured build targets as a GitHub Actions matrix.
311    ///
312    /// Derives `{os, target, artifact}` entries from `.anodizer.yaml`.
313    /// Consumed by `anodizer-action`'s `split-matrix` output to feed a
314    /// `strategy.matrix` dynamically (via `fromJson`).
315    Targets {
316        #[arg(long, help = "Output as JSON (include-form matrix)")]
317        json: bool,
318        #[arg(long = "crate", action = clap::ArgAction::Append, help = "Restrict to specific crate(s)")]
319        crate_names: Vec<String>,
320    },
321    /// Auto-tag based on commit message directives
322    Tag {
323        #[arg(long, help = "Show what tag would be created without pushing")]
324        dry_run: bool,
325        #[arg(long, help = "Override bump logic with a specific tag value")]
326        custom_tag: Option<String>,
327        #[arg(long, help = "Override default bump type (patch/minor/major)")]
328        default_bump: Option<String>,
329        #[arg(long = "crate", help = "Tag a specific crate in a workspace")]
330        crate_name: Option<String>,
331    },
332    /// Resume a release after a transient failure or after `--prepare`/`--split`
333    ///
334    /// With `--merge`: load every per-target `context.json` under `dist/` (one
335    /// per split-build worker) and run the full post-build pipeline
336    /// (sign / checksum / sbom / release / publish / announce).
337    ///
338    /// Without `--merge`: load existing `dist/` artifacts and run the
339    /// publish-only pipeline (release / publish / blob). Use this to resume
340    /// a single-host release that stalled during publish (e.g. expired
341    /// token, transient 5xx) without rebuilding.
342    Continue {
343        #[arg(
344            long,
345            help = "Merge artifacts from split build jobs and run post-build stages"
346        )]
347        merge: bool,
348        #[arg(long, help = "Custom dist directory (overrides config)")]
349        dist: Option<PathBuf>,
350        #[arg(long, help = "Run full pipeline without side effects")]
351        dry_run: bool,
352        #[arg(
353            long,
354            value_delimiter = ',',
355            help = "Skip stages (comma-separated, e.g. docker,announce)"
356        )]
357        skip: Vec<String>,
358        #[arg(
359            long,
360            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
361        )]
362        token: Option<String>,
363    },
364    /// Run only the publish stages (release, publish, blob) from a completed dist/
365    Publish {
366        #[arg(long, help = "Run full pipeline without side effects")]
367        dry_run: bool,
368        #[arg(
369            long,
370            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
371        )]
372        token: Option<String>,
373        #[arg(long, help = "Custom dist directory (overrides config)")]
374        dist: Option<PathBuf>,
375    },
376    /// Bump crate versions (Conventional Commits → semver level)
377    ///
378    /// Infers the per-crate level from commits since each crate's last tag
379    /// when no positional argument is given. `patch|minor|major`, an explicit
380    /// version, or `release` (strip prerelease) are also accepted.
381    Bump {
382        #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
383        level_or_version: Option<String>,
384        #[arg(
385            long,
386            short = 'p',
387            visible_alias = "crate",
388            action = clap::ArgAction::Append,
389            help = "Bump a specific crate (repeatable)"
390        )]
391        package: Vec<String>,
392        #[arg(
393            long,
394            alias = "all",
395            conflicts_with = "package",
396            help = "Bump every workspace member (excluding publish=false)"
397        )]
398        workspace: bool,
399        #[arg(
400            long,
401            action = clap::ArgAction::Append,
402            help = "Exclude a crate from --workspace (repeatable)"
403        )]
404        exclude: Vec<String>,
405        #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
406        pre: Option<String>,
407        #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
408        exact: bool,
409        #[arg(
410            long,
411            help = "Proceed even if the working tree has uncommitted changes"
412        )]
413        allow_dirty: bool,
414        #[arg(long, short = 'y', help = "Skip confirmation prompt")]
415        yes: bool,
416        #[arg(long, help = "Print the plan without editing any files")]
417        dry_run: bool,
418        #[arg(long, help = "Stage edits and create a single commit")]
419        commit: bool,
420        #[arg(
421            long,
422            requires = "commit",
423            help = "GPG-sign the commit (requires --commit)"
424        )]
425        sign: bool,
426        #[arg(long, help = "Override the default commit message template")]
427        commit_message: Option<String>,
428        #[arg(
429            long,
430            default_value = "text",
431            help = "Output format: text | json (json requires --dry-run)"
432        )]
433        output: String,
434    },
435    /// Run only the announce stage from a completed dist/
436    Announce {
437        #[arg(long, help = "Run full pipeline without side effects")]
438        dry_run: bool,
439        #[arg(long, help = "Custom dist directory (overrides config)")]
440        dist: Option<PathBuf>,
441        #[arg(
442            long,
443            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
444        )]
445        token: Option<String>,
446        #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
447        skip: Vec<String>,
448    },
449}
450
451/// `anodize check` parent subcommand.
452///
453/// `Config` is the historic `check` body (validate `.anodizer.yaml`); the
454/// determinism harness is plumbed here so the flag set ships with this
455/// commit, but the body lands in a follow-up task.
456#[derive(Subcommand)]
457pub enum CheckCmd {
458    /// Validate the workspace's anodize config.
459    Config {
460        #[arg(long, help = "Validate a specific workspace in a monorepo config")]
461        workspace: Option<String>,
462    },
463    /// Run the determinism harness (build pipeline twice, diff artifacts).
464    Determinism(CheckDeterminismArgs),
465}
466
467#[derive(clap::Args)]
468pub struct CheckDeterminismArgs {
469    #[arg(
470        long,
471        default_value = "2",
472        help = "Number of from-clean rebuilds to diff"
473    )]
474    pub runs: u32,
475    #[arg(
476        long,
477        value_name = "stages",
478        help = "Optional stage subset (build,archive,sbom,sign,checksum)"
479    )]
480    pub stages: Option<String>,
481    #[arg(
482        long,
483        value_name = "csv",
484        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."
485    )]
486    pub targets: Option<String>,
487    #[arg(
488        long,
489        value_name = "path",
490        help = "JSON report path; default dist/run-<id>/determinism.json"
491    )]
492    pub report: Option<PathBuf>,
493    #[arg(
494        long,
495        conflicts_with = "no_snapshot",
496        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."
497    )]
498    pub snapshot: bool,
499    #[arg(
500        long = "no-snapshot",
501        conflicts_with = "snapshot",
502        help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
503    )]
504    pub no_snapshot: bool,
505    #[arg(
506        long = "inject-drift",
507        value_name = "stage",
508        hide = true,
509        help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
510    )]
511    pub inject_drift: Option<String>,
512    #[arg(
513        long = "preserve-dist",
514        value_name = "path",
515        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."
516    )]
517    pub preserve_dist: Option<PathBuf>,
518}
519
520/// Clap `value_parser` for `--from-run=<id>`.
521///
522/// `run_id` is operator-controlled and is joined directly into a
523/// filesystem path (`<dist>/run-<id>/{report,rollback}.json`) by the
524/// `--rollback-only` replay code. Without this validator,
525/// `--from-run=../../etc/passwd` would resolve to a traversed path on
526/// both read (`report.json`) and write (`rollback.json`) — operator
527/// data-loss potential.
528///
529/// Delegates to [`anodizer_stage_publish::rollback_only::validate_run_id`]
530/// so the rule has a single source of truth (the same validator runs at
531/// the `run_with_publishers` entry point as a defense-in-depth guard).
532fn parse_run_id(s: &str) -> Result<String, String> {
533    anodizer_stage_publish::rollback_only::validate_run_id(s)
534        .map(|()| s.to_string())
535        .map_err(|err| format!("{:#}", err))
536}
537
538/// Detect the host target triple by parsing `rustc -vV` output.
539/// Delegates to `anodizer_core::partial::detect_host_target()`.
540pub fn detect_host_target() -> anyhow::Result<String> {
541    anodizer_core::partial::detect_host_target()
542}
543
544/// Return a sensible default parallelism value (number of logical CPUs, minimum 1).
545pub fn num_cpus() -> usize {
546    std::thread::available_parallelism()
547        .map(|n| n.get())
548        .unwrap_or(4)
549}
550
551/// Build the clap `Command` tree for CLI introspection.
552pub fn build_cli() -> clap::Command {
553    <Cli as clap::CommandFactory>::command()
554}