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