1#![warn(clippy::all)]
4#![warn(clippy::pedantic)]
5#![allow(clippy::format_push_string)]
6#![allow(clippy::option_if_let_else)]
7#![allow(clippy::needless_pass_by_value)]
8#![allow(clippy::must_use_candidate)]
9#![allow(clippy::module_name_repetitions)]
10#![allow(clippy::missing_errors_doc)]
11#![allow(clippy::doc_markdown)]
12#![allow(clippy::too_many_lines)]
13#![allow(clippy::unnecessary_wraps)]
14#![allow(clippy::match_same_arms)]
15#![allow(clippy::similar_names)]
16#![allow(clippy::struct_excessive_bools)]
17#![allow(clippy::derive_partial_eq_without_eq)]
18#![allow(clippy::missing_const_for_fn)] pub mod commands;
21pub mod config;
22pub mod introspection;
23pub mod output;
24pub mod output_schemas;
25pub mod schema;
26
27use std::{env, process, str::FromStr};
28
29use clap::{CommandFactory, Parser, Subcommand};
30use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
31
32const EXIT_CODES_HELP: &str = "\
34EXIT CODES:
35 0 Success - Command completed successfully
36 1 Error - Command failed with an error
37 2 Validation failed - Schema or input validation failed";
38
39#[derive(Parser)]
41#[command(name = "fraiseql")]
42#[command(author, version, about, long_about = None)]
43#[command(propagate_version = true)]
44#[command(after_help = EXIT_CODES_HELP)]
45struct Cli {
46 #[arg(short, long, global = true)]
48 verbose: bool,
49
50 #[arg(short, long, global = true)]
52 debug: bool,
53
54 #[arg(long, global = true)]
56 json: bool,
57
58 #[arg(short, long, global = true)]
60 quiet: bool,
61
62 #[command(subcommand)]
63 command: Commands,
64}
65
66#[derive(Subcommand)]
67enum Commands {
68 #[command(after_help = "\
75EXAMPLES:
76 fraiseql compile fraiseql.toml
77 fraiseql compile fraiseql.toml --types types.json
78 fraiseql compile schema.json -o schema.compiled.json
79 fraiseql compile fraiseql.toml --check")]
80 Compile {
81 #[arg(value_name = "INPUT")]
83 input: String,
84
85 #[arg(long, value_name = "TYPES")]
87 types: Option<String>,
88
89 #[arg(long, value_name = "DIR")]
91 schema_dir: Option<String>,
92
93 #[arg(long = "type-file", value_name = "FILE")]
96 type_files: Vec<String>,
97
98 #[arg(long = "query-file", value_name = "FILE")]
100 query_files: Vec<String>,
101
102 #[arg(long = "mutation-file", value_name = "FILE")]
104 mutation_files: Vec<String>,
105
106 #[arg(
108 short,
109 long,
110 value_name = "OUTPUT",
111 default_value = "schema.compiled.json"
112 )]
113 output: String,
114
115 #[arg(long)]
117 check: bool,
118
119 #[arg(long, value_name = "DATABASE_URL")]
122 database: Option<String>,
123 },
124
125 #[command(after_help = "\
130EXAMPLES:
131 fraiseql extract schema/schema.py
132 fraiseql extract schema/ --recursive
133 fraiseql extract schema.rs --language rust -o schema.json")]
134 Extract {
135 #[arg(value_name = "INPUT")]
137 input: Vec<String>,
138
139 #[arg(short, long)]
142 language: Option<String>,
143
144 #[arg(short, long)]
146 recursive: bool,
147
148 #[arg(short, long, default_value = "schema.json")]
150 output: String,
151 },
152
153 #[command(after_help = "\
157EXAMPLES:
158 fraiseql explain '{ users { id name } }'
159 fraiseql explain '{ user(id: 1) { posts { title } } }' --json")]
160 Explain {
161 #[arg(value_name = "QUERY")]
163 query: String,
164 },
165
166 #[command(after_help = "\
170EXAMPLES:
171 fraiseql cost '{ users { id name } }'
172 fraiseql cost '{ deeply { nested { query { here } } } }' --json")]
173 Cost {
174 #[arg(value_name = "QUERY")]
176 query: String,
177 },
178
179 #[command(after_help = "\
184EXAMPLES:
185 fraiseql analyze schema.compiled.json
186 fraiseql analyze schema.compiled.json --json")]
187 Analyze {
188 #[arg(value_name = "SCHEMA")]
190 schema: String,
191 },
192
193 #[command(after_help = "\
198EXAMPLES:
199 fraiseql dependency-graph schema.compiled.json
200 fraiseql dependency-graph schema.compiled.json -f dot > graph.dot
201 fraiseql dependency-graph schema.compiled.json -f mermaid
202 fraiseql dependency-graph schema.compiled.json --json")]
203 DependencyGraph {
204 #[arg(value_name = "SCHEMA")]
206 schema: String,
207
208 #[arg(short, long, value_name = "FORMAT", default_value = "json")]
210 format: String,
211 },
212
213 #[command(after_help = "\
217EXAMPLES:
218 fraiseql federation graph schema.compiled.json
219 fraiseql federation graph schema.compiled.json -f dot
220 fraiseql federation graph schema.compiled.json -f mermaid")]
221 Federation {
222 #[command(subcommand)]
224 command: FederationCommands,
225 },
226
227 #[command(after_help = "\
232EXAMPLES:
233 fraiseql lint schema.json
234 fraiseql lint schema.compiled.json --federation
235 fraiseql lint schema.json --fail-on-critical
236 fraiseql lint schema.json --json")]
237 Lint {
238 #[arg(value_name = "SCHEMA")]
240 schema: String,
241
242 #[arg(long)]
244 federation: bool,
245
246 #[arg(long)]
248 cost: bool,
249
250 #[arg(long)]
252 cache: bool,
253
254 #[arg(long)]
256 auth: bool,
257
258 #[arg(long)]
260 compilation: bool,
261
262 #[arg(long)]
264 fail_on_critical: bool,
265
266 #[arg(long)]
268 fail_on_warning: bool,
269
270 #[arg(long)]
272 verbose: bool,
273 },
274
275 #[command(after_help = "\
277EXAMPLES:
278 fraiseql generate-views -s schema.json -e User --view va_users
279 fraiseql generate-views -s schema.json -e Order --view tv_orders --refresh-strategy scheduled")]
280 GenerateViews {
281 #[arg(short, long, value_name = "SCHEMA")]
283 schema: String,
284
285 #[arg(short, long, value_name = "NAME")]
287 entity: String,
288
289 #[arg(long, value_name = "NAME")]
291 view: String,
292
293 #[arg(long, value_name = "STRATEGY", default_value = "trigger-based")]
295 refresh_strategy: String,
296
297 #[arg(short, long, value_name = "PATH")]
299 output: Option<String>,
300
301 #[arg(long, default_value = "true")]
303 include_composition_views: bool,
304
305 #[arg(long, default_value = "true")]
307 include_monitoring: bool,
308
309 #[arg(long)]
311 validate: bool,
312
313 #[arg(long, action = clap::ArgAction::SetTrue)]
315 gen_verbose: bool,
316 },
317
318 #[command(after_help = "\
326EXAMPLES:
327 fraiseql validate schema.json
328 fraiseql validate schema.json --check-unused
329 fraiseql validate schema.json --strict
330 fraiseql validate facts -s schema.json -d postgres://localhost/db")]
331 Validate {
332 #[command(subcommand)]
333 command: Option<ValidateCommands>,
334
335 #[arg(value_name = "INPUT")]
337 input: Option<String>,
338
339 #[arg(long, default_value = "true")]
341 check_cycles: bool,
342
343 #[arg(long)]
345 check_unused: bool,
346
347 #[arg(long)]
349 strict: bool,
350
351 #[arg(long, value_name = "TYPES", value_delimiter = ',')]
353 types: Vec<String>,
354 },
355
356 #[command(after_help = "\
358EXAMPLES:
359 fraiseql introspect facts -d postgres://localhost/db
360 fraiseql introspect facts -d postgres://localhost/db -f json")]
361 Introspect {
362 #[command(subcommand)]
363 command: IntrospectCommands,
364 },
365
366 #[command(after_help = "\
371EXAMPLES:
372 fraiseql generate schema.json --language python
373 fraiseql generate schema.json --language rust -o schema.rs
374 fraiseql generate schema.json --language typescript")]
375 Generate {
376 #[arg(value_name = "INPUT")]
378 input: String,
379
380 #[arg(short, long)]
382 language: String,
383
384 #[arg(short, long)]
386 output: Option<String>,
387 },
388
389 #[command(after_help = "\
394EXAMPLES:
395 fraiseql init my-app
396 fraiseql init my-app --language typescript --database postgres
397 fraiseql init my-app --size xs --no-git")]
398 Init {
399 #[arg(value_name = "PROJECT_NAME")]
401 project_name: String,
402
403 #[arg(short, long, default_value = "python")]
405 language: String,
406
407 #[arg(long, default_value = "postgres")]
409 database: String,
410
411 #[arg(long, default_value = "s")]
413 size: String,
414
415 #[arg(long)]
417 no_git: bool,
418 },
419
420 #[command(after_help = "\
425EXAMPLES:
426 fraiseql migrate up --database postgres://localhost/mydb
427 fraiseql migrate down --steps 1
428 fraiseql migrate status
429 fraiseql migrate create add_posts_table")]
430 Migrate {
431 #[command(subcommand)]
432 command: MigrateCommands,
433 },
434
435 #[command(after_help = "\
439EXAMPLES:
440 fraiseql sbom
441 fraiseql sbom --format spdx
442 fraiseql sbom --format cyclonedx --output sbom.json")]
443 Sbom {
444 #[arg(short, long, default_value = "cyclonedx")]
446 format: String,
447
448 #[arg(short, long, value_name = "FILE")]
450 output: Option<String>,
451 },
452
453 #[command(after_help = "\
463EXAMPLES:
464 fraiseql run
465 fraiseql run fraiseql.toml --database postgres://localhost/mydb
466 fraiseql run --port 3000 --watch
467 fraiseql run schema.json --introspection
468
469TOML CONFIG:
470 [server]
471 host = \"127.0.0.1\"
472 port = 9000
473
474 [server.cors]
475 origins = [\"https://app.example.com\"]
476
477 [database]
478 url = \"${DATABASE_URL}\"
479 pool_min = 2
480 pool_max = 20")]
481 Run {
482 #[arg(value_name = "INPUT")]
484 input: Option<String>,
485
486 #[arg(short, long, value_name = "DATABASE_URL")]
488 database: Option<String>,
489
490 #[arg(short, long, value_name = "PORT")]
492 port: Option<u16>,
493
494 #[arg(long, value_name = "HOST")]
496 bind: Option<String>,
497
498 #[arg(short, long)]
500 watch: bool,
501
502 #[arg(long)]
504 introspection: bool,
505 },
506
507 #[command(after_help = "\
512EXAMPLES:
513 fraiseql validate-documents manifest.json")]
514 ValidateDocuments {
515 #[arg(value_name = "MANIFEST")]
517 manifest: String,
518 },
519
520 #[command(hide = true)] Serve {
523 #[arg(value_name = "SCHEMA")]
525 schema: String,
526
527 #[arg(short, long, default_value = "8080")]
529 port: u16,
530 },
531}
532
533#[derive(Subcommand)]
534enum ValidateCommands {
535 Facts {
537 #[arg(short, long, value_name = "SCHEMA")]
539 schema: String,
540
541 #[arg(short, long, value_name = "DATABASE_URL")]
543 database: String,
544 },
545}
546
547#[derive(Subcommand)]
548enum FederationCommands {
549 Graph {
551 #[arg(value_name = "SCHEMA")]
553 schema: String,
554
555 #[arg(short, long, value_name = "FORMAT", default_value = "json")]
557 format: String,
558 },
559}
560
561#[derive(Subcommand)]
562enum IntrospectCommands {
563 Facts {
565 #[arg(short, long, value_name = "DATABASE_URL")]
567 database: String,
568
569 #[arg(short, long, value_name = "FORMAT", default_value = "python")]
571 format: String,
572 },
573}
574
575#[derive(Subcommand)]
576enum MigrateCommands {
577 Up {
579 #[arg(short, long, value_name = "DATABASE_URL")]
581 database: Option<String>,
582
583 #[arg(long, value_name = "DIR")]
585 dir: Option<String>,
586 },
587
588 Down {
590 #[arg(short, long, value_name = "DATABASE_URL")]
592 database: Option<String>,
593
594 #[arg(long, value_name = "DIR")]
596 dir: Option<String>,
597
598 #[arg(long, default_value = "1")]
600 steps: u32,
601 },
602
603 Status {
605 #[arg(short, long, value_name = "DATABASE_URL")]
607 database: Option<String>,
608
609 #[arg(long, value_name = "DIR")]
611 dir: Option<String>,
612 },
613
614 Create {
616 #[arg(value_name = "NAME")]
618 name: String,
619
620 #[arg(long, value_name = "DIR")]
622 dir: Option<String>,
623 },
624}
625
626pub async fn run() {
628 use crate::{commands, output};
629
630 if let Some(code) = handle_introspection_flags() {
631 process::exit(code);
632 }
633
634 let cli = Cli::parse();
635
636 init_logging(cli.verbose, cli.debug);
637
638 let result = match cli.command {
639 Commands::Compile {
640 input,
641 types,
642 schema_dir,
643 type_files,
644 query_files,
645 mutation_files,
646 output,
647 check,
648 database,
649 } => {
650 commands::compile::run(
651 &input,
652 types.as_deref(),
653 schema_dir.as_deref(),
654 type_files,
655 query_files,
656 mutation_files,
657 &output,
658 check,
659 database.as_deref(),
660 )
661 .await
662 },
663
664 Commands::Extract {
665 input,
666 language,
667 recursive,
668 output,
669 } => commands::extract::run(&input, language.as_deref(), recursive, &output),
670
671 Commands::Explain { query } => match commands::explain::run(&query) {
672 Ok(result) => {
673 println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
674 Ok(())
675 },
676 Err(e) => Err(e),
677 },
678
679 Commands::Cost { query } => match commands::cost::run(&query) {
680 Ok(result) => {
681 println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
682 Ok(())
683 },
684 Err(e) => Err(e),
685 },
686
687 Commands::Analyze { schema } => match commands::analyze::run(&schema) {
688 Ok(result) => {
689 println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
690 Ok(())
691 },
692 Err(e) => Err(e),
693 },
694
695 Commands::DependencyGraph { schema, format } => {
696 match commands::dependency_graph::GraphFormat::from_str(&format) {
697 Ok(fmt) => match commands::dependency_graph::run(&schema, fmt) {
698 Ok(result) => {
699 println!(
700 "{}",
701 output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
702 );
703 Ok(())
704 },
705 Err(e) => Err(e),
706 },
707 Err(e) => Err(anyhow::anyhow!(e)),
708 }
709 },
710
711 Commands::Lint {
712 schema,
713 federation: _,
714 cost: _,
715 cache: _,
716 auth: _,
717 compilation: _,
718 fail_on_critical,
719 fail_on_warning,
720 verbose: _,
721 } => {
722 let opts = commands::lint::LintOptions {
723 fail_on_critical,
724 fail_on_warning,
725 };
726 match commands::lint::run(&schema, opts) {
727 Ok(result) => {
728 println!(
729 "{}",
730 output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
731 );
732 Ok(())
733 },
734 Err(e) => Err(e),
735 }
736 },
737
738 Commands::Federation { command } => match command {
739 FederationCommands::Graph { schema, format } => {
740 match commands::federation::graph::GraphFormat::from_str(&format) {
741 Ok(fmt) => match commands::federation::graph::run(&schema, fmt) {
742 Ok(result) => {
743 println!(
744 "{}",
745 output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
746 );
747 Ok(())
748 },
749 Err(e) => Err(e),
750 },
751 Err(e) => Err(anyhow::anyhow!(e)),
752 }
753 },
754 },
755
756 Commands::GenerateViews {
757 schema,
758 entity,
759 view,
760 refresh_strategy,
761 output,
762 include_composition_views,
763 include_monitoring,
764 validate,
765 gen_verbose,
766 } => match commands::generate_views::RefreshStrategy::parse(&refresh_strategy) {
767 Ok(refresh_strat) => {
768 let config = commands::generate_views::GenerateViewsConfig {
769 schema_path: schema,
770 entity,
771 view,
772 refresh_strategy: refresh_strat,
773 output,
774 include_composition_views,
775 include_monitoring,
776 validate_only: validate,
777 verbose: cli.verbose || gen_verbose,
778 };
779
780 commands::generate_views::run(config)
781 },
782 Err(e) => Err(anyhow::anyhow!(e)),
783 },
784
785 Commands::Validate {
786 command,
787 input,
788 check_cycles,
789 check_unused,
790 strict,
791 types,
792 } => match command {
793 Some(ValidateCommands::Facts { schema, database }) => {
794 commands::validate_facts::run(std::path::Path::new(&schema), &database).await
795 },
796 None => match input {
797 Some(input) => {
798 let opts = commands::validate::ValidateOptions {
799 check_cycles,
800 check_unused,
801 strict,
802 filter_types: types,
803 };
804 match commands::validate::run_with_options(&input, opts) {
805 Ok(result) => {
806 println!(
807 "{}",
808 output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
809 );
810 if result.status == "validation-failed" {
811 Err(anyhow::anyhow!("Validation failed"))
812 } else {
813 Ok(())
814 }
815 },
816 Err(e) => Err(e),
817 }
818 },
819 None => Err(anyhow::anyhow!("INPUT required when no subcommand provided")),
820 },
821 },
822
823 Commands::Introspect { command } => match command {
824 IntrospectCommands::Facts { database, format } => {
825 match commands::introspect_facts::OutputFormat::parse(&format) {
826 Ok(fmt) => commands::introspect_facts::run(&database, fmt).await,
827 Err(e) => Err(anyhow::anyhow!(e)),
828 }
829 },
830 },
831
832 Commands::Generate {
833 input,
834 language,
835 output,
836 } => match commands::init::Language::from_str(&language) {
837 Ok(lang) => commands::generate::run(&input, lang, output.as_deref()),
838 Err(e) => Err(anyhow::anyhow!(e)),
839 },
840
841 Commands::Init {
842 project_name,
843 language,
844 database,
845 size,
846 no_git,
847 } => {
848 match (
849 commands::init::Language::from_str(&language),
850 commands::init::Database::from_str(&database),
851 commands::init::ProjectSize::from_str(&size),
852 ) {
853 (Ok(lang), Ok(db), Ok(sz)) => {
854 let config = commands::init::InitConfig {
855 project_name,
856 language: lang,
857 database: db,
858 size: sz,
859 no_git,
860 };
861 commands::init::run(&config)
862 },
863 (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => Err(anyhow::anyhow!(e)),
864 }
865 },
866
867 Commands::Migrate { command } => match command {
868 MigrateCommands::Up { database, dir } => {
869 let db_url = commands::migrate::resolve_database_url(database.as_deref());
870 match db_url {
871 Ok(url) => {
872 let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
873 let action = commands::migrate::MigrateAction::Up {
874 database_url: url,
875 dir: mig_dir,
876 };
877 commands::migrate::run(&action)
878 },
879 Err(e) => Err(e),
880 }
881 },
882 MigrateCommands::Down {
883 database,
884 dir,
885 steps,
886 } => {
887 let db_url = commands::migrate::resolve_database_url(database.as_deref());
888 match db_url {
889 Ok(url) => {
890 let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
891 let action = commands::migrate::MigrateAction::Down {
892 database_url: url,
893 dir: mig_dir,
894 steps,
895 };
896 commands::migrate::run(&action)
897 },
898 Err(e) => Err(e),
899 }
900 },
901 MigrateCommands::Status { database, dir } => {
902 let db_url = commands::migrate::resolve_database_url(database.as_deref());
903 match db_url {
904 Ok(url) => {
905 let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
906 let action = commands::migrate::MigrateAction::Status {
907 database_url: url,
908 dir: mig_dir,
909 };
910 commands::migrate::run(&action)
911 },
912 Err(e) => Err(e),
913 }
914 },
915 MigrateCommands::Create { name, dir } => {
916 let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
917 let action = commands::migrate::MigrateAction::Create { name, dir: mig_dir };
918 commands::migrate::run(&action)
919 },
920 },
921
922 Commands::Sbom { format, output } => match commands::sbom::SbomFormat::from_str(&format) {
923 Ok(fmt) => commands::sbom::run(fmt, output.as_deref()),
924 Err(e) => Err(anyhow::anyhow!(e)),
925 },
926
927 Commands::Run {
928 input,
929 database,
930 port,
931 bind,
932 watch,
933 introspection,
934 } => {
935 commands::run::run(input.as_deref(), database, port, bind, watch, introspection).await
936 },
937
938 Commands::ValidateDocuments { manifest } => {
939 match commands::validate_documents::run(&manifest) {
940 Ok(true) => Ok(()),
941 Ok(false) => {
942 process::exit(2);
943 },
944 Err(e) => Err(e),
945 }
946 },
947
948 Commands::Serve { schema, port } => commands::serve::run(&schema, port).await,
949 };
950
951 if let Err(e) = result {
952 eprintln!("Error: {e}");
953 if cli.debug {
954 eprintln!("\nDebug info:");
955 eprintln!("{e:?}");
956 }
957 process::exit(1);
958 }
959}
960
961fn init_logging(verbose: bool, debug: bool) {
962 let filter = if debug {
963 "fraiseql=debug,fraiseql_core=debug"
964 } else if verbose {
965 "fraiseql=info,fraiseql_core=info"
966 } else {
967 "fraiseql=warn,fraiseql_core=warn"
968 };
969
970 tracing_subscriber::registry()
971 .with(
972 tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into()),
973 )
974 .with(tracing_subscriber::fmt::layer())
975 .init();
976}
977
978fn handle_introspection_flags() -> Option<i32> {
979 let args: Vec<String> = env::args().collect();
980
981 if args.iter().any(|a| a == "--help-json") {
982 let cmd = Cli::command();
983 let version = env!("CARGO_PKG_VERSION");
984 let help = crate::introspection::extract_cli_help(&cmd, version);
985 let result = crate::output::CommandResult::success(
986 "help",
987 serde_json::to_value(&help).expect("CliHelp is always serializable"),
988 );
989 println!(
990 "{}",
991 serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
992 );
993 return Some(0);
994 }
995
996 if args.iter().any(|a| a == "--list-commands") {
997 let cmd = Cli::command();
998 let commands = crate::introspection::list_commands(&cmd);
999 let result = crate::output::CommandResult::success(
1000 "list-commands",
1001 serde_json::to_value(&commands).expect("command list is always serializable"),
1002 );
1003 println!(
1004 "{}",
1005 serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
1006 );
1007 return Some(0);
1008 }
1009
1010 let idx = args.iter().position(|a| a == "--show-output-schema")?;
1011 let available = crate::output_schemas::list_schema_commands().join(", ");
1012
1013 let Some(cmd_name) = args.get(idx + 1) else {
1014 let result = crate::output::CommandResult::error(
1015 "show-output-schema",
1016 &format!("Missing command name. Available: {available}"),
1017 "MISSING_ARGUMENT",
1018 );
1019 println!(
1020 "{}",
1021 serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
1022 );
1023 return Some(1);
1024 };
1025
1026 if let Some(schema) = crate::output_schemas::get_output_schema(cmd_name) {
1027 let result = crate::output::CommandResult::success(
1028 "show-output-schema",
1029 serde_json::to_value(&schema).expect("output schema is always serializable"),
1030 );
1031 println!(
1032 "{}",
1033 serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
1034 );
1035 return Some(0);
1036 }
1037
1038 let result = crate::output::CommandResult::error(
1039 "show-output-schema",
1040 &format!("Unknown command: {cmd_name}. Available: {available}"),
1041 "UNKNOWN_COMMAND",
1042 );
1043 println!(
1044 "{}",
1045 serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
1046 );
1047 Some(1)
1048}