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::Capabilities => commands::capabilities::run(cli.json, cli.quiet),
448
449        #[cfg(feature = "run-server")]
450        Commands::Run {
451            input,
452            database,
453            port,
454            bind,
455            watch,
456            introspection,
457        } => {
458            // Box::pin: `run` dispatches across up to 4 adapter init futures,
459            // pushing the combined future past clippy's `large_futures` 16-KiB
460            // stack threshold. Heap-allocating once per CLI invocation is fine.
461            Box::pin(commands::run::run(
462                input.as_deref(),
463                database,
464                port,
465                bind,
466                watch,
467                introspection,
468            ))
469            .await
470        },
471
472        Commands::Watch {
473            input,
474            output,
475            reload_url,
476            admin_token,
477            database,
478        } => {
479            commands::watch::run(
480                &input,
481                &output,
482                reload_url.as_deref(),
483                admin_token.as_deref(),
484                database.as_deref(),
485            )
486            .await
487        },
488
489        Commands::ValidateDocuments {
490            manifest,
491            max_cost,
492            schema,
493        } => {
494            let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
495            match commands::validate_documents::run(
496                &manifest,
497                max_cost,
498                schema.as_deref(),
499                &formatter,
500            ) {
501                Ok(true) => Ok(()),
502                Ok(false) => {
503                    process::exit(2);
504                },
505                Err(e) => Err(e),
506            }
507        },
508
509        Commands::Setup { database, dry_run } => {
510            let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
511            commands::setup::run(database.as_deref(), dry_run, &formatter).await
512        },
513
514        Commands::Schema { command } => match command {
515            SchemaCommands::Metadata { server, token } => {
516                commands::schema::metadata::run(&server, token.as_deref()).await
517            },
518        },
519
520        Commands::Query {
521            query,
522            schema,
523            database,
524            variables,
525            dry_run,
526        } => {
527            // Box::pin: the in-process executor + adapter init future is large
528            // enough to trip clippy's `large_futures` stack threshold.
529            Box::pin(commands::query::run(&query, &schema, database, variables, dry_run)).await
530        },
531
532        Commands::Doctor {
533            config,
534            schema,
535            db_url,
536            against_db,
537            runtime,
538            schemas,
539            json: json_flag,
540        } => {
541            let all_passed = commands::doctor::run_with_db_checks(
542                &config,
543                &schema,
544                db_url.as_deref(),
545                against_db.as_deref(),
546                runtime,
547                &schemas,
548                json_flag,
549            )
550            .await;
551            if all_passed {
552                Ok(())
553            } else {
554                process::exit(1);
555            }
556        },
557
558        Commands::Perf { command } => match command {
559            PerfCommands::RegressionScan {
560                database,
561                recent_days,
562                baseline_days,
563                min_samples,
564                threshold_pct,
565                min_delta_ms,
566                object_type,
567                fail_on_regression,
568            } => {
569                let args = commands::perf::RegressionScanArgs {
570                    database,
571                    recent_days,
572                    baseline_days,
573                    min_samples,
574                    threshold_pct,
575                    min_delta_ms,
576                    object_type,
577                    fail_on_regression,
578                    json: cli.json,
579                    quiet: cli.quiet,
580                };
581                match commands::perf::run_regression_scan(args).await {
582                    Ok(true) => Ok(()),
583                    Ok(false) => process::exit(1),
584                    Err(e) => Err(e),
585                }
586            },
587            PerfCommands::Explore { command } => match command {
588                PerfExploreCommands::Slowest {
589                    database,
590                    days,
591                    object_type,
592                    limit,
593                } => {
594                    commands::perf::run_explore_slowest(
595                        database,
596                        days,
597                        object_type,
598                        limit,
599                        cli.json,
600                        cli.quiet,
601                    )
602                    .await
603                },
604                PerfExploreCommands::NullRate {
605                    database,
606                    days,
607                    object_type,
608                } => {
609                    commands::perf::run_explore_null_rate(
610                        database,
611                        days,
612                        object_type,
613                        cli.json,
614                        cli.quiet,
615                    )
616                    .await
617                },
618                PerfExploreCommands::Summary {
619                    database,
620                    days,
621                    object_type,
622                } => {
623                    commands::perf::run_explore_summary(
624                        database,
625                        days,
626                        object_type,
627                        cli.json,
628                        cli.quiet,
629                    )
630                    .await
631                },
632            },
633        },
634
635        Commands::Sources {
636            schema,
637            db_url,
638            json,
639        } => {
640            commands::sources::run(commands::sources::SourcesArgs {
641                schema,
642                database: db_url,
643                json,
644            })
645            .await
646        },
647
648        #[cfg(feature = "functions-invoke")]
649        Commands::Functions(command) => match command {
650            crate::cli::FunctionsCommands::Invoke {
651                name,
652                payload,
653                schema,
654                mock_http,
655                mock_query,
656                idempotency_token,
657                explain,
658            } => match commands::functions::invoke(
659                &name,
660                &payload,
661                &schema,
662                mock_http.as_deref(),
663                mock_query.as_deref(),
664                idempotency_token,
665                explain,
666                cli.json,
667            )
668            .await
669            {
670                // The harness printed its own output; exit with the outcome code.
671                Ok(code) => process::exit(code),
672                // A config/harness error goes through the normal error report (exit 1).
673                Err(error) => Err(error),
674            },
675        },
676    };
677
678    if let Err(e) = result {
679        let debug_info = if debug_output {
680            Some(format!("{e:?}"))
681        } else {
682            None
683        };
684        let causes = error_causes(&e);
685        eprintln!(
686            "{}",
687            format_cli_error(&e.to_string(), &causes, debug_info.as_deref(), json_output, 1)
688        );
689        process::exit(1);
690    }
691}
692
693/// Enforce the CLI exit-code contract for a command that returns a [`CommandResult`].
694///
695/// Commands that print a [`CommandResult`] must also reflect its status in the
696/// process exit code. The contract (mirroring [`crate::output::get_exit_codes`]):
697///
698/// - `"success"` → return normally (the process exits 0)
699/// - `"validation-failed"` → exit **2** (the command ran, but the checked artifact failed its gate
700///   — e.g. a lint quality gate or a federation composition error)
701/// - any other status (e.g. `"error"`) → exit **1** (operational failure)
702///
703/// Operational errors surfaced as `Err(_)` are handled separately at the end of
704/// [`run`] and also exit 1.
705fn enforce_exit_code(result: &crate::output::CommandResult) {
706    match result.status.as_str() {
707        "success" => {},
708        "validation-failed" => process::exit(2),
709        _ => process::exit(1),
710    }
711}
712
713/// Extract an `anyhow` error's source chain — every cause **below** the top-level
714/// message — as display strings, outermost first.
715///
716/// The top-level context (rendered as the error `message`) is skipped, so a
717/// `.context("outer")` over a `bail!("root")` yields `["root"]`. Surfacing this
718/// chain is what turns an opaque `Failed to convert schema to compiled format`
719/// into an actionable `… caused by: Type 'Order' … is missing field 'id'`.
720pub(crate) fn error_causes(err: &anyhow::Error) -> Vec<String> {
721    err.chain().skip(1).map(ToString::to_string).collect()
722}
723
724/// Format a CLI error as either plain text or a JSON object for machine-readable output.
725///
726/// `causes` is the error's source chain below `message` (see [`error_causes`]); it is
727/// surfaced in **both** output modes so a failure is never reduced to its top-level
728/// context alone.
729///
730/// When `json` is `true`, produces a JSON object:
731/// ```json
732/// { "error": { "message": "...", "causes": ["..."], "code": 1 } }
733/// ```
734/// When `json` is `false`, produces a human-readable string:
735/// ```text
736/// Error: <message>
737///   caused by: <cause 1>
738///   caused by: <cause 2>
739/// ```
740/// If `debug_info` is provided and `json` is `false`, a debug section is appended.
741pub(crate) fn format_cli_error(
742    message: &str,
743    causes: &[String],
744    debug_info: Option<&str>,
745    json: bool,
746    code: i32,
747) -> String {
748    if json {
749        serde_json::json!({
750            "error": {
751                "message": message,
752                "causes": causes,
753                "code": code
754            }
755        })
756        .to_string()
757    } else {
758        let mut out = format!("Error: {message}");
759        for cause in causes {
760            out.push_str(&format!("\n  caused by: {cause}"));
761        }
762        if let Some(debug) = debug_info {
763            out.push_str("\n\nDebug info:\n");
764            out.push_str(debug);
765        }
766        out
767    }
768}
769
770fn init_logging(verbose: bool, debug: bool) {
771    let filter = if debug {
772        "fraiseql=debug,fraiseql_core=debug"
773    } else if verbose {
774        "fraiseql=info,fraiseql_core=info"
775    } else {
776        "fraiseql=warn,fraiseql_core=warn"
777    };
778
779    tracing_subscriber::registry()
780        .with(
781            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into()),
782        )
783        // Diagnostics go to stderr, never stdout — so a command whose stdout is
784        // the payload (e.g. `generate-capture-triggers … | psql`) is never
785        // corrupted by a stray WARN/INFO line.
786        .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
787        .init();
788}
789
790/// Serialize a value to a JSON `Value`, printing to stderr and exiting with code 2 on failure.
791fn serialize_or_exit<T: serde::Serialize>(value: &T, context: &str) -> serde_json::Value {
792    serde_json::to_value(value).unwrap_or_else(|e| {
793        eprintln!("fraiseql: failed to serialize {context}: {e}");
794        std::process::exit(2);
795    })
796}
797
798/// Serialize a value to pretty-printed JSON, printing to stderr and exiting with code 2 on failure.
799fn pretty_or_exit<T: serde::Serialize>(value: &T, context: &str) -> String {
800    serde_json::to_string_pretty(value).unwrap_or_else(|e| {
801        eprintln!("fraiseql: failed to format {context}: {e}");
802        std::process::exit(2);
803    })
804}
805
806fn handle_introspection_flags() -> Option<i32> {
807    let args: Vec<String> = env::args().collect();
808
809    if args.iter().any(|a| a == "--help-json") {
810        let cmd = Cli::command();
811        let version = env!("CARGO_PKG_VERSION");
812        let help = crate::introspection::extract_cli_help(&cmd, version);
813        let result =
814            crate::output::CommandResult::success("help", serialize_or_exit(&help, "help output"));
815        println!("{}", pretty_or_exit(&result, "command result"));
816        return Some(0);
817    }
818
819    if args.iter().any(|a| a == "--list-commands") {
820        let cmd = Cli::command();
821        let commands = crate::introspection::list_commands(&cmd);
822        let result = crate::output::CommandResult::success(
823            "list-commands",
824            serialize_or_exit(&commands, "command list"),
825        );
826        println!("{}", pretty_or_exit(&result, "command result"));
827        return Some(0);
828    }
829
830    let idx = args.iter().position(|a| a == "--show-output-schema")?;
831    let available = crate::output_schemas::list_schema_commands().join(", ");
832
833    let Some(cmd_name) = args.get(idx + 1) else {
834        let result = crate::output::CommandResult::error(
835            "show-output-schema",
836            &format!("Missing command name. Available: {available}"),
837            "MISSING_ARGUMENT",
838        );
839        println!("{}", pretty_or_exit(&result, "command result"));
840        return Some(1);
841    };
842
843    if let Some(schema) = crate::output_schemas::get_output_schema(cmd_name) {
844        let result = crate::output::CommandResult::success(
845            "show-output-schema",
846            serialize_or_exit(&schema, "output schema"),
847        );
848        println!("{}", pretty_or_exit(&result, "command result"));
849        return Some(0);
850    }
851
852    let result = crate::output::CommandResult::error(
853        "show-output-schema",
854        &format!("Unknown command: {cmd_name}. Available: {available}"),
855        "UNKNOWN_COMMAND",
856    );
857    println!("{}", pretty_or_exit(&result, "command result"));
858    Some(1)
859}
860
861/// The URL `fraiseql migrate up|down|status` hands confiture, once confiture is installed and
862/// the server has passed the PostgreSQL floor (#1452).
863async fn migrate_database(
864    explicit: Option<&str>,
865    formatter: &crate::output::OutputFormatter,
866) -> anyhow::Result<String> {
867    crate::commands::migrate::require_confiture(formatter)?;
868    let database = crate::commands::migrate::resolve_database(explicit)?;
869    crate::commands::migrate::refuse_unsupported_server(&database).await?;
870    Ok(database.url)
871}