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    /// Show the resolved patch set, plus any unadopted dependency-declared
770    /// patches (which bougie never applies automatically)
771    List,
772    /// Adopt dependency-declared patches into the root `composer.json`
773    /// (the only way a dependency's patches ever apply)
774    Import {
775        /// Dependencies to import from (default: all that declare patches)
776        packages: Vec<String>,
777        /// Import from every dependency that declares patches
778        #[arg(long)]
779        all: bool,
780        /// Write into the external patches file rather than `extra.patches`
781        #[arg(long = "to-file")]
782        to_file: bool,
783    },
784    /// Force a clean re-extract + re-apply for the named packages (or all):
785    /// drops their recorded fingerprints, then syncs
786    Repatch {
787        /// Packages to repatch (default: all patched packages)
788        packages: Vec<String>,
789    },
790    /// Rebuild `patches.lock.json` from current config: re-download remote
791    /// patches and re-apply everything from pristine
792    Relock,
793    /// Diagnose patch configuration: unresolvable `patches/` files,
794    /// `http://` URLs, missing checksums, unadopted dependency patches
795    Doctor,
796}
797
798#[derive(Subcommand, Debug)]
799pub enum PhpCommand {
800    /// Install a new PHP version
801    Install {
802        /// The PHP version(s) to install (e.g. `8.3`, `8.3.12`, `8.3+zts`)
803        requests: Vec<String>,
804        /// Build flavor to install [possible values: nts, nts-debug, zts, zts-debug]
805        #[arg(long)]
806        flavor: Option<String>,
807        /// Skip the entire baseline extension set; install only the bare
808        /// Debian-aligned interpreter
809        #[arg(long, conflicts_with = "without")]
810        bare: bool,
811        /// Skip a specific baseline extension. Repeatable: `--without opcache
812        /// --without readline`. The named extensions must already be in the
813        /// baseline set; use `bougie ext remove` after install for anything else
814        #[arg(long, value_name = "EXT", action = clap::ArgAction::Append)]
815        without: Vec<String>,
816    },
817    /// Remove a PHP version
818    Uninstall {
819        /// The PHP version(s) to uninstall
820        #[arg(required = true)]
821        requests: Vec<String>,
822        /// Build flavor to uninstall [possible values: nts, nts-debug, zts, zts-debug]
823        #[arg(long)]
824        flavor: Option<String>,
825    },
826    /// List available PHP interpreters
827    List {
828        /// A PHP request to filter by
829        request: Option<String>,
830        /// Only show installed PHP versions
831        #[arg(long)]
832        only_installed: bool,
833        /// Only show PHP versions available for download
834        #[arg(long)]
835        only_available: bool,
836        /// List all PHP versions, including older patch versions
837        #[arg(long)]
838        all_versions: bool,
839        /// List PHP downloads for all platforms
840        #[arg(long)]
841        all_platforms: bool,
842        /// List PHP downloads for all architectures
843        #[arg(long)]
844        all_arches: bool,
845        /// Show the URLs of available PHP downloads
846        #[arg(long)]
847        show_urls: bool,
848    },
849    /// Search for a PHP interpreter
850    Find {
851        /// A PHP request to search for
852        request: Option<String>,
853    },
854    /// Pin the project's PHP version
855    Pin {
856        /// The PHP version to pin
857        request: String,
858        /// Write the pin to `bougie.toml` (creating it if needed)
859        #[arg(long, conflicts_with = "composer")]
860        toml: bool,
861        /// Write the pin to `composer.json`'s `require.php`
862        #[arg(long, conflicts_with = "toml")]
863        composer: bool,
864    },
865    /// Refresh installed interpreters to the latest published patch
866    Upgrade {
867        /// The PHP minor version(s) to upgrade (e.g. `8.3`)
868        minor: Option<String>,
869    },
870    /// Show the PHP interpreter installation directory
871    Dir,
872}
873
874#[derive(Subcommand, Debug)]
875pub enum NodeCommand {
876    /// Install a Node.js version from nodejs.org
877    Install {
878        /// The Node version(s) to install (e.g. `latest`, `lts`, `20`,
879        /// `20.11`, `20.11.0`). Defaults to `latest`
880        requests: Vec<String>,
881    },
882    /// Remove an installed Node.js version
883    Uninstall {
884        /// The Node version(s) to uninstall (exact `20.11.0`)
885        #[arg(required = true)]
886        requests: Vec<String>,
887    },
888    /// List installed Node.js versions
889    List,
890    /// Resolve a request and show the version + download URL it maps to,
891    /// without installing
892    Find {
893        /// A Node request to resolve (e.g. `lts`, `20`). Defaults to `latest`
894        request: Option<String>,
895    },
896    /// Show the Node.js installation directory
897    Dir,
898}
899
900#[derive(Subcommand, Debug)]
901pub enum ComposerCommand {
902    /// Install `vendor/` from `composer.lock`
903    ///
904    /// Reads `composer.json` + `composer.lock` in the working directory,
905    /// content-hash-verifies the lock, parallel-downloads dists into
906    /// `vendor/`, and emits `vendor/autoload.php`
907    Install {
908        /// Run the install in this directory instead of CWD
909        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
910        working_dir: Option<std::path::PathBuf>,
911        /// Skip dev-only packages and dev autoload entries
912        #[arg(long = "no-dev")]
913        no_dev: bool,
914        /// Fail if composer.lock is out of sync with composer.json.
915        /// Currently a no-op — the install already errors on
916        /// content-hash mismatch by default
917        #[arg(long = "frozen")]
918        frozen: bool,
919        /// Verify the lock is internally consistent (content-hash,
920        /// requires, transitives) and exit. Doesn't touch `vendor/`
921        /// or run the autoloader. CI-friendly read-only check
922        #[arg(long = "lock-verify")]
923        lock_verify: bool,
924        /// Ignore all platform requirements (php, ext-*, lib-*). bougie
925        /// does not enforce platform requirements yet
926        #[arg(long = "ignore-platform-reqs")]
927        ignore_platform_reqs: bool,
928        /// Ignore a specific platform requirement
929        #[arg(long = "ignore-platform-req", value_name = "REQ")]
930        ignore_platform_req: Vec<String>,
931        /// Run composer.json root scripts, overriding `[scripts] run`
932        /// in bougie.toml. Off by default (opt-in)
933        #[arg(long, conflicts_with = "no_scripts")]
934        scripts: bool,
935        /// Skip composer.json root scripts, overriding `[scripts] run
936        /// = true` in bougie.toml
937        #[arg(long = "no-scripts")]
938        no_scripts: bool,
939        /// Apply patches, overriding `[patches] enable`. On by default
940        /// when patches are declared
941        #[arg(long, conflicts_with = "no_patches")]
942        patches: bool,
943        /// Skip native patch application for this install
944        #[arg(long = "no-patches")]
945        no_patches: bool,
946    },
947    /// Update dependencies and `composer.lock`
948    ///
949    /// Re-resolve the dependency graph, write a fresh `composer.lock`,
950    /// and install the result into `vendor/`. With no packages the whole
951    /// graph re-resolves; naming packages does a partial update, leaving
952    /// every other locked package pinned. `--no-install` stops after
953    /// writing the lock; `--dry-run` previews without writing. Aliased to
954    /// `upgrade` / `u`
955    #[command(visible_alias = "upgrade", alias = "u")]
956    Update {
957        /// Packages to update (`vendor/name`). When given, only these
958        /// packages re-resolve; every other package stays pinned to its
959        /// `composer.lock` version. With no packages, the whole graph
960        /// re-resolves from scratch
961        #[arg(value_name = "PACKAGES")]
962        packages: Vec<String>,
963        /// Write the lock but don't install into `vendor/`
964        #[arg(long = "no-install")]
965        no_install: bool,
966        /// Also update the named packages' dependencies (`-w`)
967        #[arg(short = 'w', long = "with-dependencies")]
968        with_dependencies: bool,
969        /// Also update all of the named packages' dependencies, including
970        /// ones shared with other packages (`-W`)
971        #[arg(short = 'W', long = "with-all-dependencies")]
972        with_all_dependencies: bool,
973        /// Run the update in this directory instead of CWD
974        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
975        working_dir: Option<std::path::PathBuf>,
976        /// Skip dev-only root requires when resolving
977        #[arg(long = "no-dev")]
978        no_dev: bool,
979        /// Version-preference policy when resolving
980        #[arg(long = "resolution", value_name = "STRATEGY", default_value = "highest")]
981        resolution: ResolutionStrategy,
982        /// Prefer the lowest matching versions. Equivalent to
983        /// `--resolution lowest`; when set it overrides `--resolution`
984        #[arg(long = "prefer-lowest")]
985        prefer_lowest: bool,
986        /// Resolve and print the solution without writing
987        /// `composer.lock` or touching `vendor/`. Without this flag,
988        /// `update` writes a fresh `composer.lock`
989        #[arg(long = "dry-run")]
990        dry_run: bool,
991        /// Ignore all platform requirements (php, ext-*, lib-*). bougie
992        /// does not enforce platform requirements yet
993        #[arg(long = "ignore-platform-reqs")]
994        ignore_platform_reqs: bool,
995        /// Ignore a specific platform requirement
996        #[arg(long = "ignore-platform-req", value_name = "REQ")]
997        ignore_platform_req: Vec<String>,
998    },
999    /// Validate composer.json structure and contents
1000    Validate {
1001        /// Run in this directory instead of CWD
1002        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1003        working_dir: Option<std::path::PathBuf>,
1004        /// Return non-zero exit code for warnings too
1005        #[arg(long)]
1006        strict: bool,
1007        /// Skip lock file freshness check
1008        #[arg(long = "no-check-lock")]
1009        no_check_lock: bool,
1010        /// Skip publish-only checks (name casing, required fields)
1011        #[arg(long = "no-check-publish")]
1012        no_check_publish: bool,
1013        /// Skip unbound/exact version constraint warnings
1014        #[arg(long = "no-check-all")]
1015        no_check_all: bool,
1016        /// Also validate installed dependencies' composer.json files
1017        #[arg(long = "with-dependencies")]
1018        with_dependencies: bool,
1019        /// Force lock file checking even when `config.lock` is false
1020        #[arg(long = "check-lock")]
1021        check_lock: bool,
1022    },
1023    /// Regenerate the autoloader files
1024    ///
1025    /// Regenerate `vendor/composer/autoload_*.php` against the current
1026    /// `composer.lock`. Aliased to `dump-autoload`
1027    #[command(alias = "dump-autoload")]
1028    DumpAutoloader {
1029        /// Optimize the classmap (`--optimize` / `-o`)
1030        #[arg(short = 'o', long = "optimize", alias = "optimize-autoloader")]
1031        optimize: bool,
1032        /// Emit the classmap-authoritative static loader
1033        /// (`--classmap-authoritative` / `-a`). Implies `--optimize`
1034        #[arg(short = 'a', long = "classmap-authoritative")]
1035        classmap_authoritative: bool,
1036        /// Skip dev autoload entries (`--no-dev`)
1037        #[arg(long = "no-dev")]
1038        no_dev: bool,
1039        /// Emit the `APCu` loader bootstrap (`--apcu-autoloader`)
1040        #[arg(long = "apcu-autoloader")]
1041        apcu_autoloader: bool,
1042        /// Explicit `APCu` prefix; implies `--apcu-autoloader`
1043        #[arg(long = "apcu-autoloader-prefix", value_name = "PREFIX")]
1044        apcu_prefix: Option<String>,
1045        /// Override the `ComposerAutoloaderInit<X>` class suffix —
1046        /// otherwise the value from `composer.json`'s
1047        /// `config.autoloader-suffix`, or the `composer.lock`
1048        /// content-hash
1049        #[arg(long = "autoloader-suffix", value_name = "SUFFIX")]
1050        autoloader_suffix: Option<String>,
1051        /// Run the dump in this directory instead of the current one
1052        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1053        working_dir: Option<std::path::PathBuf>,
1054    },
1055    /// Add packages to `composer.json` and install them
1056    ///
1057    /// Add one or more packages to `composer.json` `require` (or
1058    /// `require-dev`), re-resolve `composer.lock`, and install them. A
1059    /// bare `vendor/pkg` resolves the latest stable and writes a caret
1060    /// (`^X.Y`) constraint; set an explicit constraint with
1061    /// `vendor/pkg:^1.0`, `vendor/pkg=^1.0`, or a trailing argument
1062    /// (`vendor/pkg ^1.0`)
1063    Require {
1064        /// Packages to require (`vendor/pkg` or `vendor/pkg:<constraint>`)
1065        #[arg(value_name = "PACKAGES", required = true)]
1066        packages: Vec<String>,
1067        /// Add to `require-dev` instead of `require`
1068        #[arg(long = "dev")]
1069        dev: bool,
1070        /// Edit `composer.json` only — don't re-resolve `composer.lock`
1071        /// or touch `vendor/`
1072        #[arg(long = "no-update")]
1073        no_update: bool,
1074        /// Re-resolve and write `composer.lock` but don't install into
1075        /// `vendor/`
1076        #[arg(long = "no-install")]
1077        no_install: bool,
1078        /// Also update the new packages' dependencies (`-w`)
1079        #[arg(short = 'w', long = "with-dependencies")]
1080        with_dependencies: bool,
1081        /// Also update all dependencies, including shared ones (`-W`)
1082        #[arg(short = 'W', long = "with-all-dependencies")]
1083        with_all_dependencies: bool,
1084        /// Prefer the lowest matching versions when resolving
1085        #[arg(long = "prefer-lowest")]
1086        prefer_lowest: bool,
1087        /// Ignore all platform requirements (php, ext-*, lib-*)
1088        #[arg(long = "ignore-platform-reqs")]
1089        ignore_platform_reqs: bool,
1090        /// Ignore a specific platform requirement
1091        #[arg(long = "ignore-platform-req", value_name = "REQ")]
1092        ignore_platform_req: Vec<String>,
1093        /// Run in this directory instead of CWD (`-d`)
1094        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1095        working_dir: Option<std::path::PathBuf>,
1096        /// Resolve and report what would change without writing
1097        /// `composer.json`, `composer.lock`, or `vendor/`
1098        #[arg(long = "dry-run")]
1099        dry_run: bool,
1100    },
1101    /// Remove packages and uninstall them from `vendor/`
1102    ///
1103    /// Remove one or more packages from `composer.json`, re-resolve
1104    /// `composer.lock`, and uninstall them from `vendor/`
1105    Remove {
1106        /// Packages to remove (`vendor/name`)
1107        #[arg(value_name = "PACKAGES", required = true)]
1108        packages: Vec<String>,
1109        /// Remove from `require-dev` instead of `require`
1110        #[arg(long = "dev")]
1111        dev: bool,
1112        /// Edit `composer.json` only — don't re-resolve or touch
1113        /// `vendor/`
1114        #[arg(long = "no-update")]
1115        no_update: bool,
1116        /// Re-resolve and write `composer.lock` but don't touch
1117        /// `vendor/`
1118        #[arg(long = "no-install")]
1119        no_install: bool,
1120        /// Skip dev-only packages when resolving
1121        #[arg(long = "no-dev")]
1122        no_dev: bool,
1123        /// Ignore all platform requirements (php, ext-*, lib-*)
1124        #[arg(long = "ignore-platform-reqs")]
1125        ignore_platform_reqs: bool,
1126        /// Ignore a specific platform requirement
1127        #[arg(long = "ignore-platform-req", value_name = "REQ")]
1128        ignore_platform_req: Vec<String>,
1129        /// Run in this directory instead of CWD (`-d`)
1130        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1131        working_dir: Option<std::path::PathBuf>,
1132        /// Resolve and report what would change without writing
1133        /// `composer.json`, `composer.lock`, or `vendor/`
1134        #[arg(long = "dry-run")]
1135        dry_run: bool,
1136    },
1137    /// List installed packages, or show details for one
1138    ///
1139    /// Reads the project's `composer.lock`. Aliases `info`, `list`
1140    #[command(alias = "info", alias = "list")]
1141    Show {
1142        /// A single `vendor/name` to show details for. With no argument,
1143        /// every installed package is listed
1144        #[arg(value_name = "PACKAGE")]
1145        package: Option<String>,
1146        /// Render the dependency tree (`--tree` / `-t`)
1147        #[arg(short = 't', long = "tree")]
1148        tree: bool,
1149        /// Only the project's direct dependencies (`--direct` / `-D`)
1150        #[arg(short = 'D', long = "direct")]
1151        direct: bool,
1152        /// Only platform packages — php, ext-*, lib-* (`--platform` / `-p`)
1153        #[arg(short = 'p', long = "platform")]
1154        platform: bool,
1155        /// Show the root package's own info (`--self` / `-s`)
1156        #[arg(short = 's', long = "self")]
1157        self_: bool,
1158        /// Print package names only (`--name-only` / `-N`)
1159        #[arg(short = 'N', long = "name-only")]
1160        name_only: bool,
1161        /// Show each package's install path (`--path` / `-P`)
1162        #[arg(short = 'P', long = "path")]
1163        path: bool,
1164        /// Also fetch and show the latest available version
1165        /// (`--latest` / `-l`)
1166        #[arg(short = 'l', long = "latest")]
1167        latest: bool,
1168        /// Only packages with a newer version available
1169        /// (`--outdated` / `-o`). Implies `--latest`
1170        #[arg(short = 'o', long = "outdated")]
1171        outdated: bool,
1172        /// Skip dev dependencies (`--no-dev`)
1173        #[arg(long = "no-dev")]
1174        no_dev: bool,
1175        /// Run in this directory instead of CWD (`-d`)
1176        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1177        working_dir: Option<std::path::PathBuf>,
1178    },
1179    /// Show which packages depend on a given package
1180    ///
1181    /// Shows why a package is installed. Alias `depends`
1182    #[command(alias = "depends")]
1183    Why {
1184        /// The package to explain
1185        #[arg(value_name = "PACKAGE", required = true)]
1186        package: String,
1187        /// Recurse through the dependency chain (`--recursive` / `-r`)
1188        #[arg(short = 'r', long = "recursive")]
1189        recursive: bool,
1190        /// Render the full dependency-of tree (`--tree` / `-t`)
1191        #[arg(short = 't', long = "tree")]
1192        tree: 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    /// Show what prevents a package from being installed
1198    ///
1199    /// Reports the conflicting requirements for a package, optionally at
1200    /// a given version. Alias `prohibits`
1201    #[command(name = "why-not", alias = "prohibits")]
1202    WhyNot {
1203        /// The package to test
1204        #[arg(value_name = "PACKAGE", required = true)]
1205        package: String,
1206        /// The version (or constraint) to test against. Defaults to `*`
1207        #[arg(value_name = "VERSION")]
1208        version: Option<String>,
1209        /// Recurse through the dependency chain (`--recursive` / `-r`)
1210        #[arg(short = 'r', long = "recursive")]
1211        recursive: bool,
1212        /// Render the full tree (`--tree` / `-t`)
1213        #[arg(short = 't', long = "tree")]
1214        tree: bool,
1215        /// Run in this directory instead of CWD (`-d`)
1216        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1217        working_dir: Option<std::path::PathBuf>,
1218    },
1219    /// List installed packages with a newer version available
1220    ///
1221    /// Use the global `--format json` for JSON output
1222    Outdated {
1223        /// Optional `vendor/name` filters; with none, all packages are
1224        /// considered
1225        #[arg(value_name = "PACKAGES")]
1226        packages: Vec<String>,
1227        /// Only the project's direct dependencies (`--direct` / `-D`)
1228        #[arg(short = 'D', long = "direct")]
1229        direct: bool,
1230        /// Only show packages with a new major version (`--major-only`)
1231        #[arg(long = "major-only")]
1232        major_only: bool,
1233        /// Only show packages with a new minor version (`--minor-only`)
1234        #[arg(long = "minor-only")]
1235        minor_only: bool,
1236        /// Only show packages with a new patch version (`--patch-only`)
1237        #[arg(long = "patch-only")]
1238        patch_only: bool,
1239        /// Skip dev dependencies (`--no-dev`)
1240        #[arg(long = "no-dev")]
1241        no_dev: bool,
1242        /// Exit non-zero if any package is outdated (`--strict`)
1243        #[arg(long = "strict")]
1244        strict: bool,
1245        /// Run in this directory instead of CWD (`-d`)
1246        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1247        working_dir: Option<std::path::PathBuf>,
1248    },
1249    /// Check installed packages for security advisories
1250    ///
1251    /// Checks against the Packagist security-advisories database. Exits
1252    /// non-zero when advisories are found. Use the global `--format json`
1253    /// for JSON
1254    Audit {
1255        /// Skip dev dependencies (`--no-dev`)
1256        #[arg(long = "no-dev")]
1257        no_dev: bool,
1258        /// How to treat abandoned packages. Detection is not yet wired
1259        /// up
1260        #[arg(long = "abandoned", value_enum, default_value = "report")]
1261        abandoned: AbandonedHandling,
1262        /// Audit the locked set. bougie always reads `composer.lock`, so
1263        /// this is the default
1264        #[arg(long = "locked")]
1265        locked: bool,
1266        /// Run in this directory instead of CWD (`-d`)
1267        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1268        working_dir: Option<std::path::PathBuf>,
1269    },
1270    /// List the license of every installed package
1271    ///
1272    /// Use the global `--format json` for JSON
1273    Licenses {
1274        /// Skip dev dependencies (`--no-dev`)
1275        #[arg(long = "no-dev")]
1276        no_dev: bool,
1277        /// Run in this directory instead of CWD (`-d`)
1278        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1279        working_dir: Option<std::path::PathBuf>,
1280    },
1281    /// Report packages that look locally modified
1282    ///
1283    /// bougie installs from dist archives, so for the common case this
1284    /// reports "no local changes"
1285    Status {
1286        /// Run in this directory instead of CWD (`-d`)
1287        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1288        working_dir: Option<std::path::PathBuf>,
1289    },
1290    /// Show funding information for installed packages
1291    ///
1292    /// Grouped by vendor. Use `--format json` for JSON
1293    Fund {
1294        /// Skip dev dependencies (`--no-dev`)
1295        #[arg(long = "no-dev")]
1296        no_dev: bool,
1297        /// Run in this directory instead of CWD (`-d`)
1298        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1299        working_dir: Option<std::path::PathBuf>,
1300    },
1301    /// Catch-all for any composer subcommand bougie does not implement
1302    /// natively (`create-project`, `archive`, `bump`, `global`, …). These
1303    /// return an error pointing at `bougie tool install composer/composer`
1304    #[command(external_subcommand)]
1305    External(Vec<OsString>),
1306}
1307
1308/// How `composer audit` treats abandoned packages.
1309#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
1310pub enum AbandonedHandling {
1311    /// Ignore abandoned packages entirely
1312    Ignore,
1313    /// Report abandoned packages but don't fail on them
1314    Report,
1315    /// Treat abandoned packages as an audit failure
1316    Fail,
1317}
1318
1319#[derive(Subcommand, Debug)]
1320pub enum CacheCommand {
1321    /// Wipe the full cache
1322    Clean,
1323    /// Remove unneeded library files
1324    Prune {
1325        /// Show what would be pruned without removing anything
1326        #[arg(long)]
1327        dry_run: bool,
1328        /// Also remove tracked projects that no longer exist on disk
1329        #[arg(long)]
1330        prune_projects: bool,
1331    },
1332    /// Show the location of the cache directory
1333    Dir,
1334    /// Show the cache size
1335    Size,
1336}
1337
1338#[derive(Subcommand, Debug)]
1339pub enum ToolCommand {
1340    /// Install a tool. Pass `<vendor>/<name>` optionally followed by
1341    /// `@<constraint>` (e.g. `phpstan/phpstan@^1.10`)
1342    Install {
1343        /// Composer package identifier, optionally with `@<constraint>`
1344        package: String,
1345        /// Pin the tool to a specific PHP. Accepts a version (`8.3`,
1346        /// `8.3.12`) or a constraint (`~8.3`, `>=8.2,<8.4`). When the
1347        /// requested PHP isn't installed, bougie installs it
1348        /// automatically. Defaults to the highest installed NTS PHP
1349        #[arg(long, value_name = "VER")]
1350        php: Option<String>,
1351        /// Additional Composer package (`vendor/name[@<constraint>]`)
1352        /// or PHP extension (`intl`, `redis`) to install alongside the
1353        /// tool. May be passed multiple times
1354        #[arg(long, value_name = "PKG_OR_EXT")]
1355        with: Vec<String>,
1356        /// Overwrite an existing executable at the bin-dir path
1357        #[arg(long)]
1358        force: bool,
1359    },
1360    /// Remove an installed tool by its `<vendor>/<name>` identifier
1361    Uninstall {
1362        /// Composer package identifier
1363        package: String,
1364    },
1365    /// Add an extra composer package or PHP extension to an
1366    /// installed tool. Re-resolves the tool's lock and updates the
1367    /// vendor tree in place
1368    Inject {
1369        /// Composer package identifier of the tool
1370        package: String,
1371        /// Extra to add (`vendor/name[@<constraint>]` for composer
1372        /// packages, bare name for PHP extensions). Repeatable
1373        #[arg(long, value_name = "PKG_OR_EXT", required = true)]
1374        with: Vec<String>,
1375    },
1376    /// Remove an extra previously added via `--with` / `inject`
1377    Uninject {
1378        /// Composer package identifier of the tool
1379        package: String,
1380        /// Extra to remove. Repeatable
1381        #[arg(long, value_name = "PKG_OR_EXT", required = true)]
1382        with: Vec<String>,
1383    },
1384    /// List installed tools
1385    List,
1386    /// Print a tool's install directory, or the tools root if no
1387    /// package is given
1388    Dir {
1389        /// Composer package identifier; omit to print the tools root
1390        package: Option<String>,
1391    },
1392    /// Run an installed-or-cached tool one-off. Reuses an existing
1393    /// persistent install if `(package, constraint, php, with)` match
1394    /// exactly; otherwise materialises into the ephemeral cache.
1395    ///
1396    /// `bgx` is provided as a convenient alias for `bougie tool run`;
1397    /// their behavior is identical
1398    #[command(
1399        override_usage = "bougie tool run [OPTIONS] <PACKAGE> [ARGS]...",
1400        after_help = "Use `bgx` as a shortcut for `bougie tool run`.\n\n\
1401                      Use `bougie help tool run` for more details",
1402        after_long_help = ""
1403    )]
1404    Run(ToolRunArgs),
1405    // Hidden alias for `bougie tool run` for the `bgx` command. The
1406    // variant is reached only via the `bgx` binary exec'ing into it;
1407    // it doesn't surface under `bougie tool --help`. Carrying it as
1408    // a separate variant (with `display_name`, `override_usage`)
1409    // lets clap render `bgx --help` and clap-level error messages
1410    // with `bgx` as the program name rather than leaking
1411    // `bougie tool run`.
1412    #[command(
1413        hide = true,
1414        override_usage = "bgx [OPTIONS] <PACKAGE> [ARGS]...",
1415        about = "Run a tool from a PHP package",
1416        long_about = None,
1417        after_help = "Use `bougie help tool run` for more details",
1418        after_long_help = "",
1419        display_name = "bgx",
1420        // `bgx --version` / `bgx -V` exec into `bougie tool bgx`; give
1421        // this variant its own version flag so it short-circuits before
1422        // the required `<PACKAGE>` positional and prints `bgx <version>`.
1423        version = LONG_VERSION
1424    )]
1425    Bgx(BgxArgs),
1426    /// Re-resolve a tool's lock and bring its vendor tree up to date.
1427    /// Pass `--all` to walk every installed tool, or `--reinstall` to
1428    /// wipe and rebuild from scratch (recovery for broken state)
1429    Upgrade {
1430        /// Composer package identifier. Required unless `--all`
1431        #[arg(required_unless_present = "all", conflicts_with = "all")]
1432        package: Option<String>,
1433        /// Upgrade every installed tool
1434        #[arg(long)]
1435        all: bool,
1436        /// Wipe the tool dir + every entrypoint symlink and reinstall
1437        /// from scratch using the receipt's pinned `(package,
1438        /// constraint, php_version, with, extensions)` tuple
1439        #[arg(long)]
1440        reinstall: bool,
1441    },
1442}
1443
1444#[derive(Subcommand, Debug)]
1445pub enum SelfCommand {
1446    /// Update bougie
1447    Update {
1448        /// Update even when bougie can't confirm it installed this
1449        /// binary. By default `self update` only touches a binary that
1450        /// bougie's own installer placed (per the install receipt);
1451        /// copies from a package manager, cargo, or nix are left for
1452        /// that tool to update. Pass `--force` only if you know this
1453        /// copy came from bougie's installer
1454        #[arg(long)]
1455        force: bool,
1456    },
1457    /// Show bougie's version
1458    Version {
1459        /// Only show the version
1460        #[arg(long)]
1461        short: bool,
1462    },
1463}
1464
1465#[derive(Args, Debug)]
1466pub struct ToolRunArgs {
1467    /// Pin the tool to a specific PHP for this run
1468    #[arg(long, value_name = "VER")]
1469    pub php: Option<String>,
1470    /// Extra composer package or PHP extension, same shape as
1471    /// `tool install --with`. Repeatable
1472    #[arg(long, value_name = "PKG_OR_EXT")]
1473    pub with: Vec<String>,
1474    /// The tool's Composer package (optionally `@<constraint>`) followed
1475    /// by the arguments to forward to it. bougie's own options must come
1476    /// *before* the package; everything from the package onward is passed
1477    /// to the tool verbatim, so no `--` separator is needed
1478    #[arg(
1479        trailing_var_arg = true,
1480        allow_hyphen_values = true,
1481        required = true,
1482        value_name = "PACKAGE"
1483    )]
1484    pub command: Vec<std::ffi::OsString>,
1485}
1486
1487/// Args for the hidden `bgx` alias. Wraps [`ToolRunArgs`] verbatim so
1488/// the two variants share their entire surface; the wrapper exists
1489/// only so clap renders help / errors with `bgx` as the program name.
1490#[derive(Args, Debug)]
1491pub struct BgxArgs {
1492    #[command(flatten)]
1493    pub tool_run: ToolRunArgs,
1494}
1495
1496#[cfg(test)]
1497mod tests {
1498    use super::*;
1499    use clap::Parser;
1500
1501    fn cmd(argv: &[&str]) -> Command {
1502        Cli::try_parse_from(argv).expect("parse").command
1503    }
1504
1505    #[test]
1506    fn start_is_its_own_verb() {
1507        assert!(matches!(cmd(&["bougie", "start"]), Command::Start { .. }));
1508        assert!(matches!(
1509            cmd(&["bougie", "start", "--no-sync", "--dry-run"]),
1510            Command::Start { no_sync: true, dry_run: true, .. }
1511        ));
1512    }
1513
1514    #[test]
1515    fn stop_takes_names_and_purge() {
1516        let Command::Stop { names, purge } = cmd(&["bougie", "stop", "redis", "--purge"]) else {
1517            panic!("expected stop");
1518        };
1519        assert_eq!(names, ["redis"]);
1520        assert!(purge);
1521    }
1522
1523    #[test]
1524    fn up_down_live_under_services() {
1525        assert!(matches!(
1526            cmd(&["bougie", "services", "up", "redis", "-d"]),
1527            Command::Services(ServicesCommand::Up { detach: true, .. })
1528        ));
1529        assert!(matches!(
1530            cmd(&["bougie", "services", "down", "--purge"]),
1531            Command::Services(ServicesCommand::Down { purge: true, .. })
1532        ));
1533    }
1534
1535    #[test]
1536    fn top_level_up_down_are_gone() {
1537        // The deprecated top-level aliases were removed; `up`/`down` only
1538        // exist under `services` now.
1539        assert!(Cli::try_parse_from(["bougie", "up"]).is_err());
1540        assert!(Cli::try_parse_from(["bougie", "down"]).is_err());
1541    }
1542
1543    #[test]
1544    fn server_detach_flag() {
1545        for argv in [
1546            &["bougie", "server", "-d"][..],
1547            &["bougie", "server", "--detach"][..],
1548        ] {
1549            let Command::Server(args) = cmd(argv) else {
1550                panic!("expected server for {argv:?}");
1551            };
1552            assert!(args.serve.detach, "detach should be set for {argv:?}");
1553        }
1554        // The old `--no-attach` spelling is gone.
1555        assert!(Cli::try_parse_from(["bougie", "server", "--no-attach"]).is_err());
1556    }
1557
1558    #[test]
1559    fn make_no_longer_aliases_start() {
1560        // `start` is no longer a clap alias of `make`; it's the
1561        // first-class verb above. `bougie make start` is just `make`
1562        // with the literal task `start`.
1563        let Command::Make { task, .. } = cmd(&["bougie", "make", "start"]) else {
1564            panic!("expected make");
1565        };
1566        assert_eq!(task.as_deref(), Some("start"));
1567
1568        // Bare `bougie make` parses with no task; the dispatcher turns
1569        // that into a task listing.
1570        let Command::Make { task, .. } = cmd(&["bougie", "make"]) else {
1571            panic!("expected make");
1572        };
1573        assert_eq!(task, None);
1574    }
1575}