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