Skip to main content

fraiseql_cli/
runner.rs

1//! CLI command dispatch and helper utilities.
2
3use std::{env, process, str::FromStr};
4
5use clap::{CommandFactory, Parser};
6use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
7
8use crate::cli::{
9    Cli, Commands, FederationCommands, GenerateClientCommands, IntrospectCommands, MigrateCommands,
10    PerfCommands, PerfExploreCommands, SchemaCommands, ValidateCommands,
11};
12
13/// Run the FraiseQL CLI. Called from both the `fraiseql-cli` and `fraiseql` binary entry points.
14#[allow(clippy::cognitive_complexity)] // Reason: CLI dispatch with many subcommand branches
15pub async fn run() {
16    use crate::{commands, output};
17
18    if let Some(code) = handle_introspection_flags() {
19        process::exit(code);
20    }
21
22    let cli = Cli::parse();
23
24    init_logging(cli.verbose, cli.debug);
25
26    let json_output = cli.json;
27    let debug_output = cli.debug;
28
29    let result = match cli.command {
30        Commands::Compile {
31            input,
32            types,
33            schema_dir,
34            type_files,
35            query_files,
36            mutation_files,
37            output,
38            check,
39            skip_hash,
40            database,
41            emit_ddl,
42            check_migrations,
43            allow_drift,
44            config,
45        } => {
46            commands::compile::run(
47                &input,
48                types.as_deref(),
49                schema_dir.as_deref(),
50                type_files,
51                query_files,
52                mutation_files,
53                &output,
54                check,
55                database.as_deref(),
56                emit_ddl.as_deref(),
57                check_migrations,
58                skip_hash,
59                allow_drift,
60                config.as_deref(),
61            )
62            .await
63        },
64
65        Commands::Extract {
66            input,
67            language,
68            recursive,
69            output,
70        } => commands::extract::run(&input, language.as_deref(), recursive, &output),
71
72        Commands::Explain { query } => match commands::explain::run(&query) {
73            Ok(result) => {
74                println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
75                Ok(())
76            },
77            Err(e) => Err(e),
78        },
79
80        Commands::Cost { query } => match commands::cost::run(&query) {
81            Ok(result) => {
82                println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
83                Ok(())
84            },
85            Err(e) => Err(e),
86        },
87
88        Commands::Analyze { schema } => match commands::analyze::run(&schema) {
89            Ok(result) => {
90                println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
91                Ok(())
92            },
93            Err(e) => Err(e),
94        },
95
96        Commands::DependencyGraph { schema, format } => {
97            match commands::dependency_graph::GraphFormat::from_str(&format) {
98                Ok(fmt) => match commands::dependency_graph::run(&schema, fmt) {
99                    Ok(result) => {
100                        println!(
101                            "{}",
102                            output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
103                        );
104                        Ok(())
105                    },
106                    Err(e) => Err(e),
107                },
108                Err(e) => Err(anyhow::anyhow!(e)),
109            }
110        },
111
112        Commands::Lint {
113            schema,
114            federation,
115            cost,
116            cache,
117            auth,
118            compilation,
119            fail_on_critical,
120            fail_on_warning,
121        } => {
122            let opts = commands::lint::LintOptions {
123                fail_on_critical,
124                fail_on_warning,
125                filter: commands::lint::LintCategoryFilter {
126                    federation,
127                    cost,
128                    cache,
129                    auth,
130                    compilation,
131                },
132            };
133            match commands::lint::run(&schema, opts) {
134                Ok(result) => {
135                    println!(
136                        "{}",
137                        output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
138                    );
139                    // H21: `--fail-on-critical`/`--fail-on-warning` must affect the
140                    // exit code, not just the printed status.
141                    enforce_exit_code(&result);
142                    Ok(())
143                },
144                Err(e) => Err(e),
145            }
146        },
147
148        Commands::Federation { command } => match command {
149            FederationCommands::Graph { schema, format } => {
150                match commands::federation::graph::GraphFormat::from_str(&format) {
151                    Ok(fmt) => match commands::federation::graph::run(&schema, fmt) {
152                        Ok(result) => {
153                            println!(
154                                "{}",
155                                output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
156                            );
157                            Ok(())
158                        },
159                        Err(e) => Err(e),
160                    },
161                    Err(e) => Err(anyhow::anyhow!(e)),
162                }
163            },
164            #[cfg(feature = "federation")]
165            FederationCommands::Sdl { schema, output } => {
166                commands::federation::sdl::run(&schema, output.as_deref())
167            },
168            FederationCommands::Check {
169                schema,
170                against,
171                json,
172            } => {
173                match commands::federation::check::run(
174                    &schema,
175                    against.as_deref(),
176                    json || cli.json,
177                ) {
178                    Ok(result) => {
179                        // `check::run` already prints the JSON result when either the
180                        // subcommand `--json` or the global `--json` is set (it receives
181                        // `json || cli.json`). Only format/print here when neither is set,
182                        // otherwise the result is emitted twice on global `--json`.
183                        if !(json || cli.json) {
184                            println!(
185                                "{}",
186                                output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
187                            );
188                        }
189                        // H22: a composition failure must fail the process, not
190                        // print the error and exit 0.
191                        enforce_exit_code(&result);
192                        Ok(())
193                    },
194                    Err(e) => Err(e),
195                }
196            },
197        },
198
199        Commands::GenerateViews {
200            schema,
201            entity,
202            view,
203            refresh_strategy,
204            output,
205            include_composition_views,
206            include_monitoring,
207            validate,
208            gen_verbose,
209        } => match commands::generate_views::RefreshStrategy::parse(&refresh_strategy) {
210            Ok(refresh_strat) => {
211                let config = commands::generate_views::GenerateViewsConfig {
212                    schema_path: schema,
213                    entity,
214                    view,
215                    refresh_strategy: refresh_strat,
216                    output,
217                    include_composition_views,
218                    include_monitoring,
219                    validate_only: validate,
220                    verbose: cli.verbose || gen_verbose,
221                };
222
223                let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
224                commands::generate_views::run(config, &formatter).await
225            },
226            Err(e) => Err(anyhow::anyhow!(e)),
227        },
228
229        Commands::GenerateCaptureTriggers {
230            schema,
231            output,
232            no_function,
233        } => {
234            let config = commands::generate_capture_triggers::GenerateCaptureTriggersConfig {
235                schema_path: schema,
236                output,
237                include_function: !no_function,
238            };
239            let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
240            commands::generate_capture_triggers::run(config, &formatter)
241        },
242
243        Commands::Validate {
244            command,
245            input,
246            check_cycles,
247            check_unused,
248            strict,
249            types,
250            against_db,
251        } => match command {
252            Some(ValidateCommands::Facts { schema, database }) => {
253                let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
254                commands::validate_facts::run(std::path::Path::new(&schema), &database, &formatter)
255                    .await
256            },
257            None => match (against_db, input) {
258                (Some(db_url), Some(input)) => {
259                    commands::validate::run_against_db(&input, &db_url, cli.json).await
260                },
261                (Some(_), None) => Err(anyhow::anyhow!(
262                    "INPUT (schema.compiled.json) is required with --against-db"
263                )),
264                (None, Some(input)) => {
265                    let opts = commands::validate::ValidateOptions {
266                        check_cycles,
267                        check_unused,
268                        strict,
269                        filter_types: types,
270                    };
271                    match commands::validate::run_with_options(&input, opts) {
272                        Ok(result) => {
273                            println!(
274                                "{}",
275                                output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
276                            );
277                            // Exit 2 for "the schema is invalid", 1 for "the tool broke" —
278                            // the contract `--help-json` publishes, and what `lint` and
279                            // `federation check` already do. This used to wrap the status in
280                            // an `anyhow!("Validation failed")`, which fell through to the
281                            // generic handler and `process::exit(1)`, so CI could not tell a
282                            // cyclic schema from a broken toolchain. It also discarded the
283                            // per-error list that the JSON on stdout already carried
284                            // (#868 item 6).
285                            enforce_exit_code(&result);
286                            Ok(())
287                        },
288                        Err(e) => Err(e),
289                    }
290                },
291                (None, None) => Err(anyhow::anyhow!("INPUT required when no subcommand provided")),
292            },
293        },
294
295        Commands::Introspect { command } => match command {
296            IntrospectCommands::Facts { database, format } => {
297                match commands::introspect_facts::OutputFormat::parse(&format) {
298                    Ok(fmt) => {
299                        let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
300                        commands::introspect_facts::run(&database, fmt, &formatter).await
301                    },
302                    Err(e) => Err(anyhow::anyhow!(e)),
303                }
304            },
305        },
306
307        Commands::Generate {
308            input,
309            language,
310            output,
311        } => match commands::init::Language::from_str(&language) {
312            Ok(lang) => commands::generate::run(&input, lang, output.as_deref()),
313            Err(e) => Err(anyhow::anyhow!(e)),
314        },
315
316        Commands::GenerateClient { language } => match language {
317            GenerateClientCommands::Typescript { schema, out, force } => {
318                commands::generate_client::run(
319                    commands::generate_client::ClientLanguage::TypeScript,
320                    schema.as_deref(),
321                    &out,
322                    force,
323                )
324            },
325            GenerateClientCommands::Python { schema, out, force } => {
326                commands::generate_client::run(
327                    commands::generate_client::ClientLanguage::Python,
328                    schema.as_deref(),
329                    &out,
330                    force,
331                )
332            },
333            GenerateClientCommands::Go { schema, out, force } => commands::generate_client::run(
334                commands::generate_client::ClientLanguage::Go,
335                schema.as_deref(),
336                &out,
337                force,
338            ),
339            GenerateClientCommands::Rust { schema, out, force } => commands::generate_client::run(
340                commands::generate_client::ClientLanguage::Rust,
341                schema.as_deref(),
342                &out,
343                force,
344            ),
345        },
346
347        Commands::Init {
348            project_name,
349            language,
350            database,
351            size,
352            no_git,
353        } => {
354            match (
355                commands::init::Language::from_str(&language),
356                commands::init::Database::from_str(&database),
357                commands::init::ProjectSize::from_str(&size),
358            ) {
359                (Ok(lang), Ok(db), Ok(sz)) => {
360                    let config = commands::init::InitConfig {
361                        project_name,
362                        language: lang,
363                        database: db,
364                        size: sz,
365                        no_git,
366                    };
367                    commands::init::run(&config)
368                },
369                (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => Err(anyhow::anyhow!(e)),
370            }
371        },
372
373        Commands::Migrate { command } => {
374            let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
375            match command {
376                MigrateCommands::Up { database, dir } => {
377                    match migrate_database(database.as_deref(), &formatter).await {
378                        Ok(url) => {
379                            let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
380                            let action = commands::migrate::MigrateAction::Up {
381                                database_url: url,
382                                dir:          mig_dir,
383                            };
384                            commands::migrate::run(&action, &formatter)
385                        },
386                        Err(e) => Err(e),
387                    }
388                },
389                MigrateCommands::Down {
390                    database,
391                    dir,
392                    steps,
393                } => match migrate_database(database.as_deref(), &formatter).await {
394                    Ok(url) => {
395                        let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
396                        let action = commands::migrate::MigrateAction::Down {
397                            database_url: url,
398                            dir: mig_dir,
399                            steps,
400                        };
401                        commands::migrate::run(&action, &formatter)
402                    },
403                    Err(e) => Err(e),
404                },
405                MigrateCommands::Status { database, dir } => {
406                    match migrate_database(database.as_deref(), &formatter).await {
407                        Ok(url) => {
408                            let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
409                            let action = commands::migrate::MigrateAction::Status {
410                                database_url: url,
411                                dir:          mig_dir,
412                            };
413                            commands::migrate::run(&action, &formatter)
414                        },
415                        Err(e) => Err(e),
416                    }
417                },
418                MigrateCommands::Create { name, dir } => {
419                    let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
420                    let action = commands::migrate::MigrateAction::Create { name, dir: mig_dir };
421                    commands::migrate::run(&action, &formatter)
422                },
423                MigrateCommands::Generate { name, dir } => {
424                    let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
425                    let action = commands::migrate::MigrateAction::Generate { name, dir: mig_dir };
426                    commands::migrate::run(&action, &formatter)
427                },
428                MigrateCommands::Validate { dir } => {
429                    let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
430                    let action = commands::migrate::MigrateAction::Validate { dir: mig_dir };
431                    commands::migrate::run(&action, &formatter)
432                },
433                MigrateCommands::Preflight { dir } => {
434                    let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
435                    let action = commands::migrate::MigrateAction::Preflight { dir: mig_dir };
436                    commands::migrate::run(&action, &formatter)
437                },
438            }
439        },
440
441        Commands::Sbom { format, output } => match commands::sbom::SbomFormat::from_str(&format) {
442            Ok(fmt) => commands::sbom::run(fmt, output.as_deref()),
443            Err(e) => Err(anyhow::anyhow!(e)),
444        },
445
446        #[cfg(feature = "run-server")]
447        Commands::Run {
448            input,
449            database,
450            port,
451            bind,
452            watch,
453            introspection,
454        } => {
455            // Box::pin: `run` dispatches across up to 4 adapter init futures,
456            // pushing the combined future past clippy's `large_futures` 16-KiB
457            // stack threshold. Heap-allocating once per CLI invocation is fine.
458            Box::pin(commands::run::run(
459                input.as_deref(),
460                database,
461                port,
462                bind,
463                watch,
464                introspection,
465            ))
466            .await
467        },
468
469        Commands::Watch {
470            input,
471            output,
472            reload_url,
473            admin_token,
474            database,
475        } => {
476            commands::watch::run(
477                &input,
478                &output,
479                reload_url.as_deref(),
480                admin_token.as_deref(),
481                database.as_deref(),
482            )
483            .await
484        },
485
486        Commands::ValidateDocuments {
487            manifest,
488            max_cost,
489            schema,
490        } => {
491            let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
492            match commands::validate_documents::run(
493                &manifest,
494                max_cost,
495                schema.as_deref(),
496                &formatter,
497            ) {
498                Ok(true) => Ok(()),
499                Ok(false) => {
500                    process::exit(2);
501                },
502                Err(e) => Err(e),
503            }
504        },
505
506        Commands::Setup { database, dry_run } => {
507            let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
508            commands::setup::run(database.as_deref(), dry_run, &formatter).await
509        },
510
511        Commands::Schema { command } => match command {
512            SchemaCommands::Metadata { server, token } => {
513                commands::schema::metadata::run(&server, token.as_deref()).await
514            },
515        },
516
517        Commands::Query {
518            query,
519            schema,
520            database,
521            variables,
522            dry_run,
523        } => {
524            // Box::pin: the in-process executor + adapter init future is large
525            // enough to trip clippy's `large_futures` stack threshold.
526            Box::pin(commands::query::run(&query, &schema, database, variables, dry_run)).await
527        },
528
529        Commands::Doctor {
530            config,
531            schema,
532            db_url,
533            against_db,
534            runtime,
535            schemas,
536            json: json_flag,
537        } => {
538            let all_passed = commands::doctor::run_with_db_checks(
539                &config,
540                &schema,
541                db_url.as_deref(),
542                against_db.as_deref(),
543                runtime,
544                &schemas,
545                json_flag,
546            )
547            .await;
548            if all_passed {
549                Ok(())
550            } else {
551                process::exit(1);
552            }
553        },
554
555        Commands::Perf { command } => match command {
556            PerfCommands::RegressionScan {
557                database,
558                recent_days,
559                baseline_days,
560                min_samples,
561                threshold_pct,
562                min_delta_ms,
563                object_type,
564                fail_on_regression,
565            } => {
566                let args = commands::perf::RegressionScanArgs {
567                    database,
568                    recent_days,
569                    baseline_days,
570                    min_samples,
571                    threshold_pct,
572                    min_delta_ms,
573                    object_type,
574                    fail_on_regression,
575                    json: cli.json,
576                    quiet: cli.quiet,
577                };
578                match commands::perf::run_regression_scan(args).await {
579                    Ok(true) => Ok(()),
580                    Ok(false) => process::exit(1),
581                    Err(e) => Err(e),
582                }
583            },
584            PerfCommands::Explore { command } => match command {
585                PerfExploreCommands::Slowest {
586                    database,
587                    days,
588                    object_type,
589                    limit,
590                } => {
591                    commands::perf::run_explore_slowest(
592                        database,
593                        days,
594                        object_type,
595                        limit,
596                        cli.json,
597                        cli.quiet,
598                    )
599                    .await
600                },
601                PerfExploreCommands::NullRate {
602                    database,
603                    days,
604                    object_type,
605                } => {
606                    commands::perf::run_explore_null_rate(
607                        database,
608                        days,
609                        object_type,
610                        cli.json,
611                        cli.quiet,
612                    )
613                    .await
614                },
615                PerfExploreCommands::Summary {
616                    database,
617                    days,
618                    object_type,
619                } => {
620                    commands::perf::run_explore_summary(
621                        database,
622                        days,
623                        object_type,
624                        cli.json,
625                        cli.quiet,
626                    )
627                    .await
628                },
629            },
630        },
631
632        Commands::Sources {
633            schema,
634            db_url,
635            json,
636        } => {
637            commands::sources::run(commands::sources::SourcesArgs {
638                schema,
639                database: db_url,
640                json,
641            })
642            .await
643        },
644
645        #[cfg(feature = "functions-invoke")]
646        Commands::Functions(command) => match command {
647            crate::cli::FunctionsCommands::Invoke {
648                name,
649                payload,
650                schema,
651                mock_http,
652                mock_query,
653                idempotency_token,
654                explain,
655            } => match commands::functions::invoke(
656                &name,
657                &payload,
658                &schema,
659                mock_http.as_deref(),
660                mock_query.as_deref(),
661                idempotency_token,
662                explain,
663                cli.json,
664            )
665            .await
666            {
667                // The harness printed its own output; exit with the outcome code.
668                Ok(code) => process::exit(code),
669                // A config/harness error goes through the normal error report (exit 1).
670                Err(error) => Err(error),
671            },
672        },
673    };
674
675    if let Err(e) = result {
676        let debug_info = if debug_output {
677            Some(format!("{e:?}"))
678        } else {
679            None
680        };
681        let causes = error_causes(&e);
682        eprintln!(
683            "{}",
684            format_cli_error(&e.to_string(), &causes, debug_info.as_deref(), json_output, 1)
685        );
686        process::exit(1);
687    }
688}
689
690/// Enforce the CLI exit-code contract for a command that returns a [`CommandResult`].
691///
692/// Commands that print a [`CommandResult`] must also reflect its status in the
693/// process exit code. The contract (mirroring [`crate::output::get_exit_codes`]):
694///
695/// - `"success"` → return normally (the process exits 0)
696/// - `"validation-failed"` → exit **2** (the command ran, but the checked artifact failed its gate
697///   — e.g. a lint quality gate or a federation composition error)
698/// - any other status (e.g. `"error"`) → exit **1** (operational failure)
699///
700/// Operational errors surfaced as `Err(_)` are handled separately at the end of
701/// [`run`] and also exit 1.
702fn enforce_exit_code(result: &crate::output::CommandResult) {
703    match result.status.as_str() {
704        "success" => {},
705        "validation-failed" => process::exit(2),
706        _ => process::exit(1),
707    }
708}
709
710/// Extract an `anyhow` error's source chain — every cause **below** the top-level
711/// message — as display strings, outermost first.
712///
713/// The top-level context (rendered as the error `message`) is skipped, so a
714/// `.context("outer")` over a `bail!("root")` yields `["root"]`. Surfacing this
715/// chain is what turns an opaque `Failed to convert schema to compiled format`
716/// into an actionable `… caused by: Type 'Order' … is missing field 'id'`.
717pub(crate) fn error_causes(err: &anyhow::Error) -> Vec<String> {
718    err.chain().skip(1).map(ToString::to_string).collect()
719}
720
721/// Format a CLI error as either plain text or a JSON object for machine-readable output.
722///
723/// `causes` is the error's source chain below `message` (see [`error_causes`]); it is
724/// surfaced in **both** output modes so a failure is never reduced to its top-level
725/// context alone.
726///
727/// When `json` is `true`, produces a JSON object:
728/// ```json
729/// { "error": { "message": "...", "causes": ["..."], "code": 1 } }
730/// ```
731/// When `json` is `false`, produces a human-readable string:
732/// ```text
733/// Error: <message>
734///   caused by: <cause 1>
735///   caused by: <cause 2>
736/// ```
737/// If `debug_info` is provided and `json` is `false`, a debug section is appended.
738pub(crate) fn format_cli_error(
739    message: &str,
740    causes: &[String],
741    debug_info: Option<&str>,
742    json: bool,
743    code: i32,
744) -> String {
745    if json {
746        serde_json::json!({
747            "error": {
748                "message": message,
749                "causes": causes,
750                "code": code
751            }
752        })
753        .to_string()
754    } else {
755        let mut out = format!("Error: {message}");
756        for cause in causes {
757            out.push_str(&format!("\n  caused by: {cause}"));
758        }
759        if let Some(debug) = debug_info {
760            out.push_str("\n\nDebug info:\n");
761            out.push_str(debug);
762        }
763        out
764    }
765}
766
767fn init_logging(verbose: bool, debug: bool) {
768    let filter = if debug {
769        "fraiseql=debug,fraiseql_core=debug"
770    } else if verbose {
771        "fraiseql=info,fraiseql_core=info"
772    } else {
773        "fraiseql=warn,fraiseql_core=warn"
774    };
775
776    tracing_subscriber::registry()
777        .with(
778            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into()),
779        )
780        // Diagnostics go to stderr, never stdout — so a command whose stdout is
781        // the payload (e.g. `generate-capture-triggers … | psql`) is never
782        // corrupted by a stray WARN/INFO line.
783        .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
784        .init();
785}
786
787/// Serialize a value to a JSON `Value`, printing to stderr and exiting with code 2 on failure.
788fn serialize_or_exit<T: serde::Serialize>(value: &T, context: &str) -> serde_json::Value {
789    serde_json::to_value(value).unwrap_or_else(|e| {
790        eprintln!("fraiseql: failed to serialize {context}: {e}");
791        std::process::exit(2);
792    })
793}
794
795/// Serialize a value to pretty-printed JSON, printing to stderr and exiting with code 2 on failure.
796fn pretty_or_exit<T: serde::Serialize>(value: &T, context: &str) -> String {
797    serde_json::to_string_pretty(value).unwrap_or_else(|e| {
798        eprintln!("fraiseql: failed to format {context}: {e}");
799        std::process::exit(2);
800    })
801}
802
803fn handle_introspection_flags() -> Option<i32> {
804    let args: Vec<String> = env::args().collect();
805
806    if args.iter().any(|a| a == "--help-json") {
807        let cmd = Cli::command();
808        let version = env!("CARGO_PKG_VERSION");
809        let help = crate::introspection::extract_cli_help(&cmd, version);
810        let result =
811            crate::output::CommandResult::success("help", serialize_or_exit(&help, "help output"));
812        println!("{}", pretty_or_exit(&result, "command result"));
813        return Some(0);
814    }
815
816    if args.iter().any(|a| a == "--list-commands") {
817        let cmd = Cli::command();
818        let commands = crate::introspection::list_commands(&cmd);
819        let result = crate::output::CommandResult::success(
820            "list-commands",
821            serialize_or_exit(&commands, "command list"),
822        );
823        println!("{}", pretty_or_exit(&result, "command result"));
824        return Some(0);
825    }
826
827    let idx = args.iter().position(|a| a == "--show-output-schema")?;
828    let available = crate::output_schemas::list_schema_commands().join(", ");
829
830    let Some(cmd_name) = args.get(idx + 1) else {
831        let result = crate::output::CommandResult::error(
832            "show-output-schema",
833            &format!("Missing command name. Available: {available}"),
834            "MISSING_ARGUMENT",
835        );
836        println!("{}", pretty_or_exit(&result, "command result"));
837        return Some(1);
838    };
839
840    if let Some(schema) = crate::output_schemas::get_output_schema(cmd_name) {
841        let result = crate::output::CommandResult::success(
842            "show-output-schema",
843            serialize_or_exit(&schema, "output schema"),
844        );
845        println!("{}", pretty_or_exit(&result, "command result"));
846        return Some(0);
847    }
848
849    let result = crate::output::CommandResult::error(
850        "show-output-schema",
851        &format!("Unknown command: {cmd_name}. Available: {available}"),
852        "UNKNOWN_COMMAND",
853    );
854    println!("{}", pretty_or_exit(&result, "command result"));
855    Some(1)
856}
857
858/// The URL `fraiseql migrate up|down|status` hands confiture, once confiture is installed and
859/// the server has passed the PostgreSQL floor (#1452).
860async fn migrate_database(
861    explicit: Option<&str>,
862    formatter: &crate::output::OutputFormatter,
863) -> anyhow::Result<String> {
864    crate::commands::migrate::require_confiture(formatter)?;
865    let database = crate::commands::migrate::resolve_database(explicit)?;
866    crate::commands::migrate::refuse_unsupported_server(&database).await?;
867    Ok(database.url)
868}