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 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 (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 (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 (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 (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 (None, Some(name)) => {
849 if let Err(e) = show_service_help(name).await {
850 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 (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}