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