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)]
37pub enum Commands {
38    /// Run the full release pipeline
39    Release {
40        #[arg(long = "crate", visible_alias = "id", action = clap::ArgAction::Append, help = "Release a specific crate (repeatable; --id is accepted as a GoReleaser-compat alias)")]
41        crate_names: Vec<String>,
42        #[arg(long, help = "Release all crates with unreleased changes")]
43        all: bool,
44        #[arg(long, help = "Force release even without unreleased changes")]
45        force: bool,
46        #[arg(long, help = "Build without publishing (snapshot mode)")]
47        snapshot: bool,
48        #[arg(long, help = "Create a nightly release with date-based version")]
49        nightly: bool,
50        #[arg(long, help = "Run full pipeline without side effects")]
51        dry_run: bool,
52        #[arg(long, help = "Remove dist directory before starting")]
53        clean: bool,
54        #[arg(
55            long,
56            value_delimiter = ',',
57            help = "Skip stages (comma-separated, e.g. docker,announce)"
58        )]
59        skip: Vec<String>,
60        #[arg(
61            long,
62            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
63        )]
64        token: Option<String>,
65        #[arg(
66            long,
67            default_value = "60m",
68            help = "Pipeline timeout duration (e.g., 60m, 1h, 5s)"
69        )]
70        timeout: String,
71        #[arg(
72            long,
73            short = 'p',
74            help = "Maximum number of parallel build jobs (default: number of CPUs)"
75        )]
76        parallelism: Option<usize>,
77        #[arg(long, help = "Automatically set --snapshot if the git repo is dirty")]
78        auto_snapshot: bool,
79        #[arg(long, help = "Build only for the host target triple")]
80        single_target: bool,
81        #[arg(
82            long,
83            help = "Path to a custom release notes file (overrides changelog)"
84        )]
85        release_notes: Option<PathBuf>,
86        #[arg(
87            long,
88            conflicts_with = "crate_names",
89            help = "Release a specific workspace in a monorepo config"
90        )]
91        workspace: Option<String>,
92        #[arg(long, help = "Set the release as a draft")]
93        draft: bool,
94        #[arg(long, help = "Path to a file containing custom release header text")]
95        release_header: Option<PathBuf>,
96        #[arg(
97            long,
98            help = "Path to a template file for release header (rendered with template variables)"
99        )]
100        release_header_tmpl: Option<PathBuf>,
101        #[arg(long, help = "Path to a file containing custom release footer text")]
102        release_footer: Option<PathBuf>,
103        #[arg(
104            long,
105            help = "Path to a template file for release footer (rendered with template variables)"
106        )]
107        release_footer_tmpl: Option<PathBuf>,
108        #[arg(
109            long,
110            help = "Path to a template file for release notes (rendered with template variables, overrides --release-notes)"
111        )]
112        release_notes_tmpl: Option<PathBuf>,
113        #[arg(long, help = "Abort immediately on first error during publishing")]
114        fail_fast: bool,
115        #[arg(
116            long,
117            conflicts_with = "merge",
118            help = "Run only the build stage for split CI fan-out (outputs artifacts JSON to dist/)"
119        )]
120        split: bool,
121        #[arg(
122            long,
123            conflicts_with = "split",
124            help = "Merge artifacts from split build jobs and resume the pipeline from post-build stages"
125        )]
126        merge: bool,
127        #[arg(
128            long,
129            help = "Run local build + archive + sign + checksum + sbom stages but skip release / publish / announce (GoReleaser Pro parity). Artifacts stay in dist/ for inspection."
130        )]
131        prepare: bool,
132    },
133    /// Build binaries only (always runs in snapshot mode)
134    Build {
135        #[arg(long = "crate", action = clap::ArgAction::Append, help = "Build a specific crate (repeatable)")]
136        crate_names: Vec<String>,
137        #[arg(
138            long,
139            default_value = "60m",
140            help = "Pipeline timeout duration (e.g., 60m, 1h, 5s)"
141        )]
142        timeout: String,
143        #[arg(
144            long,
145            short = 'p',
146            help = "Maximum number of parallel build jobs (default: number of CPUs)"
147        )]
148        parallelism: Option<usize>,
149        #[arg(long, help = "Build only for the host target triple")]
150        single_target: bool,
151        #[arg(
152            long,
153            conflicts_with = "crate_names",
154            help = "Build a specific workspace in a monorepo config"
155        )]
156        workspace: Option<String>,
157        #[arg(
158            long,
159            short = 'o',
160            help = "Copy the built binary to this path (requires --single-target and single crate)"
161        )]
162        output: Option<PathBuf>,
163        #[arg(
164            long,
165            value_delimiter = ',',
166            help = "Skip stages (comma-separated: pre-hooks, post-hooks, validate, before)"
167        )]
168        skip: Vec<String>,
169    },
170    /// Validate configuration
171    Check {
172        #[arg(long, help = "Validate a specific workspace in a monorepo config")]
173        workspace: Option<String>,
174    },
175    /// Generate starter config
176    Init,
177    /// Generate changelog only
178    Changelog {
179        #[arg(long = "crate", help = "Generate changelog for a specific crate")]
180        crate_name: Option<String>,
181    },
182    /// Generate shell completions
183    Completion {
184        #[arg(value_enum, help = "Shell to generate completions for")]
185        shell: Shell,
186    },
187    /// Check availability of required external tools
188    Healthcheck,
189    /// Generate man pages to stdout
190    Man,
191    /// Output JSON Schema for .anodizer.yaml
192    Jsonschema,
193    /// Resolve a git tag to its matching crate in the config
194    ResolveTag {
195        #[arg(help = "Tag to resolve (e.g. 'v1.2.3', 'core-v0.2.3')")]
196        tag: String,
197        #[arg(long, help = "Output as JSON")]
198        json: bool,
199    },
200    /// Emit the configured build targets as a GitHub Actions matrix.
201    ///
202    /// Derives `{os, target, artifact}` entries from `.anodizer.yaml`. Used by
203    /// `tj-smith47/anodizer-action`'s `split-matrix` output to feed a
204    /// `strategy.matrix` dynamically (via `fromJson`).
205    Targets {
206        #[arg(long, help = "Output as JSON (include-form matrix)")]
207        json: bool,
208        #[arg(long = "crate", action = clap::ArgAction::Append, help = "Restrict to specific crate(s)")]
209        crate_names: Vec<String>,
210    },
211    /// Auto-tag based on commit message directives
212    Tag {
213        #[arg(long, help = "Show what tag would be created without pushing")]
214        dry_run: bool,
215        #[arg(long, help = "Override bump logic with a specific tag value")]
216        custom_tag: Option<String>,
217        #[arg(long, help = "Override default bump type (patch/minor/major)")]
218        default_bump: Option<String>,
219        #[arg(long = "crate", help = "Tag a specific crate in a workspace")]
220        crate_name: Option<String>,
221    },
222    /// Resume a release after a transient failure or after `--prepare`/`--split`
223    ///
224    /// With `--merge`: load every per-target `context.json` under `dist/` (one
225    /// per split-build worker) and run the full post-build pipeline
226    /// (sign / checksum / sbom / release / publish / announce).
227    ///
228    /// Without `--merge`: load existing `dist/` artifacts and run the
229    /// publish-only pipeline (release / publish / blob). Use this to resume
230    /// a single-host release that stalled during publish (e.g. expired
231    /// token, transient 5xx) without rebuilding.
232    Continue {
233        #[arg(
234            long,
235            help = "Merge artifacts from split build jobs and run post-build stages"
236        )]
237        merge: bool,
238        #[arg(long, help = "Custom dist directory (overrides config)")]
239        dist: Option<PathBuf>,
240        #[arg(long, help = "Run full pipeline without side effects")]
241        dry_run: bool,
242        #[arg(
243            long,
244            value_delimiter = ',',
245            help = "Skip stages (comma-separated, e.g. docker,announce)"
246        )]
247        skip: Vec<String>,
248        #[arg(
249            long,
250            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
251        )]
252        token: Option<String>,
253    },
254    /// Run only the publish stages (release, publish, blob) from a completed dist/
255    Publish {
256        #[arg(long, help = "Run full pipeline without side effects")]
257        dry_run: bool,
258        #[arg(
259            long,
260            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
261        )]
262        token: Option<String>,
263        #[arg(long, help = "Custom dist directory (overrides config)")]
264        dist: Option<PathBuf>,
265    },
266    /// Bump crate versions (Conventional Commits → semver level)
267    ///
268    /// Infers the per-crate level from commits since each crate's last tag
269    /// when no positional argument is given. `patch|minor|major`, an explicit
270    /// version, or `release` (strip prerelease) are also accepted.
271    Bump {
272        #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
273        level_or_version: Option<String>,
274        #[arg(
275            long,
276            short = 'p',
277            visible_alias = "crate",
278            action = clap::ArgAction::Append,
279            help = "Bump a specific crate (repeatable)"
280        )]
281        package: Vec<String>,
282        #[arg(
283            long,
284            alias = "all",
285            conflicts_with = "package",
286            help = "Bump every workspace member (excluding publish=false)"
287        )]
288        workspace: bool,
289        #[arg(
290            long,
291            action = clap::ArgAction::Append,
292            help = "Exclude a crate from --workspace (repeatable)"
293        )]
294        exclude: Vec<String>,
295        #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
296        pre: Option<String>,
297        #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
298        exact: bool,
299        #[arg(
300            long,
301            help = "Proceed even if the working tree has uncommitted changes"
302        )]
303        allow_dirty: bool,
304        #[arg(long, short = 'y', help = "Skip confirmation prompt")]
305        yes: bool,
306        #[arg(long, help = "Print the plan without editing any files")]
307        dry_run: bool,
308        #[arg(long, help = "Stage edits and create a single commit")]
309        commit: bool,
310        #[arg(
311            long,
312            requires = "commit",
313            help = "GPG-sign the commit (requires --commit)"
314        )]
315        sign: bool,
316        #[arg(long, help = "Override the default commit message template")]
317        commit_message: Option<String>,
318        #[arg(
319            long,
320            default_value = "text",
321            help = "Output format: text | json (json requires --dry-run)"
322        )]
323        output: String,
324    },
325    /// Run only the announce stage from a completed dist/
326    Announce {
327        #[arg(long, help = "Run full pipeline without side effects")]
328        dry_run: bool,
329        #[arg(long, help = "Custom dist directory (overrides config)")]
330        dist: Option<PathBuf>,
331        #[arg(
332            long,
333            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
334        )]
335        token: Option<String>,
336        #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
337        skip: Vec<String>,
338    },
339}
340
341/// Detect the host target triple by parsing `rustc -vV` output.
342/// Delegates to `anodizer_core::partial::detect_host_target()`.
343pub fn detect_host_target() -> anyhow::Result<String> {
344    anodizer_core::partial::detect_host_target()
345}
346
347/// Return a sensible default parallelism value (number of logical CPUs, minimum 1).
348pub fn num_cpus() -> usize {
349    std::thread::available_parallelism()
350        .map(|n| n.get())
351        .unwrap_or(4)
352}
353
354/// Build the clap `Command` tree for CLI introspection.
355pub fn build_cli() -> clap::Command {
356    <Cli as clap::CommandFactory>::command()
357}