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