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