Skip to main content

xbp_cli/cli/
mod.rs

1pub mod app;
2pub mod auto_commit;
3pub mod commands;
4pub mod error;
5pub mod features;
6pub mod handlers;
7pub mod help_render;
8pub mod router;
9pub mod ui;
10
11pub use handlers::*;
12
13use crate::cli::app::AppContext;
14use crate::cli::error::{CliError, CliResult, ErrorFactory};
15use crate::commands::cli_session::{
16    require_authenticated_cli_session, run_login_status, run_logout, run_whoami,
17};
18use crate::commands::curl;
19use crate::commands::generate_systemd::{run_generate_systemd, GenerateSystemdArgs};
20use crate::commands::redeploy_v2::run_redeploy_v2;
21use crate::commands::{
22    install_package, list_services, open_global_config, pick_service_for_command, run_commit,
23    run_config, run_config_cloudflare_account_delete, run_config_cloudflare_account_set,
24    run_config_cloudflare_account_show, run_config_cloudflare_login, run_config_cloudflare_setup,
25    run_config_cloudflare_status, run_config_crates_login, run_config_crates_logout,
26    run_config_linear_select_initiative, run_config_publish_setup, run_config_release_setup,
27    run_config_secret_delete, run_config_secret_set, run_config_secret_show, run_cursor_ingest,
28    run_generate_config, run_init, run_login, run_publish_command, run_redeploy,
29    run_redeploy_service, run_service_command, run_service_interactive, run_setup,
30    run_version_bump_command, run_version_command, run_version_discover_services,
31    run_version_domain_command, run_version_release_command, run_version_workspace_command,
32    show_service_help, CommitArgs, CommitError, CommitRunOutcome, GenerateConfigArgs,
33    PublishCommandOptions, ReleaseLatestPolicy, VersionDomainCommand, VersionDomainCommandOptions,
34    VersionDomainDiagnoseOptions, VersionDomainDoctorOptions, VersionDomainInitOptions,
35    VersionDomainReleaseOptions, VersionDomainSyncOptions, VersionReleaseOptions,
36    WorkspacePublishPlanOptions, WorkspacePublishRunOptions, WorkspaceVersionCheckOptions,
37    WorkspaceVersionCommand, WorkspaceVersionCommandOptions, WorkspaceVersionSyncOptions,
38    WorkspaceVersionValidateOptions,
39};
40use crate::commands::{run_diag, run_nginx};
41use crate::config::sync_versioning_files_registry;
42use crate::logging::{init_logger, log_error, log_info, log_success, log_warn};
43use clap::{error::ErrorKind as ClapErrorKind, Parser};
44use colored::Colorize;
45use commands::Cli;
46
47pub async fn run() -> CliResult<()> {
48    let cli: Cli = match Cli::try_parse() {
49        Ok(cli) => cli,
50        Err(err) => {
51            let kind = err.kind();
52            let rendered = err.to_string();
53            if kind == ClapErrorKind::DisplayVersion {
54                help_render::emit_version_line(env!("CARGO_PKG_VERSION"));
55                return Ok(());
56            }
57            if kind == ClapErrorKind::DisplayHelp
58                || kind == ClapErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
59                || rendered.contains("Manage the XBP API server")
60            {
61                ui::configure_color_output();
62                if help_render::is_root_help_text(&rendered) {
63                    crate::commands::print_help().await;
64                } else {
65                    help_render::emit_styled_help(&rendered, help_render::HelpScope::Auto);
66                }
67                return Ok(());
68            }
69            return Err(ErrorFactory::clap_parse(err));
70        }
71    };
72
73    if cli.commands {
74        help_render::print_command_catalog();
75        return Ok(());
76    }
77
78    // Keep plain `xbp` aligned with `xbp --help` instead of entering
79    // interactive project selection flow.
80    if should_print_help(&cli) {
81        ui::configure_color_output();
82        crate::commands::print_help().await;
83        return Ok(());
84    }
85
86    let debug: bool = cli.debug;
87    ui::configure_color_output();
88    let command_name = cli
89        .command
90        .as_ref()
91        .map(commands::command_label)
92        .unwrap_or("interactive");
93    ui::print_cli_header(command_name, debug);
94
95    if let Err(e) = init_logger(debug).await {
96        let _ = log_error(
97            "system",
98            "Failed to initialize logger",
99            Some(&e.to_string()),
100        )
101        .await;
102    }
103    if let Err(e) = sync_versioning_files_registry() {
104        let _ = log_warn("config", "Failed to sync versioning registry", Some(&e)).await;
105    }
106
107    let background_cursor_trigger =
108        background_cursor_ingest_trigger(cli.command.as_ref()).map(str::to_string);
109    let background_system_inventory_trigger =
110        background_system_inventory_refresh_trigger(cli.command.as_ref()).map(str::to_string);
111    let mut ctx = AppContext::new(debug);
112    let result = router::dispatch(cli, &mut ctx).await;
113
114    if result.is_ok() {
115        if let Some(trigger) = background_cursor_trigger {
116            if let Err(error) =
117                crate::commands::cursor_ingest::maybe_start_background_cursor_ingest(&trigger)
118            {
119                let _ = log_warn(
120                    "cursor",
121                    "Background Cursor ingest could not be started",
122                    Some(&error),
123                )
124                .await;
125            }
126        }
127        if let Some(trigger) = background_system_inventory_trigger {
128            if let Err(error) = crate::commands::system_inventory_refresh::
129                maybe_start_background_system_inventory_refresh(&trigger)
130            {
131                let _ = log_warn(
132                    "codetime",
133                    "Background system inventory refresh could not be started",
134                    Some(&error),
135                )
136                .await;
137            }
138        }
139    }
140
141    result
142}
143
144pub(super) async fn handle_init(debug: bool) -> CliResult<()> {
145    if let Err(e) = run_init(debug).await {
146        let _ = log_error("init", "Init failed", Some(&e)).await;
147        return Err(e.into());
148    }
149    Ok(())
150}
151
152pub(super) async fn handle_commit(cmd: commands::CommitCmd) -> CliResult<()> {
153    let args = CommitArgs {
154        dry_run: cmd.dry_run,
155        push: cmd.push,
156        no_ai: cmd.no_ai,
157        model: cmd.model,
158        scope: cmd.scope,
159    };
160
161    match run_commit(args).await {
162        Ok(CommitRunOutcome::Completed) | Ok(CommitRunOutcome::NothingToCommit) => Ok(()),
163        Err(error) => Err(map_commit_error(error)),
164    }
165}
166
167fn map_commit_error(error: CommitError) -> CliError {
168    match error {
169        CommitError::PushFailed {
170            summary,
171            commit_sha,
172        } => {
173            let details = if let Some(commit_sha) = commit_sha {
174                format!("Created local commit {commit_sha} but push failed: {summary}")
175            } else {
176                format!("Push failed: {summary}")
177            };
178            ErrorFactory::operation(
179                "commit",
180                "push changes",
181                details,
182                commit_push_hint(&summary),
183            )
184        }
185        CommitError::TerminalSessionsBlocked(message) => ErrorFactory::validation(
186            "commit",
187            message,
188            Some("Add `/terminals` to `.gitignore` and run `git rm -r --cached terminals/`."),
189        ),
190        CommitError::Operation(message) => ErrorFactory::operation(
191            "commit",
192            "create conventional commit",
193            message,
194            Some("Run `xbp commit --dry-run` after saving changes."),
195        ),
196    }
197}
198
199fn commit_push_hint(summary: &str) -> Option<&'static str> {
200    let lowered = summary.to_ascii_lowercase();
201    if lowered.contains("behind") || lowered.contains("non-fast-forward") {
202        Some("Run `git pull --rebase origin <branch>` and then `git push`.")
203    } else {
204        Some("Verify GitHub auth and branch permissions, then push again.")
205    }
206}
207
208pub(super) async fn handle_publish(cmd: commands::PublishCmd, _debug: bool) -> CliResult<()> {
209    let options = PublishCommandOptions {
210        dry_run: cmd.dry_run,
211        allow_dirty: cmd.allow_dirty,
212        force: cmd.force,
213        include_prereqs: cmd.include_prereqs,
214        target: cmd.target,
215        manifest_path: cmd.manifest_path,
216        expected_version: None,
217    };
218
219    if let Err(e) = run_publish_command(options).await {
220        let _ = log_error("publish", "Publish command failed", Some(&e)).await;
221        return Err(ErrorFactory::operation(
222            "publish",
223            "run publish workflow",
224            e,
225            Some("Configure project targets with `xbp config npm setup-release` or `xbp config crates setup-release`."),
226        ));
227    }
228
229    Ok(())
230}
231
232pub(super) async fn handle_setup(debug: bool) -> CliResult<()> {
233    if let Err(e) = ui::with_loader("Running setup checks", run_setup(debug)).await {
234        let _ = log_error("setup", "Setup failed", Some(&e)).await;
235        return Err(ErrorFactory::operation(
236            "setup",
237            "setup environment",
238            e,
239            Some("Run with `--debug` for command-level output."),
240        ));
241    }
242    Ok(())
243}
244
245pub(super) async fn handle_redeploy(service_name: Option<String>, debug: bool) -> CliResult<()> {
246    if let Some(name) = service_name {
247        if let Err(e) = ui::with_loader(
248            &format!("Redeploying service `{}`", name),
249            run_redeploy_service(&name, debug),
250        )
251        .await
252        {
253            let _ = log_error("redeploy", "Service redeploy failed", Some(&e)).await;
254            return Err(ErrorFactory::operation(
255                "redeploy",
256                &format!("redeploy service `{}`", name),
257                e,
258                Some("Verify service name with `xbp services`."),
259            ));
260        }
261    } else if let Err(e) = ui::with_loader("Redeploying full project", run_redeploy()).await {
262        let _ = log_error("redeploy", "Redeploy failed", Some(&e)).await;
263        return Err(ErrorFactory::operation(
264            "redeploy",
265            "redeploy project",
266            e,
267            Some("Try `xbp redeploy <service>` for scoped retries."),
268        ));
269    }
270    Ok(())
271}
272
273pub(super) async fn handle_redeploy_v2(cmd: commands::RedeployV2Cmd, debug: bool) -> CliResult<()> {
274    let _ = log_info("redeploy_v2", "Starting remote redeploy process", None).await;
275    match run_redeploy_v2(cmd.password, cmd.username, cmd.host, cmd.project_dir, debug).await {
276        Ok(()) => Ok(()),
277        Err(e) => {
278            let _ = log_error("redeploy_v2", "Remote redeploy failed", Some(&e)).await;
279            Err(e.into())
280        }
281    }
282}
283
284pub(super) async fn handle_config(cmd: commands::ConfigCmd, debug: bool) -> CliResult<()> {
285    let commands::ConfigCmd {
286        project,
287        no_open,
288        provider,
289    } = cmd;
290
291    if provider.is_some() && (project || no_open) {
292        return Err(ErrorFactory::validation(
293            "config",
294            "`xbp config <provider> ...` cannot be combined with `--project` or `--no-open`.",
295            Some("Run either provider key management OR project/global config actions."),
296        ));
297    }
298
299    if let Some(provider_cmd) = provider {
300        match provider_cmd {
301            commands::ConfigProviderCmd::Openrouter(subcmd) => match subcmd.action {
302                commands::ConfigSecretAction::SetKey { key } => {
303                    if let Err(e) = run_config_secret_set("openrouter", key).await {
304                        let _ = log_error("config", "Failed to set OpenRouter key", Some(&e)).await;
305                        return Err(ErrorFactory::operation(
306                            "config",
307                            "set OpenRouter key",
308                            e,
309                            Some("Run `xbp config openrouter show` to confirm key state."),
310                        ));
311                    }
312                }
313                commands::ConfigSecretAction::DeleteKey => {
314                    if let Err(e) = run_config_secret_delete("openrouter").await {
315                        let _ =
316                            log_error("config", "Failed to delete OpenRouter key", Some(&e)).await;
317                        return Err(ErrorFactory::operation(
318                            "config",
319                            "delete OpenRouter key",
320                            e,
321                            Some("Use `xbp config openrouter show` to verify removal."),
322                        ));
323                    }
324                }
325                commands::ConfigSecretAction::Show { raw } => {
326                    if let Err(e) = run_config_secret_show("openrouter", raw).await {
327                        let _ =
328                            log_error("config", "Failed to show OpenRouter key", Some(&e)).await;
329                        return Err(ErrorFactory::operation(
330                            "config",
331                            "show OpenRouter key",
332                            e,
333                            None,
334                        ));
335                    }
336                }
337            },
338            commands::ConfigProviderCmd::Github(subcmd) => match subcmd.action {
339                commands::ConfigSecretAction::SetKey { key } => {
340                    if let Err(e) = run_config_secret_set("github", key).await {
341                        let _ = log_error("config", "Failed to set GitHub token", Some(&e)).await;
342                        return Err(ErrorFactory::operation(
343                            "config",
344                            "set GitHub token",
345                            e,
346                            Some("Use a token with repo scope for private repos."),
347                        ));
348                    }
349                }
350                commands::ConfigSecretAction::DeleteKey => {
351                    if let Err(e) = run_config_secret_delete("github").await {
352                        let _ =
353                            log_error("config", "Failed to delete GitHub token", Some(&e)).await;
354                        return Err(ErrorFactory::operation(
355                            "config",
356                            "delete GitHub token",
357                            e,
358                            None,
359                        ));
360                    }
361                }
362                commands::ConfigSecretAction::Show { raw } => {
363                    if let Err(e) = run_config_secret_show("github", raw).await {
364                        let _ = log_error("config", "Failed to show GitHub token", Some(&e)).await;
365                        return Err(ErrorFactory::operation(
366                            "config",
367                            "show GitHub token",
368                            e,
369                            None,
370                        ));
371                    }
372                }
373            },
374            commands::ConfigProviderCmd::Cloudflare(subcmd) => match subcmd.action {
375                None => {
376                    if let Err(e) = run_config_cloudflare_setup().await {
377                        let _ =
378                            log_error("config", "Failed to configure Cloudflare", Some(&e)).await;
379                        return Err(ErrorFactory::operation(
380                            "config",
381                            "configure Cloudflare",
382                            e,
383                            Some(
384                                "Run `xbp config cloudflare status` to inspect credential sources.",
385                            ),
386                        ));
387                    }
388                }
389                Some(commands::CloudflareConfigAction::SetKey { key }) => {
390                    if let Err(e) = run_config_secret_set("cloudflare", key).await {
391                        let _ =
392                            log_error("config", "Failed to set Cloudflare token", Some(&e)).await;
393                        return Err(ErrorFactory::operation(
394                            "config",
395                            "set Cloudflare token",
396                            e,
397                            Some("Use a Cloudflare API token with Secrets Store and DNS permissions."),
398                        ));
399                    }
400                }
401                Some(commands::CloudflareConfigAction::DeleteKey) => {
402                    if let Err(e) = run_config_secret_delete("cloudflare").await {
403                        let _ = log_error("config", "Failed to delete Cloudflare token", Some(&e))
404                            .await;
405                        return Err(ErrorFactory::operation(
406                            "config",
407                            "delete Cloudflare token",
408                            e,
409                            None,
410                        ));
411                    }
412                }
413                Some(commands::CloudflareConfigAction::ShowKey { raw }) => {
414                    if let Err(e) = run_config_secret_show("cloudflare", raw).await {
415                        let _ =
416                            log_error("config", "Failed to show Cloudflare token", Some(&e)).await;
417                        return Err(ErrorFactory::operation(
418                            "config",
419                            "show Cloudflare token",
420                            e,
421                            None,
422                        ));
423                    }
424                }
425                Some(commands::CloudflareConfigAction::SetAccountId { account_id }) => {
426                    if let Err(e) = run_config_cloudflare_account_set(account_id).await {
427                        let _ =
428                            log_error("config", "Failed to set Cloudflare account ID", Some(&e))
429                                .await;
430                        return Err(ErrorFactory::operation(
431                            "config",
432                            "set Cloudflare account ID",
433                            e,
434                            None,
435                        ));
436                    }
437                }
438                Some(commands::CloudflareConfigAction::DeleteAccountId) => {
439                    if let Err(e) = run_config_cloudflare_account_delete().await {
440                        let _ =
441                            log_error("config", "Failed to delete Cloudflare account ID", Some(&e))
442                                .await;
443                        return Err(ErrorFactory::operation(
444                            "config",
445                            "delete Cloudflare account ID",
446                            e,
447                            None,
448                        ));
449                    }
450                }
451                Some(commands::CloudflareConfigAction::ShowAccountId { raw }) => {
452                    if let Err(e) = run_config_cloudflare_account_show(raw).await {
453                        let _ =
454                            log_error("config", "Failed to show Cloudflare account ID", Some(&e))
455                                .await;
456                        return Err(ErrorFactory::operation(
457                            "config",
458                            "show Cloudflare account ID",
459                            e,
460                            None,
461                        ));
462                    }
463                }
464                Some(commands::CloudflareConfigAction::Login) => {
465                    if let Err(e) = run_config_cloudflare_login().await {
466                        let _ = log_error("config", "Failed to link Cloudflare", Some(&e)).await;
467                        return Err(ErrorFactory::operation(
468                            "config",
469                            "link Cloudflare",
470                            e,
471                            None,
472                        ));
473                    }
474                }
475                Some(commands::CloudflareConfigAction::Status) => {
476                    if let Err(e) = run_config_cloudflare_status().await {
477                        let _ =
478                            log_error("config", "Failed to show Cloudflare status", Some(&e)).await;
479                        return Err(ErrorFactory::operation(
480                            "config",
481                            "show Cloudflare status",
482                            e,
483                            None,
484                        ));
485                    }
486                }
487                Some(commands::CloudflareConfigAction::Setup) => {
488                    if let Err(e) = run_config_cloudflare_setup().await {
489                        let _ =
490                            log_error("config", "Failed to configure Cloudflare", Some(&e)).await;
491                        return Err(ErrorFactory::operation(
492                            "config",
493                            "configure Cloudflare",
494                            e,
495                            None,
496                        ));
497                    }
498                }
499            },
500            commands::ConfigProviderCmd::Linear(subcmd) => match subcmd.action {
501                commands::LinearConfigAction::SetKey { key } => {
502                    if let Err(e) = run_config_secret_set("linear", key).await {
503                        let _ = log_error("config", "Failed to set Linear API key", Some(&e)).await;
504                        return Err(ErrorFactory::operation(
505                            "config",
506                            "set Linear API key",
507                            e,
508                            Some("Use this to link Linear issue IDs in generated release notes and publish release updates to Linear initiatives."),
509                        ));
510                    }
511                }
512                commands::LinearConfigAction::DeleteKey => {
513                    if let Err(e) = run_config_secret_delete("linear").await {
514                        let _ =
515                            log_error("config", "Failed to delete Linear API key", Some(&e)).await;
516                        return Err(ErrorFactory::operation(
517                            "config",
518                            "delete Linear API key",
519                            e,
520                            None,
521                        ));
522                    }
523                }
524                commands::LinearConfigAction::Show { raw } => {
525                    if let Err(e) = run_config_secret_show("linear", raw).await {
526                        let _ =
527                            log_error("config", "Failed to show Linear API key", Some(&e)).await;
528                        return Err(ErrorFactory::operation(
529                            "config",
530                            "show Linear API key",
531                            e,
532                            None,
533                        ));
534                    }
535                }
536                commands::LinearConfigAction::SelectInitiative => {
537                    if let Err(e) = run_config_linear_select_initiative().await {
538                        let _ = log_error(
539                            "config",
540                            "Failed to select repo Linear initiative",
541                            Some(&e),
542                        )
543                        .await;
544                        return Err(ErrorFactory::operation(
545                            "config",
546                            "select repo Linear initiative",
547                            e,
548                            Some("Run this inside an XBP project and configure a Linear key with `xbp config linear set-key` first."),
549                        ));
550                    }
551                }
552            },
553            commands::ConfigProviderCmd::Npm(subcmd) => match subcmd.action {
554                commands::RegistryConfigAction::SetKey { key } => {
555                    if let Err(e) = run_config_secret_set("npm", key).await {
556                        let _ = log_error("config", "Failed to set npm token", Some(&e)).await;
557                        return Err(ErrorFactory::operation(
558                            "config",
559                            "set npm token",
560                            e,
561                            Some("Use a valid npm automation or granular publish token."),
562                        ));
563                    }
564                }
565                commands::RegistryConfigAction::DeleteKey => {
566                    if let Err(e) = run_config_secret_delete("npm").await {
567                        let _ = log_error("config", "Failed to delete npm token", Some(&e)).await;
568                        return Err(ErrorFactory::operation(
569                            "config",
570                            "delete npm token",
571                            e,
572                            None,
573                        ));
574                    }
575                }
576                commands::RegistryConfigAction::Show { raw } => {
577                    if let Err(e) = run_config_secret_show("npm", raw).await {
578                        let _ = log_error("config", "Failed to show npm token", Some(&e)).await;
579                        return Err(ErrorFactory::operation("config", "show npm token", e, None));
580                    }
581                }
582                commands::RegistryConfigAction::SetupRelease => {
583                    if let Err(e) = run_config_publish_setup("npm").await {
584                        let _ =
585                            log_error("config", "Failed to configure npm publish", Some(&e)).await;
586                        return Err(ErrorFactory::operation(
587                            "config",
588                            "configure npm publish",
589                            e,
590                            Some("Run this inside the target XBP project and ensure the package manifest exists."),
591                        ));
592                    }
593                }
594            },
595            commands::ConfigProviderCmd::Crates(subcmd) => match subcmd.action {
596                commands::CratesConfigAction::SetKey { key } => {
597                    if let Err(e) = run_config_secret_set("crates", key).await {
598                        let _ = log_error("config", "Failed to set crates token", Some(&e)).await;
599                        return Err(ErrorFactory::operation(
600                            "config",
601                            "set crates token",
602                            e,
603                            Some("Use a crates.io API token or rely on CARGO_REGISTRY_TOKEN. Then run `xbp config crates login` if you also want Cargo's local credentials file updated."),
604                        ));
605                    }
606                }
607                commands::CratesConfigAction::DeleteKey => {
608                    if let Err(e) = run_config_secret_delete("crates").await {
609                        let _ =
610                            log_error("config", "Failed to delete crates token", Some(&e)).await;
611                        return Err(ErrorFactory::operation(
612                            "config",
613                            "delete crates token",
614                            e,
615                            None,
616                        ));
617                    }
618                }
619                commands::CratesConfigAction::Show { raw } => {
620                    if let Err(e) = run_config_secret_show("crates", raw).await {
621                        let _ = log_error("config", "Failed to show crates token", Some(&e)).await;
622                        return Err(ErrorFactory::operation(
623                            "config",
624                            "show crates token",
625                            e,
626                            None,
627                        ));
628                    }
629                }
630                commands::CratesConfigAction::SetupRelease => {
631                    if let Err(e) = run_config_publish_setup("crates").await {
632                        let _ = log_error("config", "Failed to configure crates publish", Some(&e))
633                            .await;
634                        return Err(ErrorFactory::operation(
635                            "config",
636                            "configure crates publish",
637                            e,
638                            Some("Run this inside the target XBP project and point the workflow at the crate manifest you want to publish."),
639                        ));
640                    }
641                }
642                commands::CratesConfigAction::Login { key } => {
643                    if let Err(e) = run_config_crates_login(key).await {
644                        let _ = log_error("config", "Failed to log Cargo into crates.io", Some(&e))
645                            .await;
646                        return Err(ErrorFactory::operation(
647                            "config",
648                            "log Cargo into crates.io",
649                            e,
650                            Some("Store a crates token with `xbp config crates set-key` first, or pass it directly to `xbp config crates login <token>`."),
651                        ));
652                    }
653                }
654                commands::CratesConfigAction::Logout => {
655                    if let Err(e) = run_config_crates_logout().await {
656                        let _ =
657                            log_error("config", "Failed to log Cargo out of crates.io", Some(&e))
658                                .await;
659                        return Err(ErrorFactory::operation(
660                            "config",
661                            "log Cargo out of crates.io",
662                            e,
663                            Some("This only clears Cargo's local credentials. Use `xbp config crates delete-key` if you also want to remove the token from global XBP config."),
664                        ));
665                    }
666                }
667            },
668            commands::ConfigProviderCmd::Release(subcmd) => match subcmd.action {
669                commands::ReleaseConfigAction::Setup => {
670                    if let Err(e) = run_config_release_setup().await {
671                        let _ =
672                            log_error("config", "Failed to configure release settings", Some(&e))
673                                .await;
674                        return Err(ErrorFactory::operation(
675                            "config",
676                            "configure release settings",
677                            e,
678                            Some("Run this inside the target XBP project to update .xbp/xbp.yaml."),
679                        ));
680                    }
681                }
682            },
683        }
684    } else if project {
685        if let Err(e) = run_config(debug).await {
686            return Err(ErrorFactory::operation(
687                "config",
688                "read project config",
689                e,
690                Some("Ensure you're inside an XBP project root."),
691            ));
692        }
693    } else if let Err(e) = open_global_config(no_open).await {
694        let _ = log_error("config", "Failed to open global config", Some(&e)).await;
695        return Err(ErrorFactory::operation(
696            "config",
697            "open global config",
698            e,
699            None,
700        ));
701    }
702    Ok(())
703}
704
705pub(super) async fn handle_install(
706    package: Option<String>,
707    list: bool,
708    force: bool,
709    debug: bool,
710) -> CliResult<()> {
711    if list {
712        crate::commands::print_install_targets_help();
713        return Ok(());
714    }
715
716    let Some(package) = package else {
717        crate::commands::print_install_empty_state();
718        return Ok(());
719    };
720
721    if package.trim().is_empty() {
722        crate::commands::print_install_empty_state();
723        return Ok(());
724    }
725
726    if crate::commands::is_install_listing_request(&package) {
727        crate::commands::print_install_targets_help();
728        return Ok(());
729    }
730
731    let install_msg: String = format!("Installing package: {}", package);
732    let _ = log_info("install", &install_msg, None).await;
733    match ui::with_loader(
734        &format!("Installing package `{}`", package),
735        install_package(&package, force, debug),
736    )
737    .await
738    {
739        Ok(()) => {
740            let success_msg = format!("Successfully installed: {}", package);
741            let _ = log_success("install", &success_msg, None).await;
742            Ok(())
743        }
744        Err(e) => Err(e.into()),
745    }
746}
747
748pub(super) async fn handle_curl(cmd: commands::CurlCmd, debug: bool) -> CliResult<()> {
749    let url = cmd
750        .url
751        .unwrap_or_else(|| "https://example.com/api".to_string());
752    if let Err(e) = ui::with_loader(
753        &format!("Requesting {}", url),
754        curl::run_curl(&url, cmd.no_timeout, debug),
755    )
756    .await
757    {
758        let _ = log_error("curl", "Curl command failed", Some(&e)).await;
759        return Err(ErrorFactory::operation(
760            "curl",
761            "execute request",
762            e,
763            Some("Double-check the URL and network connectivity."),
764        ));
765    }
766    Ok(())
767}
768
769pub(super) async fn handle_services(debug: bool) -> CliResult<()> {
770    if let Err(e) = ui::with_loader("Loading configured services", list_services(debug)).await {
771        let _ = log_error("services", "Failed to list services", Some(&e)).await;
772        return Err(ErrorFactory::operation(
773            "services",
774            "list services",
775            e,
776            Some("Ensure xbp config is present and valid."),
777        ));
778    }
779    Ok(())
780}
781
782pub(super) async fn handle_service(
783    command: Option<String>,
784    service_name: Option<String>,
785    debug: bool,
786) -> CliResult<()> {
787    match (command.as_deref(), service_name.as_deref()) {
788        // Bare `xbp service` → preview + interactive picker (service then command)
789        (None, None) => {
790            if let Err(e) = run_service_interactive(debug).await {
791                let _ = log_error("service", "Interactive service run failed", Some(&e)).await;
792                return Err(ErrorFactory::operation(
793                    "service",
794                    "interactive picker",
795                    e,
796                    None,
797                ));
798            }
799        }
800        // `xbp service <cmd>` (no name) → pick service that supports the cmd
801        (Some(cmd), None) if cmd != "help" && cmd != "--help" => {
802            if let Err(e) = pick_service_for_command(cmd, debug).await {
803                let _ = log_error(
804                    "service",
805                    &format!("Service pick for '{}' failed", cmd),
806                    Some(&e),
807                )
808                .await;
809                return Err(ErrorFactory::operation(
810                    "service",
811                    &format!("pick service for {}", cmd),
812                    e,
813                    Some("Pass a service name explicitly, or ensure commands are defined in xbp config."),
814                ));
815            }
816        }
817        // `xbp service help <name>` or `xbp service --help <name>`
818        (Some(cmd), Some(name)) if cmd == "help" || cmd == "--help" => {
819            if let Err(e) = show_service_help(name).await {
820                let _ = log_error("service", "Failed to show service help", Some(&e)).await;
821                return Err(ErrorFactory::operation(
822                    "service",
823                    "show service help",
824                    e,
825                    None,
826                ));
827            }
828        }
829        // `xbp service <cmd> <name>` — classic direct execution
830        (Some(cmd), Some(name)) => {
831            if let Err(e) = run_service_command(cmd, name, debug).await {
832                let _ = log_error(
833                    "service",
834                    &format!("Service command '{}' failed", cmd),
835                    Some(&e),
836                )
837                .await;
838                return Err(ErrorFactory::operation(
839                    "service",
840                    &format!("run `{}` for `{}`", cmd, name),
841                    e,
842                    Some("Check available services via `xbp services`."),
843                ));
844            }
845        }
846        // `xbp service <name>` (no explicit command) — treat as show service info / help
847        (None, Some(name)) => {
848            if let Err(e) = show_service_help(name).await {
849                // fall back to usage if help fails (e.g. unknown service)
850                let _ = log_warn(
851                    "service",
852                    &format!("Could not show help for service '{}': {}", name, e),
853                    None,
854                )
855                .await;
856                print_service_usage();
857            }
858        }
859        // `xbp service help` (no name)
860        (Some(_), None) => {
861            print_service_usage();
862        }
863    }
864    Ok(())
865}
866
867pub(super) async fn handle_nginx(cmd: commands::NginxSubCommand, debug: bool) -> CliResult<()> {
868    if let Err(e) = run_nginx(cmd, debug).await {
869        let _ = log_error("nginx", "Nginx command failed", Some(&e.to_string())).await;
870        return Err(ErrorFactory::operation(
871            "nginx",
872            "execute nginx command",
873            e.to_string(),
874            Some("Try `xbp nginx --help` for command syntax."),
875        ));
876    }
877    Ok(())
878}
879
880pub(super) async fn handle_diag(cmd: commands::DiagCmd, debug: bool) -> CliResult<()> {
881    if let Err(e) = ui::with_loader("Running diagnostics", run_diag(cmd, debug)).await {
882        let _ = log_error("diag", "Diag command failed", Some(&e.to_string())).await;
883        return Err(ErrorFactory::operation(
884            "diag",
885            "run diagnostics",
886            e.to_string(),
887            Some("Re-run with `--debug` for more context."),
888        ));
889    }
890    Ok(())
891}
892
893pub(super) async fn handle_generate(cmd: commands::GenerateCmd, debug: bool) -> CliResult<()> {
894    match cmd.command {
895        commands::GenerateSubCommand::Config(subcmd) => {
896            let args = GenerateConfigArgs {
897                force: subcmd.force,
898                update: subcmd.update,
899                from_json: subcmd.from_json,
900            };
901            if let Err(e) = run_generate_config(args, debug).await {
902                let _ = log_error(
903                    "generate-config",
904                    "Failed to generate project config",
905                    Some(&e),
906                )
907                .await;
908                return Err(ErrorFactory::operation(
909                    "generate-config",
910                    "generate .xbp/xbp.yaml",
911                    e,
912                    Some("Use --update to refresh an existing config or --force to overwrite it."),
913                ));
914            }
915        }
916        commands::GenerateSubCommand::Systemd(subcmd) => {
917            let args = GenerateSystemdArgs {
918                output_dir: subcmd.output_dir,
919                service: subcmd.service,
920                api: subcmd.api,
921            };
922            if let Err(e) = run_generate_systemd(args, debug).await {
923                let _ = log_error(
924                    "generate-systemd",
925                    "Failed to generate systemd units",
926                    Some(&e),
927                )
928                .await;
929                return Err(ErrorFactory::operation(
930                    "generate-systemd",
931                    "generate unit files",
932                    e,
933                    Some("Use a writable `--output-dir` or run with elevated permissions."),
934                ));
935            }
936        }
937    }
938    Ok(())
939}
940
941pub(super) async fn handle_done(cmd: commands::DoneCmd, _debug: bool) -> CliResult<()> {
942    if let Err(e) = crate::commands::run_done(
943        cmd.root,
944        cmd.since,
945        cmd.output,
946        cmd.no_ai,
947        cmd.recursive,
948        cmd.exclude,
949    )
950    .await
951    {
952        let _ = log_error("done", "Done command failed", Some(&e)).await;
953        return Err(e.into());
954    }
955    Ok(())
956}
957
958pub(super) async fn handle_fix_process_monitor_json(
959    cmd: commands::FixProcessMonitorJsonCmd,
960    _debug: bool,
961) -> CliResult<()> {
962    if let Err(error) =
963        crate::commands::run_fix_process_monitor_json(cmd.path, cmd.check, cmd.stdout)
964    {
965        let _ = log_error(
966            "fix-process-monitor-json",
967            "Failed to repair process monitor JSON",
968            Some(&error),
969        )
970        .await;
971        return Err(error.into());
972    }
973    Ok(())
974}
975
976pub(super) async fn handle_cursor(cmd: commands::CursorCmd) -> CliResult<()> {
977    let result = match cmd.command {
978        commands::CursorSubCommand::Ingest { dry_run } => run_cursor_ingest(dry_run).await,
979    };
980
981    if let Err(error) = result {
982        let _ = log_error("cursor", "Cursor command failed", Some(&error)).await;
983        return Err(error.into());
984    }
985    Ok(())
986}
987
988pub(super) async fn handle_login(cmd: commands::LoginCmd) -> CliResult<()> {
989    let result = match cmd.action {
990        Some(commands::LoginSubCommand::Status) => run_login_status().await,
991        Some(commands::LoginSubCommand::Logout) => run_logout().await,
992        None => run_login().await,
993    };
994
995    if let Err(e) = result {
996        let _ = log_error("login", "Login command failed", Some(&e)).await;
997        return Err(e.into());
998    }
999
1000    Ok(())
1001}
1002
1003pub(super) async fn handle_whoami() -> CliResult<()> {
1004    if let Err(e) = run_whoami().await {
1005        let _ = log_error("whoami", "Whoami command failed", Some(&e)).await;
1006        return Err(e.into());
1007    }
1008
1009    Ok(())
1010}
1011
1012pub(super) async fn handle_version(cmd: commands::VersionCmd, debug: bool) -> CliResult<()> {
1013    if let Err(e) = require_authenticated_cli_session().await {
1014        return Err(ErrorFactory::operation(
1015            "version",
1016            "verify CLI login",
1017            e,
1018            Some("Run `xbp login` before using version workflows."),
1019        ));
1020    }
1021
1022    let commands::VersionCmd {
1023        target,
1024        explicit_version,
1025        git,
1026        command,
1027    } = cmd;
1028    let resolved_target = explicit_version.or(target);
1029
1030    if command.is_some() && (resolved_target.is_some() || git) {
1031        return Err(ErrorFactory::validation(
1032            "version",
1033            "`xbp version release` cannot be combined with `--git`, positional targets, or `--version`/`-v` on the parent command.",
1034            Some(
1035                "Run `xbp version release` as a standalone command, or use `xbp version --version <x.y.z>` without a subcommand.",
1036            ),
1037        ));
1038    }
1039
1040    if let Some(subcommand) = command {
1041        match subcommand {
1042            commands::VersionSubCommand::Release(release_cmd) => {
1043                let options = VersionReleaseOptions {
1044                    explicit_version: release_cmd.version,
1045                    release_flag: release_cmd.flag.map(|flag| flag.as_str().to_string()),
1046                    allow_dirty: release_cmd.allow_dirty,
1047                    title: release_cmd.title,
1048                    notes: release_cmd.notes,
1049                    notes_file: release_cmd.notes_file,
1050                    draft: release_cmd.draft,
1051                    prerelease: release_cmd.prerelease,
1052                    publish: release_cmd.publish,
1053                    force: release_cmd.force,
1054                    latest_policy: match release_cmd.make_latest {
1055                        commands::VersionReleaseLatest::True => ReleaseLatestPolicy::True,
1056                        commands::VersionReleaseLatest::False => ReleaseLatestPolicy::False,
1057                        commands::VersionReleaseLatest::Legacy => ReleaseLatestPolicy::Legacy,
1058                    },
1059                };
1060                if let Err(e) = run_version_release_command(options).await {
1061                    let hint = version_release_error_hint(&e);
1062                    return Err(ErrorFactory::operation(
1063                        "version",
1064                        "release version",
1065                        e,
1066                        hint,
1067                    ));
1068                }
1069                return Ok(());
1070            }
1071            commands::VersionSubCommand::Discover(discover_cmd) => {
1072                if let Err(e) = run_version_discover_services(&discover_cmd).await {
1073                    return Err(ErrorFactory::operation(
1074                        "version",
1075                        "discover and register project services",
1076                        e,
1077                        Some(
1078                            "Run `xbp version discover --dry-run` to preview package roots, or `--no-register` to opt out of writing.",
1079                        ),
1080                    ));
1081                }
1082                return Ok(());
1083            }
1084            commands::VersionSubCommand::Bump(bump_cmd) => {
1085                if let Err(e) = run_version_bump_command(&bump_cmd).await {
1086                    return Err(ErrorFactory::operation(
1087                        "version",
1088                        "bump mutated package versions",
1089                        e,
1090                        Some(
1091                            "Run `xbp version bump --dry-run` to preview candidates, or `xbp version bump --all --patch` in non-interactive shells.",
1092                        ),
1093                    ));
1094                }
1095                return Ok(());
1096            }
1097            commands::VersionSubCommand::Domain(domain_cmd) => {
1098                let command = match domain_cmd.command {
1099                    commands::VersionDomainSubCommand::Init(init_cmd) => {
1100                        VersionDomainCommand::Init(VersionDomainInitOptions {
1101                            name: init_cmd.name,
1102                            root: init_cmd.root,
1103                            write: init_cmd.write,
1104                        })
1105                    }
1106                    commands::VersionDomainSubCommand::Doctor(doctor_cmd) => {
1107                        VersionDomainCommand::Doctor(VersionDomainDoctorOptions {
1108                            domain: doctor_cmd.domain,
1109                        })
1110                    }
1111                    commands::VersionDomainSubCommand::Sync(sync_cmd) => {
1112                        VersionDomainCommand::Sync(VersionDomainSyncOptions {
1113                            domain: sync_cmd.domain,
1114                            write: sync_cmd.write,
1115                        })
1116                    }
1117                    commands::VersionDomainSubCommand::Diagnose(diagnose_cmd) => {
1118                        VersionDomainCommand::Diagnose(VersionDomainDiagnoseOptions {
1119                            domain: diagnose_cmd.domain,
1120                            log: diagnose_cmd.log,
1121                        })
1122                    }
1123                    commands::VersionDomainSubCommand::Release(release_cmd) => {
1124                        VersionDomainCommand::Release(VersionDomainReleaseOptions {
1125                            domain: release_cmd.domain,
1126                            patch: release_cmd.patch,
1127                            minor: release_cmd.minor,
1128                            major: release_cmd.major,
1129                            version: release_cmd.version,
1130                            deploy: release_cmd.deploy,
1131                            allow_major_jump: release_cmd.allow_major_jump,
1132                            allow_cross_domain_version: release_cmd.allow_cross_domain_version,
1133                        })
1134                    }
1135                };
1136                if let Err(e) =
1137                    run_version_domain_command(VersionDomainCommandOptions { command }).await
1138                {
1139                    return Err(ErrorFactory::operation(
1140                        "version",
1141                        "run version-domain command",
1142                        e,
1143                        Some("Run `xbp version domain -h` to inspect supported usage."),
1144                    ));
1145                }
1146                return Ok(());
1147            }
1148            commands::VersionSubCommand::Workspace(workspace_cmd) => {
1149                let (repo, json, workspace_command) = match workspace_cmd.command {
1150                    commands::VersionWorkspaceSubCommand::Check(check_cmd) => (
1151                        check_cmd.target.repo,
1152                        check_cmd.target.json,
1153                        WorkspaceVersionCommand::Check(WorkspaceVersionCheckOptions {
1154                            version: check_cmd.version,
1155                        }),
1156                    ),
1157                    commands::VersionWorkspaceSubCommand::Sync(sync_cmd) => (
1158                        sync_cmd.target.repo,
1159                        sync_cmd.target.json,
1160                        WorkspaceVersionCommand::Sync(WorkspaceVersionSyncOptions {
1161                            version: sync_cmd.version,
1162                            write: sync_cmd.write,
1163                        }),
1164                    ),
1165                    commands::VersionWorkspaceSubCommand::Validate(validate_cmd) => (
1166                        validate_cmd.target.repo,
1167                        validate_cmd.target.json,
1168                        WorkspaceVersionCommand::Validate(WorkspaceVersionValidateOptions {
1169                            package: validate_cmd.package,
1170                            cargo_check: validate_cmd.cargo_check,
1171                            package_dry_run: validate_cmd.package_dry_run,
1172                        }),
1173                    ),
1174                    commands::VersionWorkspaceSubCommand::Publish(publish_cmd) => {
1175                        match publish_cmd.command {
1176                            commands::VersionWorkspacePublishSubCommand::Plan(plan_cmd) => (
1177                                plan_cmd.target.repo,
1178                                plan_cmd.target.json,
1179                                WorkspaceVersionCommand::PublishPlan(WorkspacePublishPlanOptions {
1180                                    only: plan_cmd.only,
1181                                    include_prereqs: plan_cmd.include_prereqs,
1182                                }),
1183                            ),
1184                            commands::VersionWorkspacePublishSubCommand::Run(run_cmd) => (
1185                                run_cmd.target.repo,
1186                                run_cmd.target.json,
1187                                WorkspaceVersionCommand::PublishRun(WorkspacePublishRunOptions {
1188                                    dry_run: run_cmd.dry_run,
1189                                    from: run_cmd.from,
1190                                    only: run_cmd.only,
1191                                    include_prereqs: run_cmd.include_prereqs,
1192                                    continue_on_error: run_cmd.continue_on_error,
1193                                    allow_dirty: run_cmd.allow_dirty,
1194                                    timeout_seconds: run_cmd.timeout_seconds,
1195                                    poll_interval_seconds: run_cmd.poll_interval_seconds,
1196                                }),
1197                            ),
1198                        }
1199                    }
1200                };
1201
1202                if let Err(e) = run_version_workspace_command(WorkspaceVersionCommandOptions {
1203                    repo,
1204                    json,
1205                    command: workspace_command,
1206                })
1207                .await
1208                {
1209                    return Err(ErrorFactory::operation(
1210                        "version",
1211                        "run workspace version command",
1212                        e,
1213                        Some("Run `xbp version workspace -h` to inspect supported usage."),
1214                    ));
1215                }
1216                return Ok(());
1217            }
1218        }
1219    }
1220
1221    if let Err(e) = run_version_command(resolved_target, git, debug).await {
1222        return Err(ErrorFactory::operation(
1223            "version",
1224            "run version command",
1225            e,
1226            Some("Run `xbp version -h` to inspect supported usage."),
1227        ));
1228    }
1229    Ok(())
1230}
1231
1232fn version_release_error_hint(error: &str) -> Option<&'static str> {
1233    if error.contains("Working tree is dirty") || error.contains("uncommitted changes") {
1234        return Some(
1235            "Use `--allow-dirty` if only generated files changed. `--force` also allows a dirty tree when `--publish` is enabled.",
1236        );
1237    }
1238    if error.contains("CARGO_REGISTRY_TOKEN") || error.contains("no token found") {
1239        return Some(
1240            "Configure a crates.io token with `xbp config crates set-key` or export `CARGO_REGISTRY_TOKEN`.",
1241        );
1242    }
1243    if error.contains("Workspace version drift detected") {
1244        return Some(
1245            "Run `xbp version workspace sync --write` to align crate versions, or pass `--force` to auto-sync during release.",
1246        );
1247    }
1248    if error.contains("Workspace closure detected")
1249        || error.contains("workspace publish command failed")
1250    {
1251        return Some(
1252            "For multi-crate workspaces, inspect the publish output and run `xbp version workspace publish plan --include-prereqs` first.",
1253        );
1254    }
1255    if error.contains("Release cancelled") {
1256        return Some(
1257            "Re-run with `--allow-dirty` to skip dirty-tree prompts, or `--force` to auto-heal workspace versions and allow dirty publishes.",
1258        );
1259    }
1260    None
1261}
1262
1263fn should_print_help(cli: &Cli) -> bool {
1264    cli.command.is_none() && !cli.list && !cli.logs && !cli.commands && cli.port.is_none()
1265}
1266
1267fn background_cursor_ingest_trigger(command: Option<&commands::Commands>) -> Option<&'static str> {
1268    match command {
1269        None => None,
1270        Some(commands::Commands::Cursor(_)) => None,
1271        Some(commands::Commands::Diag(_)) => None,
1272        Some(commands::Commands::Whoami) => None,
1273        Some(commands::Commands::Login(login_cmd)) => match login_cmd.action {
1274            None => Some("login"),
1275            Some(commands::LoginSubCommand::Status | commands::LoginSubCommand::Logout) => None,
1276        },
1277        Some(other) => Some(commands::command_label(other)),
1278    }
1279}
1280
1281fn background_system_inventory_refresh_trigger(
1282    command: Option<&commands::Commands>,
1283) -> Option<&'static str> {
1284    match command {
1285        None => None,
1286        Some(commands::Commands::Cursor(_)) => None,
1287        Some(commands::Commands::Diag(_)) => None,
1288        Some(commands::Commands::Whoami) => None,
1289        Some(commands::Commands::Login(login_cmd)) => match login_cmd.action {
1290            None => Some("login"),
1291            Some(commands::LoginSubCommand::Status | commands::LoginSubCommand::Logout) => None,
1292        },
1293        Some(other) => Some(commands::command_label(other)),
1294    }
1295}
1296
1297fn print_service_usage() {
1298    ui::configure_color_output();
1299    println!();
1300    println!("{}", "xbp service".bright_magenta().bold());
1301    ui::divider(28);
1302    println!(
1303        "{} {}",
1304        "Usage:".bright_cyan().bold(),
1305        "xbp service [command] [service-name]".bright_white()
1306    );
1307    println!();
1308    println!("Running with no arguments opens an interactive preview + picker.");
1309    ui::section("Commands");
1310    for command in ["build", "install", "start", "dev", "pre"] {
1311        println!("  {}", command.bright_cyan());
1312    }
1313    ui::section("Examples");
1314    println!(
1315        "  {}",
1316        "xbp service                      # interactive: pick service → command".dimmed()
1317    );
1318    println!(
1319        "  {}",
1320        "xbp service start                # pick from services that support start".dimmed()
1321    );
1322    println!("  {}", "xbp service build zeus".dimmed());
1323    ui::tip("Run `xbp service --help <service-name>` for service-specific guidance. All runs are recorded under $HOME/.xbp/logs/service/");
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328    use super::*;
1329    use crate::cli::commands::{Commands, VersionReleaseLatest, VersionSubCommand};
1330
1331    #[test]
1332    fn plain_cli_invocation_prints_help() {
1333        let cli = Cli::try_parse_from(["xbp"]).expect("parse");
1334        assert!(should_print_help(&cli));
1335    }
1336
1337    #[test]
1338    fn list_flag_skips_help_short_circuit() {
1339        let cli = Cli::try_parse_from(["xbp", "-l"]).expect("parse");
1340        assert!(!should_print_help(&cli));
1341    }
1342
1343    #[test]
1344    fn commands_flag_skips_help_short_circuit() {
1345        let cli = Cli::try_parse_from(["xbp", "--commands"]).expect("parse");
1346        assert!(!should_print_help(&cli));
1347        assert!(cli.commands);
1348    }
1349
1350    #[test]
1351    fn install_without_package_parses_and_is_optional() {
1352        let cli = Cli::try_parse_from(["xbp", "install"]).expect("parse");
1353        let Some(Commands::Install {
1354            list,
1355            force,
1356            package,
1357        }) = cli.command
1358        else {
1359            panic!("expected install command");
1360        };
1361        assert!(!list);
1362        assert!(!force);
1363        assert!(package.is_none());
1364    }
1365
1366    #[test]
1367    fn install_with_package_still_parses() {
1368        let cli = Cli::try_parse_from(["xbp", "install", "docker"]).expect("parse");
1369        let Some(Commands::Install {
1370            list,
1371            force,
1372            package,
1373        }) = cli.command
1374        else {
1375            panic!("expected install command");
1376        };
1377        assert!(!list);
1378        assert!(!force);
1379        assert_eq!(package.as_deref(), Some("docker"));
1380    }
1381
1382    #[test]
1383    fn install_force_flag_parses() {
1384        let cli = Cli::try_parse_from(["xbp", "install", "--force", "nx-brew"]).expect("parse");
1385        let Some(Commands::Install {
1386            list,
1387            force,
1388            package,
1389        }) = cli.command
1390        else {
1391            panic!("expected install command");
1392        };
1393        assert!(!list);
1394        assert!(force);
1395        assert_eq!(package.as_deref(), Some("nx-brew"));
1396    }
1397
1398    #[test]
1399    fn install_list_flag_parses() {
1400        let cli = Cli::try_parse_from(["xbp", "install", "--list"]).expect("parse");
1401        let Some(Commands::Install {
1402            list,
1403            force: _,
1404            package,
1405        }) = cli.command
1406        else {
1407            panic!("expected install command");
1408        };
1409        assert!(list);
1410        assert!(package.is_none());
1411    }
1412
1413    #[test]
1414    fn install_short_list_flag_parses() {
1415        let cli = Cli::try_parse_from(["xbp", "install", "-l"]).expect("parse");
1416        let Some(Commands::Install {
1417            list,
1418            force: _,
1419            package,
1420        }) = cli.command
1421        else {
1422            panic!("expected install command");
1423        };
1424        assert!(list);
1425        assert!(package.is_none());
1426    }
1427
1428    #[test]
1429    fn install_ls_alias_parses_as_package() {
1430        let cli = Cli::try_parse_from(["xbp", "install", "ls"]).expect("parse");
1431        let Some(Commands::Install {
1432            list,
1433            force: _,
1434            package,
1435        }) = cli.command
1436        else {
1437            panic!("expected install command");
1438        };
1439        assert!(!list);
1440        assert_eq!(package.as_deref(), Some("ls"));
1441    }
1442
1443    #[test]
1444    fn version_release_make_latest_parses_explicit_value() {
1445        let cli = Cli::try_parse_from([
1446            "xbp",
1447            "version",
1448            "release",
1449            "--version",
1450            "1.2.3",
1451            "--make-latest",
1452            "false",
1453        ])
1454        .expect("parse");
1455        let Some(Commands::Version(version_cmd)) = cli.command else {
1456            panic!("expected version command");
1457        };
1458        assert!(version_cmd.explicit_version.is_none());
1459        let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
1460            panic!("expected version release subcommand");
1461        };
1462        assert!(matches!(
1463            release_cmd.make_latest,
1464            VersionReleaseLatest::False
1465        ));
1466    }
1467
1468    #[test]
1469    fn version_release_make_latest_defaults_to_legacy() {
1470        let cli = Cli::try_parse_from(["xbp", "version", "release", "--version", "1.2.3"])
1471            .expect("parse");
1472        let Some(Commands::Version(version_cmd)) = cli.command else {
1473            panic!("expected version command");
1474        };
1475        let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
1476            panic!("expected version release subcommand");
1477        };
1478        assert!(matches!(
1479            release_cmd.make_latest,
1480            VersionReleaseLatest::Legacy
1481        ));
1482    }
1483
1484    #[test]
1485    fn version_explicit_flag_parses() {
1486        let cli = Cli::try_parse_from(["xbp", "version", "--version", "1.2.3"]).expect("parse");
1487        let Some(Commands::Version(version_cmd)) = cli.command else {
1488            panic!("expected version command");
1489        };
1490        assert_eq!(version_cmd.explicit_version.as_deref(), Some("1.2.3"));
1491        assert!(version_cmd.target.is_none());
1492        assert!(version_cmd.command.is_none());
1493    }
1494
1495    #[test]
1496    fn version_short_explicit_flag_parses_with_alias() {
1497        let cli = Cli::try_parse_from(["xbp", "v", "-v", "1.2.3"]).expect("parse");
1498        let Some(Commands::Version(version_cmd)) = cli.command else {
1499            panic!("expected version command");
1500        };
1501        assert_eq!(version_cmd.explicit_version.as_deref(), Some("1.2.3"));
1502        assert!(version_cmd.target.is_none());
1503        assert!(version_cmd.command.is_none());
1504    }
1505}