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