Skip to main content

bougie_cli/
lib.rs

1use clap::builder::styling::{AnsiColor, Effects, Styles};
2use clap::{Args, Parser, Subcommand};
3use std::ffi::OsString;
4
5/// Full version string, uv-style: `0.6.4 (63c5f57d3 2026-05-08 <target>)`.
6///
7/// Built by `build.rs`; degrades to the bare crate version when git metadata is
8/// unavailable. clap prefixes the binary name, so `--version` prints
9/// `bougie 0.6.4 (...)`.
10pub const LONG_VERSION: &str = env!("BOUGIE_LONG_VERSION");
11
12const HELP_STYLES: Styles = Styles::styled()
13    .header(AnsiColor::Blue.on_default().effects(Effects::BOLD))
14    .usage(AnsiColor::Magenta.on_default().effects(Effects::BOLD))
15    .literal(AnsiColor::BrightMagenta.on_default().effects(Effects::BOLD))
16    .placeholder(AnsiColor::BrightMagenta.on_default())
17    .error(AnsiColor::Red.on_default().effects(Effects::BOLD))
18    .valid(AnsiColor::Green.on_default())
19    .invalid(AnsiColor::Yellow.on_default().effects(Effects::BOLD));
20
21/// Grouped quick-reference appended to `bougie --help`. clap renders all
22/// subcommands in one flat "Commands:" list (it has no headed-group
23/// support), and `display_order` clusters that list into these same
24/// groups — this cheat-sheet just names the groups so the core verbs are
25/// findable at a glance.
26const COMMAND_GROUPS: &str = "\
27Command groups:
28  Project      init, new, start, stop, run, sync, make, format
29  Dependencies add, remove, lock, tree, outdated, ext, composer
30  Toolchain    php, node, tool
31  Services     server, services, projects
32  Admin        cache, self
33
34Run `bougie help <command>` for details on any command.";
35
36#[derive(Parser, Debug)]
37#[command(
38    name = "bougie",
39    version = LONG_VERSION,
40    about,
41    long_about = None,
42    styles = HELP_STYLES,
43    after_long_help = COMMAND_GROUPS,
44)]
45pub struct Cli {
46    #[command(subcommand)]
47    pub command: Command,
48
49    /// Suppress non-error output.
50    #[arg(short, long, global = true)]
51    pub quiet: bool,
52
53    /// Verbose output.
54    #[arg(short, long, global = true)]
55    pub verbose: bool,
56
57    /// Output format.
58    #[arg(long, global = true, default_value = "text")]
59    pub format: OutputFormat,
60}
61
62/// Shared PHP-source preference flags (uv's system-Python model adapted
63/// to PHP). Flattened into `sync` / `run`; `--managed-php` and
64/// `--no-managed-php` are mutually exclusive. With none set, bougie's
65/// default applies: prefer an installed managed PHP, then a qualifying
66/// system PHP, then download a managed one.
67#[derive(Args, Debug, Clone, Copy, Default)]
68pub struct PhpPrefArgs {
69    /// Only use a bougie-managed PHP; never a system PHP.
70    #[arg(long, conflicts_with = "no_managed_php")]
71    pub managed_php: bool,
72    /// Only use a system PHP already on this machine; never a managed one.
73    #[arg(long)]
74    pub no_managed_php: bool,
75    /// Never download a managed PHP — use an installed managed PHP or a
76    /// system one. Errors if neither is present.
77    #[arg(long)]
78    pub no_php_downloads: bool,
79}
80
81#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
82pub enum OutputFormat {
83    Text,
84    /// `json-v1` is bougie's structured envelope; `json` is accepted as
85    /// an alias so Composer-compatible subcommands (`composer show
86    /// --format json`, etc.) work with the same global flag.
87    #[value(name = "json-v1", alias = "json")]
88    JsonV1,
89}
90
91#[derive(Subcommand, Debug)]
92pub enum Command {
93    /// Create a new project.
94    #[command(display_order = 1)]
95    Init {
96        /// Place bougie configuration in a bougie.toml file.
97        #[arg(long)]
98        toml: bool,
99        /// Set the package name (`vendor/package`) of the generated
100        /// composer.json. Overrides the name from a `--starter` manifest.
101        #[arg(long, value_name = "VENDOR/PACKAGE")]
102        name: Option<String>,
103        /// Scaffold from a starter pack: a built-in alias (e.g. `mageos`)
104        /// or an https URL serving a starter manifest. Writes the
105        /// starter's composer.json instead of the empty default.
106        #[arg(long, value_name = "URL_OR_ALIAS")]
107        starter: Option<String>,
108        /// After scaffolding, bring the project up — equivalent to
109        /// `bougie start` (sync the toolchain + vendor, then run the
110        /// project recipe). Unix-only.
111        #[arg(long)]
112        start: bool,
113    },
114
115    /// Create a new project in a new directory.
116    #[command(display_order = 2)]
117    New {
118        /// Directory to create under the current directory and scaffold
119        /// the project into.
120        #[arg(value_name = "DIRECTORY")]
121        directory: String,
122        /// Place bougie configuration in a bougie.toml file.
123        #[arg(long)]
124        toml: bool,
125        /// Set the package name (`vendor/package`) of the generated
126        /// composer.json. Overrides the name from a `--starter` manifest.
127        #[arg(long, value_name = "VENDOR/PACKAGE")]
128        name: Option<String>,
129        /// Scaffold from a starter pack: a built-in alias (e.g. `mageos`)
130        /// or an https URL serving a starter manifest.
131        #[arg(long, value_name = "URL_OR_ALIAS")]
132        starter: Option<String>,
133        /// After scaffolding, bring the project up — equivalent to
134        /// `bougie start`. Unix-only.
135        #[arg(long)]
136        start: bool,
137    },
138
139    /// Manage PHP extensions.
140    #[command(subcommand, display_order = 15)]
141    Ext(ExtCommand),
142
143    /// Add one or more packages to the project and sync. The uv-flavored
144    /// twin of `composer require`: a bare `vendor/pkg` writes a `>=X.Y`
145    /// lower bound (vs `composer require`'s caret `^X.Y`), and an
146    /// explicit constraint uses the `@` syntax (`vendor/pkg@^1.0`), as in
147    /// `bougie tool install` / `bougie ext add`. Edits `composer.json`,
148    /// re-resolves `composer.lock`, and installs into `vendor/`.
149    #[command(display_order = 10)]
150    Add {
151        /// Packages to add, `vendor/pkg` or `vendor/pkg@<constraint>`.
152        #[arg(value_name = "PACKAGES", required = true)]
153        packages: Vec<String>,
154        /// Add to `require-dev` instead of `require`.
155        #[arg(long = "dev")]
156        dev: bool,
157        /// Also update the new packages' dependencies (`-w`).
158        #[arg(short = 'w', long = "with-dependencies")]
159        with_dependencies: bool,
160        /// Also update all dependencies, including shared ones (`-W`).
161        #[arg(short = 'W', long = "with-all-dependencies")]
162        with_all_dependencies: bool,
163        /// Update `composer.json` + `composer.lock` but don't install
164        /// into `vendor/`.
165        #[arg(long = "no-sync")]
166        no_sync: bool,
167        /// Edit `composer.json` only — don't touch the lock or `vendor/`.
168        #[arg(long = "frozen")]
169        frozen: bool,
170        /// Run in this directory instead of CWD (`-d`).
171        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
172        working_dir: Option<std::path::PathBuf>,
173        /// Resolve and report what would change without writing anything.
174        #[arg(long = "dry-run")]
175        dry_run: bool,
176    },
177
178    /// Remove one or more packages from the project and sync. The
179    /// uv-flavored twin of `composer remove`.
180    #[command(display_order = 11)]
181    Remove {
182        /// Packages to remove (`vendor/name`).
183        #[arg(value_name = "PACKAGES", required = true)]
184        packages: Vec<String>,
185        /// Remove from `require-dev` instead of `require`.
186        #[arg(long = "dev")]
187        dev: bool,
188        /// Re-resolve `composer.lock` but don't touch `vendor/`.
189        #[arg(long = "no-sync")]
190        no_sync: bool,
191        /// Edit `composer.json` only — don't touch the lock or `vendor/`.
192        #[arg(long = "frozen")]
193        frozen: bool,
194        /// Run in this directory instead of CWD (`-d`).
195        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
196        working_dir: Option<std::path::PathBuf>,
197        /// Resolve and report what would change without writing anything.
198        #[arg(long = "dry-run")]
199        dry_run: bool,
200    },
201
202    /// Refresh `composer.lock` to match `composer.json` (native; uv's
203    /// `uv lock`). Minimal: keeps every package at its locked version
204    /// where still valid, re-resolving only what changed. Never bumps
205    /// versions and never installs — use `bougie composer update` to pull
206    /// newer versions.
207    #[command(display_order = 12)]
208    Lock {
209        /// Run in this directory instead of CWD (`-d`).
210        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
211        working_dir: Option<std::path::PathBuf>,
212        /// Resolve and report what would change without writing the lock.
213        #[arg(long = "dry-run")]
214        dry_run: bool,
215    },
216
217    /// Print the project's dependency tree (native; uv's `uv tree`).
218    /// Reads `composer.lock`.
219    #[command(display_order = 13)]
220    Tree {
221        /// Root the tree at this package instead of the project.
222        #[arg(value_name = "PACKAGE")]
223        package: Option<String>,
224        /// Skip dev dependencies.
225        #[arg(long = "no-dev")]
226        no_dev: bool,
227        /// Run in this directory instead of CWD (`-d`).
228        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
229        working_dir: Option<std::path::PathBuf>,
230    },
231
232    /// List installed packages with a newer version available (native;
233    /// like `uv`/`pnpm outdated`). Reads `composer.lock` and queries the
234    /// configured repositories.
235    #[command(display_order = 14)]
236    Outdated {
237        /// Optional `vendor/name` filters; with none, all are considered.
238        #[arg(value_name = "PACKAGES")]
239        packages: Vec<String>,
240        /// Only the project's direct dependencies (`--direct` / `-D`).
241        #[arg(short = 'D', long = "direct")]
242        direct: bool,
243        /// Only packages with a new major version.
244        #[arg(long = "major-only")]
245        major_only: bool,
246        /// Only packages with a new minor version.
247        #[arg(long = "minor-only")]
248        minor_only: bool,
249        /// Only packages with a new patch version.
250        #[arg(long = "patch-only")]
251        patch_only: bool,
252        /// Skip dev dependencies.
253        #[arg(long = "no-dev")]
254        no_dev: bool,
255        /// Exit non-zero if any package is outdated.
256        #[arg(long = "strict")]
257        strict: bool,
258        /// Run in this directory instead of CWD (`-d`).
259        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
260        working_dir: Option<std::path::PathBuf>,
261    },
262
263    /// Install everything the project requires.
264    #[command(display_order = 6)]
265    Sync {
266        /// Don't try to download anything, this will fail if there are uncached packages.
267        #[arg(long)]
268        offline: bool,
269        /// Show the plan, change nothing on disk.
270        #[arg(long)]
271        dry_run: bool,
272        /// Run composer.json root scripts for this sync, overriding
273        /// `[scripts] run` in bougie.toml. Off by default (opt-in).
274        #[arg(long, conflicts_with = "no_scripts")]
275        scripts: bool,
276        /// Skip composer.json root scripts for this sync, overriding
277        /// `[scripts] run = true` in bougie.toml.
278        #[arg(long = "no-scripts")]
279        no_scripts: bool,
280        #[command(flatten)]
281        php: PhpPrefArgs,
282    },
283
284    /// Run a command in the project environment.
285    #[command(display_order = 5)]
286    Run {
287        /// Add a temporary extension for this invocation.
288        #[arg(long, value_name = "EXT=VER")]
289        with: Vec<String>,
290        /// Skip the implicit `bougie sync` before running.
291        #[arg(long)]
292        no_sync: bool,
293        /// Layer the server's debug overlay (`vendor/bougie/conf.d-debug/`)
294        /// into `PHP_INI_SCAN_DIR` and set `XDEBUG_SESSION=1` for the
295        /// child. Installs xdebug on first use if not already present.
296        #[arg(long)]
297        xdebug: bool,
298        /// Run with a specific PHP interpreter. Accepts a version
299        /// (`8.3`, `8.3.12`), a constraint (`~8.3`, `>=8.2,<8.4`), or a
300        /// path to a `php` binary. Forces a sync to that interpreter,
301        /// so it can't be combined with `--no-sync`. Mirrors
302        /// `uv run --python`.
303        #[arg(long = "php", value_name = "VER|PATH", conflicts_with = "no_sync")]
304        php_request: Option<String>,
305        #[command(flatten)]
306        php: PhpPrefArgs,
307        /// Command and arguments. `--` separator is optional.
308        #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
309        argv: Vec<String>,
310    },
311
312    /// Manage PHP interpreters.
313    #[command(subcommand, display_order = 20)]
314    Php(PhpCommand),
315
316    /// Manage Node.js interpreters (for projects that build frontend
317    /// assets — Vite, Laravel Mix, Magento static-content deploy).
318    #[command(subcommand, display_order = 21)]
319    Node(NodeCommand),
320
321    /// Run Composer, reimplemented natively. bougie does not bundle or
322    /// execute the Composer phar; the common Composer surface
323    /// (install/update/require/remove/show/why/why-not/outdated/audit/
324    /// licenses/fund/status/validate/dump-autoload) runs natively, and an
325    /// unrecognized subcommand errors with a pointer to
326    /// `bougie tool install composer/composer` for the full upstream
327    /// Composer.
328    #[command(subcommand, display_order = 16)]
329    Composer(ComposerCommand),
330
331    /// Manage globally-installed, isolated PHP CLI tools. See
332    /// `TOOL_PLAN.md` for the design.
333    #[command(subcommand, display_order = 22)]
334    Tool(ToolCommand),
335
336    /// Runtime shim invoked by tool wrappers (`#!.../bougie tool-exec`).
337    /// Not for direct CLI use; hidden from `--help`.
338    #[command(hide = true, name = "tool-exec")]
339    ToolExec {
340        /// Path to the tool wrapper script the kernel handed us as
341        /// argv[1] via the shebang.
342        wrapper: std::path::PathBuf,
343        /// User-supplied arguments to the tool, passed through to PHP.
344        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
345        args: Vec<std::ffi::OsString>,
346    },
347
348    /// Manage bougie's cache.
349    #[command(subcommand, display_order = 40)]
350    Cache(CacheCommand),
351
352    /// Manage the bougie binary itself.
353    #[command(subcommand)]
354    #[command(name = "self", display_order = 41)]
355    SelfCmd(SelfCommand),
356
357    /// Run the bougie development HTTP server for the current project.
358    /// With no subcommand, registers the project with the shared dev
359    /// server, prints its URL, and streams its log (Ctrl-C detaches).
360    /// See SERVER.md.
361    #[command(display_order = 30)]
362    Server(ServerArgs),
363
364    /// Manage project-scoped dev services (mariadb, redis, …). See
365    /// SERVICES.md and CLI.md §3.8.
366    #[command(subcommand, display_order = 31)]
367    Services(ServicesCommand),
368
369    /// Inspect and manage provisioned tenants across the shared dev
370    /// services and the project each belongs to. Reads the on-disk
371    /// tenant ledgers; no daemon required.
372    #[command(subcommand, display_order = 32)]
373    Projects(ProjectsCommand),
374
375    /// Bring the whole project up: run the detected recipe's `start`
376    /// task, whose DAG syncs the toolchain + vendor, provisions and
377    /// starts the project's services, runs any setup, and starts the
378    /// dev server. The project lifecycle umbrella (ddev's `start`).
379    /// For an individual task use `bougie make <task>`. Unix-only.
380    #[command(display_order = 3)]
381    Start {
382        /// Skip the implicit `bougie sync` prologue.
383        #[arg(long)]
384        no_sync: bool,
385        /// Show what would run, but don't execute.
386        #[arg(long)]
387        dry_run: bool,
388        /// Explain why each step runs or skips.
389        #[arg(long)]
390        explain: bool,
391        /// Ignore the builtin recipe; use only `bougie.toml`.
392        #[arg(long)]
393        no_builtin: bool,
394        /// Force a specific builtin (e.g. `magento`).
395        #[arg(long, value_name = "NAME")]
396        recipe: Option<String>,
397    },
398
399    /// Bring the project down: stop its declared services (or every
400    /// service in `names`), including the dev-server tenant when the
401    /// `server` service is declared. The shared daemon and any other
402    /// project's tenants stay up. The teardown twin of `bougie start`.
403    /// Unix-only.
404    #[command(display_order = 4)]
405    Stop {
406        /// Service names to stop. Empty = every declared service.
407        names: Vec<String>,
408        /// Destroy persisted tenant data (e.g. FLUSHDB on redis). Off
409        /// by default — `bougie start` should restore state.
410        #[arg(long)]
411        purge: bool,
412    },
413
414    /// Walk a project recipe's DAG, running tasks whose freshness
415    /// check fails. With no task, lists the available tasks; use
416    /// `bougie start` to bring the whole project up. See RECIPES.md.
417    #[command(display_order = 7)]
418    Make {
419        /// Task to run. With none, the available tasks are listed.
420        task: Option<String>,
421        /// List available tasks instead of running.
422        #[arg(long, conflicts_with_all = ["dry_run", "explain", "print"])]
423        list: bool,
424        /// Show what would run, but don't execute.
425        #[arg(long)]
426        dry_run: bool,
427        /// Explain why each step runs or skips.
428        #[arg(long)]
429        explain: bool,
430        /// Skip the implicit `bougie sync` prologue.
431        #[arg(long)]
432        no_sync: bool,
433        /// Ignore the builtin recipe; use only `bougie.toml`.
434        #[arg(long)]
435        no_builtin: bool,
436        /// Force a specific builtin (e.g. `magento`).
437        #[arg(long, value_name = "NAME")]
438        recipe: Option<String>,
439        /// Print the merged recipe to stdout instead of running.
440        #[arg(long)]
441        print: bool,
442    },
443
444    /// Format the project's PHP, the way `uv format` runs ruff.
445    ///
446    /// bougie does not bundle a formatter: on first use it downloads a
447    /// pinned `wick` binary (cresset-tools/wick — an unconfigurable,
448    /// Laravel Pint-style formatter), caches it, and execs it. Every
449    /// argument is forwarded verbatim to `wick`, so `bougie format`,
450    /// `bougie format --check`, `bougie format src/ --diff`, and
451    /// `… | bougie format -` behave exactly like the matching `wick`
452    /// invocation. Pin a specific wick with `BOUGIE_WICK_VERSION`.
453    #[command(display_order = 8)]
454    Format {
455        /// Arguments forwarded verbatim to `wick` (paths, `--check`,
456        /// `--diff`, `-` for stdin, …).
457        #[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "ARGS")]
458        args: Vec<std::ffi::OsString>,
459    },
460}
461
462#[derive(Subcommand, Debug)]
463pub enum ServicesCommand {
464    /// Start the project's declared services (or every service in
465    /// `names`) and provision the project's tenant in each. For the
466    /// whole-project bring-up use `bougie start`.
467    Up {
468        /// Service names to bring up. Empty = every declared service.
469        names: Vec<String>,
470        /// Start the services and return immediately instead of
471        /// attaching to their combined log stream. Attaching is the
472        /// default for an interactive (TTY) text-mode invocation;
473        /// non-interactive runs and `--format json-v1` always detach.
474        #[arg(short = 'd', long)]
475        detach: bool,
476    },
477    /// Stop the project's declared services (or every service in
478    /// `names`). The shared global process stays up while any other
479    /// project's tenant remains. For the whole-project teardown use
480    /// `bougie stop`.
481    Down {
482        names: Vec<String>,
483        /// Destroy persisted tenant data (e.g. FLUSHDB on redis). Off
484        /// by default — re-adding the service should restore state.
485        #[arg(long)]
486        purge: bool,
487    },
488    /// Declare a service in the project. Errors if the name isn't in
489    /// the catalog. Use `bougie services catalog` to discover names.
490    Add {
491        /// One or more service names, each optionally `@<version>`.
492        names: Vec<String>,
493    },
494    /// Remove a service declaration from the project. Tenant data is
495    /// kept by default (re-adding restores it); pass `--purge` to also
496    /// destroy it.
497    Remove {
498        /// Service names to remove.
499        names: Vec<String>,
500        /// Also destroy the project's tenant data for each service
501        /// (same as `bougie services down --purge`) before undeclaring.
502        #[arg(long)]
503        purge: bool,
504    },
505    /// List the services declared in the current project.
506    List {
507        /// Reserved for cross-project listing in Phase 3+. Today this
508        /// degrades silently to per-project output.
509        #[arg(long)]
510        all: bool,
511    },
512    /// Print the built-in service catalog (no daemon required).
513    Catalog,
514    /// Restart the named services (or every declared service). Stops
515    /// then starts the underlying global process; the tenant ledger
516    /// is preserved, so generated passwords / DB numbers survive.
517    /// Affects every project sharing the same service.
518    Restart {
519        names: Vec<String>,
520    },
521    /// Per-service status for the current project.
522    Status {
523        /// Limit to a single service.
524        name: Option<String>,
525    },
526    /// Tail (and optionally follow) service logs. With no name, shows
527    /// the combined ("multilog") stream of every service declared in the
528    /// project, each line prefixed with its (colorized) service name —
529    /// the same view `bougie services up` attaches to.
530    Logs {
531        /// Service name. Omit to tail every declared service at once.
532        name: Option<String>,
533        /// Follow the log; runs until interrupted (Ctrl-C).
534        #[arg(short = 'f', long)]
535        follow: bool,
536        /// Number of trailing lines to print before any follow.
537        #[arg(short = 'n', long, default_value_t = 50)]
538        lines: usize,
539    },
540    /// Inspect and control the `bougied` daemon.
541    #[command(subcommand)]
542    Daemon(ServicesDaemonCommand),
543}
544
545#[derive(Subcommand, Debug)]
546pub enum ProjectsCommand {
547    /// List every provisioned tenant across the shared services and the
548    /// project each belongs to. Reads the on-disk tenant ledgers; no
549    /// daemon required.
550    List {
551        /// Show the per-service allocation (redis db number, rabbitmq
552        /// vhost, server hostname, …) as an extra column.
553        #[arg(long)]
554        alloc: bool,
555    },
556    /// Deprovision tenants and remove them from the service ledgers.
557    /// With no flags, targets *orphaned* tenants whose project directory
558    /// no longer exists. Destructive: when the service is running this
559    /// drops the tenant's data (database, vhost, redis db, …); when it's
560    /// stopped, only the ledger entry is removed.
561    Purge {
562        /// Purge a specific project's tenants by path (it may already be
563        /// deleted) instead of the orphaned set.
564        #[arg(long)]
565        project: Option<String>,
566        /// Purge every tenant of every project. Use with care.
567        #[arg(long)]
568        all: bool,
569        /// Print what would be purged and exit without changing anything.
570        #[arg(long)]
571        dry_run: bool,
572        /// Skip the confirmation prompt (required for non-interactive use).
573        #[arg(short = 'y', long)]
574        yes: bool,
575    },
576}
577
578#[derive(Subcommand, Debug)]
579pub enum ServicesDaemonCommand {
580    /// Print daemon PID, socket path, and managed-service count. The
581    /// daemon is auto-spawned if not already running.
582    Status,
583    /// Send a graceful shutdown to the running daemon.
584    Stop,
585    /// Print the daemon's reported version (used by the CLI to detect
586    /// post-`self update` daemon-binary mismatches).
587    Version,
588}
589
590/// `bougie server` — the project verb plus its management subcommands.
591/// With no subcommand, the flattened [`ServeArgs`] drive the default
592/// "serve the current project" action; otherwise a [`ServerCommand`]
593/// runs.
594#[derive(Args, Debug)]
595#[command(args_conflicts_with_subcommands = true)]
596pub struct ServerArgs {
597    #[command(subcommand)]
598    pub command: Option<ServerCommand>,
599    #[command(flatten)]
600    pub serve: ServeArgs,
601}
602
603/// Default-action arguments for `bougie server` (no subcommand):
604/// register the current project with the shared dev server, print its
605/// URL, and stream its log.
606#[derive(Args, Debug)]
607#[allow(clippy::struct_excessive_bools)] // each bool is a distinct CLI flag
608pub struct ServeArgs {
609    /// Hostname label override — the `<name>` in `<name>.bougie.run`.
610    /// Defaults to a name derived from the project.
611    #[arg(value_name = "NAME")]
612    pub name: Option<String>,
613    /// Open the project URL in a browser once the server is ready.
614    #[arg(long)]
615    pub open: bool,
616    /// Serve over HTTPS (requires `bougie server tls install`).
617    #[arg(long)]
618    pub tls: bool,
619    /// Print the URL and return immediately instead of attaching to the
620    /// log stream. Matches `services up`'s `-d`.
621    #[arg(short = 'd', long = "detach")]
622    pub detach: bool,
623    /// Skip the implicit `bougie sync` before serving.
624    #[arg(long)]
625    pub no_sync: bool,
626}
627
628#[derive(Subcommand, Debug)]
629pub enum ServerCommand {
630    /// Low-level primitive: run the server process against an explicit
631    /// multi-host `server.toml`, foreground, with no daemon. This is
632    /// what `bougied` spawns and what CI / power users invoke directly;
633    /// `--config` is required because a multi-host server has no single
634    /// project to default to. The bougied-managed path (`bougie services
635    /// up server`) supplies its own service-scoped `server.toml`.
636    Run {
637        /// `server.toml` path. Required.
638        #[arg(long, value_name = "PATH")]
639        config: std::path::PathBuf,
640        /// CLI override of `[server].listen` (e.g. `127.0.0.1:7080`).
641        #[arg(long, value_name = "ADDR")]
642        listen: Option<String>,
643        /// CLI override of `[server].log_format`.
644        #[arg(long, value_name = "FMT")]
645        log_format: Option<String>,
646    },
647    /// Show the dev server's hosts and live pool state. Reads the
648    /// running server's control socket when available, falling back to
649    /// the configured hosts otherwise. Replaces the old `list`, which
650    /// remains as a hidden alias.
651    #[command(alias = "list")]
652    Status {
653        /// `server.toml` to inspect. Defaults to the bougied-managed
654        /// config.
655        #[arg(long, value_name = "PATH")]
656        config: Option<std::path::PathBuf>,
657    },
658    /// Open the current project's (or NAME's) dev URL in a browser.
659    Open {
660        /// Hostname label to open. Defaults to the current project.
661        #[arg(value_name = "NAME")]
662        name: Option<String>,
663    },
664    /// Stop the shared dev server. Equivalent to `bougie services down
665    /// server`; stops hosting for every project, since the server is shared.
666    Stop,
667    /// Tail the dev server's request log. In a project, defaults to
668    /// this project's host.
669    Logs {
670        /// Follow the log; runs until interrupted (Ctrl-C).
671        #[arg(short = 'f', long)]
672        follow: bool,
673        /// Number of trailing lines to print before any follow.
674        #[arg(short = 'n', long, default_value_t = 50)]
675        lines: usize,
676    },
677    /// Manage local TLS via mkcert.
678    #[command(subcommand)]
679    Tls(ServerTlsCommand),
680    /// Manage `/etc/hosts` overrides.
681    #[command(subcommand)]
682    Hosts(ServerHostsCommand),
683}
684
685#[derive(Subcommand, Debug)]
686pub enum ServerHostsCommand {
687    /// Rewrite the bougie sentinel block in /etc/hosts to match
688    /// server.toml. Requires root — runs via sudo.
689    Apply {
690        /// `server.toml` to read the host list from. Defaults to the
691        /// bougied-managed config.
692        #[arg(long, value_name = "PATH")]
693        config: Option<std::path::PathBuf>,
694    },
695}
696
697#[derive(Subcommand, Debug)]
698pub enum ServerTlsCommand {
699    /// Fetch mkcert and install bougie's local CA.
700    Install,
701    /// Uninstall bougie's local CA.
702    Uninstall,
703}
704
705#[derive(Subcommand, Debug)]
706pub enum ExtCommand {
707    /// Add an extension dependency. Each `<arg>` is either an
708    /// extension name (e.g. `redis`, `xdebug@3.5.1`) — fetched from
709    /// the index and recorded in composer.json — or a path to a local
710    /// `.so` file (e.g. `/opt/tideways/tideways-php-8.5.so`), in which
711    /// case bougie copies it into the store, auto-detects the
712    /// extension name and Zend-ness from the binary, and writes a
713    /// fragment to the durable, machine-local `conf.d-local/` (under
714    /// `$BOUGIE_HOME`) without touching composer.json. Mix and match in
715    /// one invocation.
716    Add {
717        /// Extension names or `.so` paths (anything ending in `.so` is
718        /// treated as a local file).
719        args: Vec<String>,
720        /// Skip the implicit `bougie sync` after the composer call.
721        #[arg(long)]
722        no_sync: bool,
723        #[command(flatten)]
724        php: PhpPrefArgs,
725    },
726    /// Remove an extension dependency.
727    Remove {
728        /// The extension(s) to remove.
729        names: Vec<String>,
730        /// Skip the implicit `bougie sync` after the composer call.
731        #[arg(long)]
732        no_sync: bool,
733    },
734    /// List available extensions.
735    List {
736        /// Only show installed extensions.
737        #[arg(long)]
738        only_installed: bool,
739        /// Only show extensions advertised by the index.
740        #[arg(long)]
741        only_available: bool,
742        /// List all extension versions, including older releases.
743        #[arg(long)]
744        all_versions: bool,
745        /// List extensions for all platforms, not just the host's.
746        #[arg(long)]
747        all_platforms: bool,
748        /// Show the URLs of available extension downloads.
749        #[arg(long)]
750        show_urls: bool,
751    },
752}
753
754#[derive(Subcommand, Debug)]
755pub enum PhpCommand {
756    /// Install a new PHP version.
757    Install {
758        /// The PHP version(s) to install (e.g. `8.3`, `8.3.12`, `8.3+zts`).
759        requests: Vec<String>,
760        /// Build flavor to install [possible values: nts, nts-debug, zts, zts-debug].
761        #[arg(long)]
762        flavor: Option<String>,
763        /// Skip the entire baseline extension set; install only the bare
764        /// Debian-aligned interpreter (`REFACTOR_DEBIAN_ALIGNED.md`).
765        #[arg(long, conflicts_with = "without")]
766        bare: bool,
767        /// Skip a specific baseline extension. Repeatable: `--without opcache
768        /// --without readline`. The named extensions must already be in the
769        /// baseline set; use `bougie ext remove` after install for anything else.
770        #[arg(long, value_name = "EXT", action = clap::ArgAction::Append)]
771        without: Vec<String>,
772    },
773    /// Remove a PHP version.
774    Uninstall {
775        /// The PHP version(s) to uninstall.
776        #[arg(required = true)]
777        requests: Vec<String>,
778        /// Build flavor to uninstall [possible values: nts, nts-debug, zts, zts-debug].
779        #[arg(long)]
780        flavor: Option<String>,
781    },
782    /// List available PHP interpreters.
783    List {
784        /// A PHP request to filter by.
785        request: Option<String>,
786        /// Only show installed PHP versions.
787        #[arg(long)]
788        only_installed: bool,
789        /// Only show PHP versions available for download.
790        #[arg(long)]
791        only_available: bool,
792        /// List all PHP versions, including older patch versions.
793        #[arg(long)]
794        all_versions: bool,
795        /// List PHP downloads for all platforms.
796        #[arg(long)]
797        all_platforms: bool,
798        /// List PHP downloads for all architectures.
799        #[arg(long)]
800        all_arches: bool,
801        /// Show the URLs of available PHP downloads.
802        #[arg(long)]
803        show_urls: bool,
804    },
805    /// Search for a PHP interpreter.
806    Find {
807        /// A PHP request to search for.
808        request: Option<String>,
809    },
810    /// Pin the project's PHP version.
811    Pin {
812        /// The PHP version to pin.
813        request: String,
814        /// Write the pin to `bougie.toml` (creating it if needed).
815        #[arg(long, conflicts_with = "composer")]
816        toml: bool,
817        /// Write the pin to `composer.json`'s `require.php`.
818        #[arg(long, conflicts_with = "toml")]
819        composer: bool,
820    },
821    /// Refresh installed interpreters to the latest published patch.
822    Upgrade {
823        /// The PHP minor version(s) to upgrade (e.g. `8.3`).
824        minor: Option<String>,
825    },
826    /// Show the PHP interpreter installation directory.
827    Dir,
828}
829
830#[derive(Subcommand, Debug)]
831pub enum NodeCommand {
832    /// Install a Node.js version from nodejs.org.
833    Install {
834        /// The Node version(s) to install (e.g. `latest`, `lts`, `20`,
835        /// `20.11`, `20.11.0`). Defaults to `latest`.
836        requests: Vec<String>,
837    },
838    /// Remove an installed Node.js version.
839    Uninstall {
840        /// The Node version(s) to uninstall (exact `20.11.0`).
841        #[arg(required = true)]
842        requests: Vec<String>,
843    },
844    /// List installed Node.js versions.
845    List,
846    /// Resolve a request and show the version + download URL it maps to,
847    /// without installing.
848    Find {
849        /// A Node request to resolve (e.g. `lts`, `20`). Defaults to `latest`.
850        request: Option<String>,
851    },
852    /// Show the Node.js installation directory.
853    Dir,
854}
855
856#[derive(Subcommand, Debug)]
857pub enum ComposerCommand {
858    /// Install a project's `vendor/` from `composer.lock`. Reads
859    /// `composer.json` + `composer.lock` in the working directory,
860    /// content-hash-verifies the lock, parallel-downloads dists into
861    /// `vendor/`, and emits `vendor/autoload.php`.
862    Install {
863        /// Run the install in this directory instead of CWD.
864        /// Mirrors Composer's `--working-dir` / `-d`.
865        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
866        working_dir: Option<std::path::PathBuf>,
867        /// Skip dev-only packages and dev autoload entries.
868        #[arg(long = "no-dev")]
869        no_dev: bool,
870        /// Fail if composer.lock is out of sync with composer.json.
871        /// Currently a no-op — the install already errors on
872        /// content-hash mismatch by default. Accepted for parity
873        /// with Composer's CI usage.
874        #[arg(long = "frozen")]
875        frozen: bool,
876        /// Verify the lock is internally consistent (content-hash,
877        /// requires, transitives) and exit. Doesn't touch `vendor/`
878        /// or run the autoloader. CI-friendly read-only check.
879        #[arg(long = "lock-verify")]
880        lock_verify: bool,
881        /// Ignore all platform requirements (php, ext-*, lib-*).
882        /// Accepted for Composer parity; bougie does not enforce
883        /// platform requirements yet.
884        #[arg(long = "ignore-platform-reqs")]
885        ignore_platform_reqs: bool,
886        /// Ignore a specific platform requirement.
887        #[arg(long = "ignore-platform-req", value_name = "REQ")]
888        ignore_platform_req: Vec<String>,
889        /// Run composer.json root scripts, overriding `[scripts] run`
890        /// in bougie.toml. Off by default (opt-in).
891        #[arg(long, conflicts_with = "no_scripts")]
892        scripts: bool,
893        /// Skip composer.json root scripts, overriding `[scripts] run
894        /// = true` in bougie.toml (Composer-compatible `--no-scripts`).
895        #[arg(long = "no-scripts")]
896        no_scripts: bool,
897    },
898    /// Resolve the project's dependency graph, write a fresh
899    /// `composer.lock`, and install the result into `vendor/` (matching
900    /// Composer's `update`). With no package arguments this re-resolves
901    /// from scratch; naming one or more packages does a partial update —
902    /// only those re-resolve while every other locked package stays
903    /// pinned. `--no-install` stops after writing the lock; `--dry-run`
904    /// previews the solution without writing anything. Aliased to
905    /// `upgrade` / `u`, like Composer.
906    #[command(visible_alias = "upgrade", alias = "u")]
907    Update {
908        /// Packages to update (`vendor/name`). When given, only these
909        /// packages re-resolve; every other package stays pinned to its
910        /// `composer.lock` version (Composer's partial update). With no
911        /// packages, the whole graph re-resolves from scratch.
912        #[arg(value_name = "PACKAGES")]
913        packages: Vec<String>,
914        /// Write the lock but don't install into `vendor/` (Composer's
915        /// `--no-install`).
916        #[arg(long = "no-install")]
917        no_install: bool,
918        /// Also update the named packages' dependencies (Composer's
919        /// `--with-dependencies` / `-w`).
920        #[arg(short = 'w', long = "with-dependencies")]
921        with_dependencies: bool,
922        /// Also update all of the named packages' dependencies, including
923        /// ones shared with other packages (Composer's
924        /// `--with-all-dependencies` / `-W`).
925        #[arg(short = 'W', long = "with-all-dependencies")]
926        with_all_dependencies: bool,
927        /// Run the update in this directory instead of CWD.
928        /// Mirrors Composer's `--working-dir` / `-d`.
929        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
930        working_dir: Option<std::path::PathBuf>,
931        /// Skip dev-only root requires when resolving.
932        #[arg(long = "no-dev")]
933        no_dev: bool,
934        /// Resolve and print the solution without writing
935        /// `composer.lock` or touching `vendor/`. Without this flag,
936        /// `update` writes a fresh `composer.lock`.
937        #[arg(long = "dry-run")]
938        dry_run: bool,
939        /// Ignore all platform requirements (php, ext-*, lib-*).
940        /// Accepted for Composer parity; bougie does not enforce
941        /// platform requirements yet.
942        #[arg(long = "ignore-platform-reqs")]
943        ignore_platform_reqs: bool,
944        /// Ignore a specific platform requirement.
945        #[arg(long = "ignore-platform-req", value_name = "REQ")]
946        ignore_platform_req: Vec<String>,
947    },
948    /// Validate composer.json structure and contents.
949    Validate {
950        /// Run in this directory instead of CWD.
951        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
952        working_dir: Option<std::path::PathBuf>,
953        /// Return non-zero exit code for warnings too.
954        #[arg(long)]
955        strict: bool,
956        /// Skip lock file freshness check.
957        #[arg(long = "no-check-lock")]
958        no_check_lock: bool,
959        /// Skip publish-only checks (name casing, required fields).
960        #[arg(long = "no-check-publish")]
961        no_check_publish: bool,
962        /// Skip unbound/exact version constraint warnings.
963        #[arg(long = "no-check-all")]
964        no_check_all: bool,
965        /// Also validate installed dependencies' composer.json files.
966        #[arg(long = "with-dependencies")]
967        with_dependencies: bool,
968        /// Force lock file checking even when `config.lock` is false.
969        #[arg(long = "check-lock")]
970        check_lock: bool,
971    },
972    /// Regenerate `vendor/composer/autoload_*.php` against the current
973    /// `composer.lock`. Drop-in for `composer dump-autoload`; output
974    /// is byte-equivalent to Composer 2.8.12 with the same flags. Aliased
975    /// to `dump-autoload` for users coming from Composer muscle-memory.
976    #[command(alias = "dump-autoload")]
977    DumpAutoloader {
978        /// Optimize the classmap (`--optimize` / `-o`).
979        #[arg(short = 'o', long = "optimize", alias = "optimize-autoloader")]
980        optimize: bool,
981        /// Emit the classmap-authoritative static loader
982        /// (`--classmap-authoritative` / `-a`). Implies `--optimize`.
983        #[arg(short = 'a', long = "classmap-authoritative")]
984        classmap_authoritative: bool,
985        /// Skip dev autoload entries (`--no-dev`).
986        #[arg(long = "no-dev")]
987        no_dev: bool,
988        /// Emit the `APCu` loader bootstrap (`--apcu-autoloader`).
989        #[arg(long = "apcu-autoloader")]
990        apcu_autoloader: bool,
991        /// Explicit `APCu` prefix; implies `--apcu-autoloader`.
992        #[arg(long = "apcu-autoloader-prefix", value_name = "PREFIX")]
993        apcu_prefix: Option<String>,
994        /// Override the `ComposerAutoloaderInit<X>` class suffix —
995        /// otherwise the value from `composer.json`'s
996        /// `config.autoloader-suffix`, or the `composer.lock`
997        /// content-hash.
998        #[arg(long = "autoloader-suffix", value_name = "SUFFIX")]
999        autoloader_suffix: Option<String>,
1000        /// Run the dump in this directory instead of the current one.
1001        /// Mirrors Composer's `--working-dir` / `-d`.
1002        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1003        working_dir: Option<std::path::PathBuf>,
1004    },
1005    /// Add one or more packages to `composer.json` `require` (or
1006    /// `require-dev`), re-resolve `composer.lock`, and install them.
1007    /// Fully Composer-compatible: a bare `vendor/pkg` resolves the
1008    /// latest stable and writes a caret (`^X.Y`) constraint; supply an
1009    /// explicit constraint with `vendor/pkg:^1.0`, `vendor/pkg=^1.0`, or
1010    /// a trailing argument (`vendor/pkg ^1.0`) — Composer's separators
1011    /// are `:`, `=`, or a space (the `@` separator is *not* accepted, as
1012    /// in Composer). For bougie's `>=`-default + `@`-syntax house style,
1013    /// use the top-level `bougie add` instead.
1014    Require {
1015        /// Packages to require, as Composer name↔version pairs.
1016        #[arg(value_name = "PACKAGES", required = true)]
1017        packages: Vec<String>,
1018        /// Add to `require-dev` instead of `require`.
1019        #[arg(long = "dev")]
1020        dev: bool,
1021        /// Edit `composer.json` only — don't re-resolve `composer.lock`
1022        /// or touch `vendor/` (Composer's `--no-update`).
1023        #[arg(long = "no-update")]
1024        no_update: bool,
1025        /// Re-resolve and write `composer.lock` but don't install into
1026        /// `vendor/` (Composer's `--no-install`).
1027        #[arg(long = "no-install")]
1028        no_install: bool,
1029        /// Also update the new packages' dependencies (`-w`).
1030        #[arg(short = 'w', long = "with-dependencies")]
1031        with_dependencies: bool,
1032        /// Also update all dependencies, including shared ones (`-W`).
1033        #[arg(short = 'W', long = "with-all-dependencies")]
1034        with_all_dependencies: bool,
1035        /// Prefer the lowest matching versions when resolving.
1036        #[arg(long = "prefer-lowest")]
1037        prefer_lowest: bool,
1038        /// Ignore all platform requirements (php, ext-*, lib-*).
1039        #[arg(long = "ignore-platform-reqs")]
1040        ignore_platform_reqs: bool,
1041        /// Ignore a specific platform requirement.
1042        #[arg(long = "ignore-platform-req", value_name = "REQ")]
1043        ignore_platform_req: Vec<String>,
1044        /// Run in this directory instead of CWD (`-d`).
1045        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1046        working_dir: Option<std::path::PathBuf>,
1047        /// Resolve and report what would change without writing
1048        /// `composer.json`, `composer.lock`, or `vendor/`.
1049        #[arg(long = "dry-run")]
1050        dry_run: bool,
1051    },
1052    /// Remove one or more packages from `composer.json`, re-resolve
1053    /// `composer.lock`, and uninstall them from `vendor/`. Drop-in for
1054    /// `composer remove`.
1055    Remove {
1056        /// Packages to remove (`vendor/name`).
1057        #[arg(value_name = "PACKAGES", required = true)]
1058        packages: Vec<String>,
1059        /// Remove from `require-dev` instead of `require`.
1060        #[arg(long = "dev")]
1061        dev: bool,
1062        /// Edit `composer.json` only — don't re-resolve or touch
1063        /// `vendor/` (Composer's `--no-update`).
1064        #[arg(long = "no-update")]
1065        no_update: bool,
1066        /// Re-resolve and write `composer.lock` but don't touch
1067        /// `vendor/` (Composer's `--no-install`).
1068        #[arg(long = "no-install")]
1069        no_install: bool,
1070        /// Skip dev-only packages when resolving.
1071        #[arg(long = "no-dev")]
1072        no_dev: bool,
1073        /// Ignore all platform requirements (php, ext-*, lib-*).
1074        #[arg(long = "ignore-platform-reqs")]
1075        ignore_platform_reqs: bool,
1076        /// Ignore a specific platform requirement.
1077        #[arg(long = "ignore-platform-req", value_name = "REQ")]
1078        ignore_platform_req: Vec<String>,
1079        /// Run in this directory instead of CWD (`-d`).
1080        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1081        working_dir: Option<std::path::PathBuf>,
1082        /// Resolve and report what would change without writing
1083        /// `composer.json`, `composer.lock`, or `vendor/`.
1084        #[arg(long = "dry-run")]
1085        dry_run: bool,
1086    },
1087    /// List installed packages, or show details for one. Reads the
1088    /// project's `composer.lock`. Drop-in for `composer show` (aliases
1089    /// `info`, `list`).
1090    #[command(alias = "info", alias = "list")]
1091    Show {
1092        /// A single `vendor/name` to show details for. With no argument,
1093        /// every installed package is listed.
1094        #[arg(value_name = "PACKAGE")]
1095        package: Option<String>,
1096        /// Render the dependency tree (`--tree` / `-t`).
1097        #[arg(short = 't', long = "tree")]
1098        tree: bool,
1099        /// Only the project's direct dependencies (`--direct` / `-D`).
1100        #[arg(short = 'D', long = "direct")]
1101        direct: bool,
1102        /// Only platform packages — php, ext-*, lib-* (`--platform` / `-p`).
1103        #[arg(short = 'p', long = "platform")]
1104        platform: bool,
1105        /// Show the root package's own info (`--self` / `-s`).
1106        #[arg(short = 's', long = "self")]
1107        self_: bool,
1108        /// Print package names only (`--name-only` / `-N`).
1109        #[arg(short = 'N', long = "name-only")]
1110        name_only: bool,
1111        /// Show each package's install path (`--path` / `-P`).
1112        #[arg(short = 'P', long = "path")]
1113        path: bool,
1114        /// Also fetch and show the latest available version
1115        /// (`--latest` / `-l`).
1116        #[arg(short = 'l', long = "latest")]
1117        latest: bool,
1118        /// Only packages with a newer version available
1119        /// (`--outdated` / `-o`). Implies `--latest`.
1120        #[arg(short = 'o', long = "outdated")]
1121        outdated: bool,
1122        /// Skip dev dependencies (`--no-dev`).
1123        #[arg(long = "no-dev")]
1124        no_dev: bool,
1125        /// Run in this directory instead of CWD (`-d`).
1126        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1127        working_dir: Option<std::path::PathBuf>,
1128    },
1129    /// Show which packages depend on a given package — i.e. why it's
1130    /// installed. Drop-in for `composer why` (alias `depends`).
1131    #[command(alias = "depends")]
1132    Why {
1133        /// The package to explain.
1134        #[arg(value_name = "PACKAGE", required = true)]
1135        package: String,
1136        /// Recurse through the dependency chain (`--recursive` / `-r`).
1137        #[arg(short = 'r', long = "recursive")]
1138        recursive: bool,
1139        /// Render the full dependency-of tree (`--tree` / `-t`).
1140        #[arg(short = 't', long = "tree")]
1141        tree: bool,
1142        /// Run in this directory instead of CWD (`-d`).
1143        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1144        working_dir: Option<std::path::PathBuf>,
1145    },
1146    /// Show what prevents a package (optionally at a version) from being
1147    /// installed — conflicting requirements. Drop-in for
1148    /// `composer why-not` (alias `prohibits`).
1149    #[command(name = "why-not", alias = "prohibits")]
1150    WhyNot {
1151        /// The package to test.
1152        #[arg(value_name = "PACKAGE", required = true)]
1153        package: String,
1154        /// The version (or constraint) to test against. Defaults to `*`.
1155        #[arg(value_name = "VERSION")]
1156        version: Option<String>,
1157        /// Recurse through the dependency chain (`--recursive` / `-r`).
1158        #[arg(short = 'r', long = "recursive")]
1159        recursive: bool,
1160        /// Render the full tree (`--tree` / `-t`).
1161        #[arg(short = 't', long = "tree")]
1162        tree: bool,
1163        /// Run in this directory instead of CWD (`-d`).
1164        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1165        working_dir: Option<std::path::PathBuf>,
1166    },
1167    /// List installed packages that have a newer version available.
1168    /// Drop-in for `composer outdated` (a focused `show --latest
1169    /// --outdated`). Use the global `--format json` for JSON output.
1170    Outdated {
1171        /// Optional `vendor/name` filters; with none, all packages are
1172        /// considered.
1173        #[arg(value_name = "PACKAGES")]
1174        packages: Vec<String>,
1175        /// Only the project's direct dependencies (`--direct` / `-D`).
1176        #[arg(short = 'D', long = "direct")]
1177        direct: bool,
1178        /// Only show packages with a new major version (`--major-only`).
1179        #[arg(long = "major-only")]
1180        major_only: bool,
1181        /// Only show packages with a new minor version (`--minor-only`).
1182        #[arg(long = "minor-only")]
1183        minor_only: bool,
1184        /// Only show packages with a new patch version (`--patch-only`).
1185        #[arg(long = "patch-only")]
1186        patch_only: bool,
1187        /// Skip dev dependencies (`--no-dev`).
1188        #[arg(long = "no-dev")]
1189        no_dev: bool,
1190        /// Exit non-zero if any package is outdated (`--strict`).
1191        #[arg(long = "strict")]
1192        strict: bool,
1193        /// Run in this directory instead of CWD (`-d`).
1194        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1195        working_dir: Option<std::path::PathBuf>,
1196    },
1197    /// Check installed packages against the Packagist security-advisories
1198    /// database. Drop-in for `composer audit`. Exits non-zero when
1199    /// advisories are found. Use the global `--format json` for JSON.
1200    Audit {
1201        /// Skip dev dependencies (`--no-dev`).
1202        #[arg(long = "no-dev")]
1203        no_dev: bool,
1204        /// How to treat abandoned packages (`--abandoned`). Currently
1205        /// accepted for parity; abandoned detection is not yet wired.
1206        #[arg(long = "abandoned", value_enum, default_value = "report")]
1207        abandoned: AbandonedHandling,
1208        /// Audit the locked set (`--locked`). bougie always reads
1209        /// `composer.lock`, so this is the default behavior; accepted
1210        /// for parity.
1211        #[arg(long = "locked")]
1212        locked: bool,
1213        /// Run in this directory instead of CWD (`-d`).
1214        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1215        working_dir: Option<std::path::PathBuf>,
1216    },
1217    /// List the license of every installed package. Drop-in for
1218    /// `composer licenses`. Use the global `--format json` for JSON.
1219    Licenses {
1220        /// Skip dev dependencies (`--no-dev`).
1221        #[arg(long = "no-dev")]
1222        no_dev: bool,
1223        /// Run in this directory instead of CWD (`-d`).
1224        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1225        working_dir: Option<std::path::PathBuf>,
1226    },
1227    /// Report packages that look locally modified. Drop-in for
1228    /// `composer status`. bougie installs from dist archives, so for the
1229    /// common case this reports "no local changes".
1230    Status {
1231        /// Run in this directory instead of CWD (`-d`).
1232        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1233        working_dir: Option<std::path::PathBuf>,
1234    },
1235    /// Show funding information for installed packages, grouped by
1236    /// vendor. Drop-in for `composer fund`. Use `--format json` for JSON.
1237    Fund {
1238        /// Skip dev dependencies (`--no-dev`).
1239        #[arg(long = "no-dev")]
1240        no_dev: bool,
1241        /// Run in this directory instead of CWD (`-d`).
1242        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1243        working_dir: Option<std::path::PathBuf>,
1244    },
1245    /// Catch-all for any composer subcommand bougie does not implement
1246    /// natively (`create-project`, `archive`, `bump`, `global`, …).
1247    /// bougie does not bundle the Composer phar, so these no longer run;
1248    /// the dispatch returns an error pointing at
1249    /// `bougie tool install composer/composer` for the full upstream
1250    /// Composer.
1251    #[command(external_subcommand)]
1252    External(Vec<OsString>),
1253}
1254
1255/// How `composer audit` treats abandoned packages.
1256#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
1257pub enum AbandonedHandling {
1258    /// Ignore abandoned packages entirely.
1259    Ignore,
1260    /// Report abandoned packages but don't fail on them.
1261    Report,
1262    /// Treat abandoned packages as an audit failure.
1263    Fail,
1264}
1265
1266#[derive(Subcommand, Debug)]
1267pub enum CacheCommand {
1268    /// Wipe the full cache.
1269    Clean,
1270    /// Remove unneeded library files.
1271    Prune {
1272        /// Show what would be pruned without removing anything.
1273        #[arg(long)]
1274        dry_run: bool,
1275        /// Also remove tracked projects that no longer exist on disk.
1276        #[arg(long)]
1277        prune_projects: bool,
1278    },
1279    /// Show the location of the cache directory.
1280    Dir,
1281    /// Show the cache size.
1282    Size,
1283}
1284
1285#[derive(Subcommand, Debug)]
1286pub enum ToolCommand {
1287    /// Install a tool. Pass `<vendor>/<name>` optionally followed by
1288    /// `@<constraint>` (e.g. `phpstan/phpstan@^1.10`).
1289    Install {
1290        /// Composer package identifier, optionally with `@<constraint>`.
1291        package: String,
1292        /// Pin the tool to a specific PHP. Accepts a version (`8.3`,
1293        /// `8.3.12`) or a constraint (`~8.3`, `>=8.2,<8.4`). When the
1294        /// requested PHP isn't installed, bougie installs it
1295        /// automatically. Defaults to the highest installed NTS PHP.
1296        #[arg(long, value_name = "VER")]
1297        php: Option<String>,
1298        /// Additional Composer package (`vendor/name[@<constraint>]`)
1299        /// or PHP extension (`intl`, `redis`) to install alongside the
1300        /// tool. May be passed multiple times.
1301        #[arg(long, value_name = "PKG_OR_EXT")]
1302        with: Vec<String>,
1303        /// Overwrite an existing executable at the bin-dir path.
1304        #[arg(long)]
1305        force: bool,
1306    },
1307    /// Remove an installed tool by its `<vendor>/<name>` identifier.
1308    Uninstall {
1309        /// Composer package identifier.
1310        package: String,
1311    },
1312    /// Add an extra composer package or PHP extension to an
1313    /// installed tool. Re-resolves the tool's lock and updates the
1314    /// vendor tree in place.
1315    Inject {
1316        /// Composer package identifier of the tool.
1317        package: String,
1318        /// Extra to add (`vendor/name[@<constraint>]` for composer
1319        /// packages, bare name for PHP extensions). Repeatable.
1320        #[arg(long, value_name = "PKG_OR_EXT", required = true)]
1321        with: Vec<String>,
1322    },
1323    /// Remove an extra previously added via `--with` / `inject`.
1324    Uninject {
1325        /// Composer package identifier of the tool.
1326        package: String,
1327        /// Extra to remove. Repeatable.
1328        #[arg(long, value_name = "PKG_OR_EXT", required = true)]
1329        with: Vec<String>,
1330    },
1331    /// List installed tools.
1332    List,
1333    /// Print a tool's install directory, or the tools root if no
1334    /// package is given.
1335    Dir {
1336        /// Composer package identifier; omit to print the tools root.
1337        package: Option<String>,
1338    },
1339    /// Run an installed-or-cached tool one-off. Reuses an existing
1340    /// persistent install if `(package, constraint, php, with)` match
1341    /// exactly; otherwise materialises into the ephemeral cache.
1342    ///
1343    /// `bgx` is provided as a convenient alias for `bougie tool run`;
1344    /// their behavior is identical.
1345    #[command(
1346        override_usage = "bougie tool run [OPTIONS] <PACKAGE> [ARGS]...",
1347        after_help = "Use `bgx` as a shortcut for `bougie tool run`.\n\n\
1348                      Use `bougie help tool run` for more details.",
1349        after_long_help = ""
1350    )]
1351    Run(ToolRunArgs),
1352    // Hidden alias for `bougie tool run` for the `bgx` command. The
1353    // variant is reached only via the `bgx` binary exec'ing into it;
1354    // it doesn't surface under `bougie tool --help`. Carrying it as
1355    // a separate variant (with `display_name`, `override_usage`)
1356    // lets clap render `bgx --help` and clap-level error messages
1357    // with `bgx` as the program name rather than leaking
1358    // `bougie tool run`.
1359    #[command(
1360        hide = true,
1361        override_usage = "bgx [OPTIONS] <PACKAGE> [ARGS]...",
1362        about = "Run a tool from a Composer package.",
1363        long_about = None,
1364        after_help = "Use `bougie help tool run` for more details.",
1365        after_long_help = "",
1366        display_name = "bgx",
1367        // `bgx --version` / `bgx -V` exec into `bougie tool bgx`; give
1368        // this variant its own version flag so it short-circuits before
1369        // the required `<PACKAGE>` positional and prints `bgx <version>`.
1370        version = LONG_VERSION
1371    )]
1372    Bgx(BgxArgs),
1373    /// Re-resolve a tool's lock and bring its vendor tree up to date.
1374    /// Pass `--all` to walk every installed tool, or `--reinstall` to
1375    /// wipe and rebuild from scratch (recovery for broken state).
1376    Upgrade {
1377        /// Composer package identifier. Required unless `--all`.
1378        #[arg(required_unless_present = "all", conflicts_with = "all")]
1379        package: Option<String>,
1380        /// Upgrade every installed tool.
1381        #[arg(long)]
1382        all: bool,
1383        /// Wipe the tool dir + every entrypoint symlink and reinstall
1384        /// from scratch using the receipt's pinned `(package,
1385        /// constraint, php_version, with, extensions)` tuple.
1386        #[arg(long)]
1387        reinstall: bool,
1388    },
1389}
1390
1391#[derive(Subcommand, Debug)]
1392pub enum SelfCommand {
1393    /// Update bougie.
1394    Update {
1395        /// Update even when bougie can't confirm it installed this
1396        /// binary. By default `self update` only touches a binary that
1397        /// bougie's own installer placed (per the install receipt);
1398        /// copies from a package manager, cargo, or nix are left for
1399        /// that tool to update. Pass `--force` only if you know this
1400        /// copy came from bougie's installer.
1401        #[arg(long)]
1402        force: bool,
1403    },
1404    /// Show bougie's version.
1405    Version {
1406        /// Only show the version.
1407        #[arg(long)]
1408        short: bool,
1409    },
1410}
1411
1412#[derive(Args, Debug)]
1413pub struct ToolRunArgs {
1414    /// Pin the tool to a specific PHP for this run.
1415    #[arg(long, value_name = "VER")]
1416    pub php: Option<String>,
1417    /// Extra composer package or PHP extension, same shape as
1418    /// `tool install --with`. Repeatable.
1419    #[arg(long, value_name = "PKG_OR_EXT")]
1420    pub with: Vec<String>,
1421    /// The tool's Composer package (optionally `@<constraint>`) followed
1422    /// by the arguments to forward to it. bougie's own options must come
1423    /// *before* the package; everything from the package onward is passed
1424    /// to the tool verbatim, so no `--` separator is needed.
1425    #[arg(
1426        trailing_var_arg = true,
1427        allow_hyphen_values = true,
1428        required = true,
1429        value_name = "PACKAGE"
1430    )]
1431    pub command: Vec<std::ffi::OsString>,
1432}
1433
1434/// Args for the hidden `bgx` alias. Wraps [`ToolRunArgs`] verbatim so
1435/// the two variants share their entire surface; the wrapper exists
1436/// only so clap renders help / errors with `bgx` as the program name.
1437#[derive(Args, Debug)]
1438pub struct BgxArgs {
1439    #[command(flatten)]
1440    pub tool_run: ToolRunArgs,
1441}
1442
1443#[cfg(test)]
1444mod tests {
1445    use super::*;
1446    use clap::Parser;
1447
1448    fn cmd(argv: &[&str]) -> Command {
1449        Cli::try_parse_from(argv).expect("parse").command
1450    }
1451
1452    #[test]
1453    fn start_is_its_own_verb() {
1454        assert!(matches!(cmd(&["bougie", "start"]), Command::Start { .. }));
1455        assert!(matches!(
1456            cmd(&["bougie", "start", "--no-sync", "--dry-run"]),
1457            Command::Start { no_sync: true, dry_run: true, .. }
1458        ));
1459    }
1460
1461    #[test]
1462    fn stop_takes_names_and_purge() {
1463        let Command::Stop { names, purge } = cmd(&["bougie", "stop", "redis", "--purge"]) else {
1464            panic!("expected stop");
1465        };
1466        assert_eq!(names, ["redis"]);
1467        assert!(purge);
1468    }
1469
1470    #[test]
1471    fn up_down_live_under_services() {
1472        assert!(matches!(
1473            cmd(&["bougie", "services", "up", "redis", "-d"]),
1474            Command::Services(ServicesCommand::Up { detach: true, .. })
1475        ));
1476        assert!(matches!(
1477            cmd(&["bougie", "services", "down", "--purge"]),
1478            Command::Services(ServicesCommand::Down { purge: true, .. })
1479        ));
1480    }
1481
1482    #[test]
1483    fn top_level_up_down_are_gone() {
1484        // The deprecated top-level aliases were removed; `up`/`down` only
1485        // exist under `services` now.
1486        assert!(Cli::try_parse_from(["bougie", "up"]).is_err());
1487        assert!(Cli::try_parse_from(["bougie", "down"]).is_err());
1488    }
1489
1490    #[test]
1491    fn server_detach_flag() {
1492        for argv in [
1493            &["bougie", "server", "-d"][..],
1494            &["bougie", "server", "--detach"][..],
1495        ] {
1496            let Command::Server(args) = cmd(argv) else {
1497                panic!("expected server for {argv:?}");
1498            };
1499            assert!(args.serve.detach, "detach should be set for {argv:?}");
1500        }
1501        // The old `--no-attach` spelling is gone.
1502        assert!(Cli::try_parse_from(["bougie", "server", "--no-attach"]).is_err());
1503    }
1504
1505    #[test]
1506    fn make_no_longer_aliases_start() {
1507        // `start` is no longer a clap alias of `make`; it's the
1508        // first-class verb above. `bougie make start` is just `make`
1509        // with the literal task `start`.
1510        let Command::Make { task, .. } = cmd(&["bougie", "make", "start"]) else {
1511            panic!("expected make");
1512        };
1513        assert_eq!(task.as_deref(), Some("start"));
1514
1515        // Bare `bougie make` parses with no task; the dispatcher turns
1516        // that into a task listing.
1517        let Command::Make { task, .. } = cmd(&["bougie", "make"]) else {
1518            panic!("expected make");
1519        };
1520        assert_eq!(task, None);
1521    }
1522}