Skip to main content

xbp_cli/cli/
mod.rs

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