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 #[command(subcommand)]
33 pub command: Option<Commands>,
34}
35
36#[derive(Subcommand)]
37#[allow(clippy::large_enum_variant)]
42pub enum Commands {
43 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 {
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 Check {
282 #[command(subcommand)]
283 cmd: CheckCmd,
284 },
285 Init,
287 Changelog {
289 #[arg(long = "crate", help = "Generate changelog for a specific crate")]
290 crate_name: Option<String>,
291 },
292 Completion {
294 #[arg(value_enum, help = "Shell to generate completions for")]
295 shell: Shell,
296 },
297 Healthcheck,
299 Man,
301 Jsonschema,
303 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 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 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 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 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 {
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 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#[derive(Subcommand)]
457pub enum CheckCmd {
458 Config {
460 #[arg(long, help = "Validate a specific workspace in a monorepo config")]
461 workspace: Option<String>,
462 },
463 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
520fn 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
538pub fn detect_host_target() -> anyhow::Result<String> {
541 anodizer_core::partial::detect_host_target()
542}
543
544pub fn num_cpus() -> usize {
546 std::thread::available_parallelism()
547 .map(|n| n.get())
548 .unwrap_or(4)
549}
550
551pub fn build_cli() -> clap::Command {
553 <Cli as clap::CommandFactory>::command()
554}