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