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::Doctor {
481            config,
482            schema,
483            db_url,
484            against_db,
485            schemas,
486            json: json_flag,
487        } => {
488            let all_passed = commands::doctor::run_with_db_checks(
489                &config,
490                &schema,
491                db_url.as_deref(),
492                against_db.as_deref(),
493                &schemas,
494                json_flag,
495            )
496            .await;
497            if all_passed {
498                Ok(())
499            } else {
500                process::exit(1);
501            }
502        },
503
504        Commands::Perf { command } => match command {
505            PerfCommands::RegressionScan {
506                database,
507                recent_days,
508                baseline_days,
509                min_samples,
510                threshold_pct,
511                min_delta_ms,
512                object_type,
513                fail_on_regression,
514            } => {
515                let args = commands::perf::RegressionScanArgs {
516                    database,
517                    recent_days,
518                    baseline_days,
519                    min_samples,
520                    threshold_pct,
521                    min_delta_ms,
522                    object_type,
523                    fail_on_regression,
524                    json: cli.json,
525                    quiet: cli.quiet,
526                };
527                match commands::perf::run_regression_scan(args).await {
528                    Ok(true) => Ok(()),
529                    Ok(false) => process::exit(1),
530                    Err(e) => Err(e),
531                }
532            },
533            PerfCommands::Explore { command } => match command {
534                PerfExploreCommands::Slowest {
535                    database,
536                    days,
537                    object_type,
538                    limit,
539                } => {
540                    commands::perf::run_explore_slowest(
541                        database,
542                        days,
543                        object_type,
544                        limit,
545                        cli.json,
546                        cli.quiet,
547                    )
548                    .await
549                },
550                PerfExploreCommands::NullRate {
551                    database,
552                    days,
553                    object_type,
554                } => {
555                    commands::perf::run_explore_null_rate(
556                        database,
557                        days,
558                        object_type,
559                        cli.json,
560                        cli.quiet,
561                    )
562                    .await
563                },
564                PerfExploreCommands::Summary {
565                    database,
566                    days,
567                    object_type,
568                } => {
569                    commands::perf::run_explore_summary(
570                        database,
571                        days,
572                        object_type,
573                        cli.json,
574                        cli.quiet,
575                    )
576                    .await
577                },
578            },
579        },
580    };
581
582    if let Err(e) = result {
583        let debug_info = if debug_output {
584            Some(format!("{e:?}"))
585        } else {
586            None
587        };
588        eprintln!("{}", format_cli_error(&e.to_string(), debug_info.as_deref(), json_output, 1));
589        process::exit(1);
590    }
591}
592
593/// Enforce the CLI exit-code contract for a command that returns a [`CommandResult`].
594///
595/// Commands that print a [`CommandResult`] must also reflect its status in the
596/// process exit code. The contract (mirroring [`crate::output::get_exit_codes`]):
597///
598/// - `"success"` → return normally (the process exits 0)
599/// - `"validation-failed"` → exit **2** (the command ran, but the checked artifact failed its gate
600///   — e.g. a lint quality gate or a federation composition error)
601/// - any other status (e.g. `"error"`) → exit **1** (operational failure)
602///
603/// Operational errors surfaced as `Err(_)` are handled separately at the end of
604/// [`run`] and also exit 1.
605fn enforce_exit_code(result: &crate::output::CommandResult) {
606    match result.status.as_str() {
607        "success" => {},
608        "validation-failed" => process::exit(2),
609        _ => process::exit(1),
610    }
611}
612
613/// Format a CLI error as either plain text or a JSON object for machine-readable output.
614///
615/// When `json` is `true`, produces a JSON object:
616/// ```json
617/// { "error": { "message": "...", "code": 1 } }
618/// ```
619/// When `json` is `false`, produces a human-readable string:
620/// ```text
621/// Error: <message>
622/// ```
623/// If `debug_info` is provided and `json` is `false`, a debug section is appended.
624pub(crate) fn format_cli_error(
625    message: &str,
626    debug_info: Option<&str>,
627    json: bool,
628    code: i32,
629) -> String {
630    if json {
631        serde_json::json!({
632            "error": {
633                "message": message,
634                "code": code
635            }
636        })
637        .to_string()
638    } else {
639        let mut out = format!("Error: {message}");
640        if let Some(debug) = debug_info {
641            out.push_str("\n\nDebug info:\n");
642            out.push_str(debug);
643        }
644        out
645    }
646}
647
648fn init_logging(verbose: bool, debug: bool) {
649    let filter = if debug {
650        "fraiseql=debug,fraiseql_core=debug"
651    } else if verbose {
652        "fraiseql=info,fraiseql_core=info"
653    } else {
654        "fraiseql=warn,fraiseql_core=warn"
655    };
656
657    tracing_subscriber::registry()
658        .with(
659            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into()),
660        )
661        // Diagnostics go to stderr, never stdout — so a command whose stdout is
662        // the payload (e.g. `generate-capture-triggers … | psql`) is never
663        // corrupted by a stray WARN/INFO line.
664        .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
665        .init();
666}
667
668/// Serialize a value to a JSON `Value`, printing to stderr and exiting with code 2 on failure.
669fn serialize_or_exit<T: serde::Serialize>(value: &T, context: &str) -> serde_json::Value {
670    serde_json::to_value(value).unwrap_or_else(|e| {
671        eprintln!("fraiseql: failed to serialize {context}: {e}");
672        std::process::exit(2);
673    })
674}
675
676/// Serialize a value to pretty-printed JSON, printing to stderr and exiting with code 2 on failure.
677fn pretty_or_exit<T: serde::Serialize>(value: &T, context: &str) -> String {
678    serde_json::to_string_pretty(value).unwrap_or_else(|e| {
679        eprintln!("fraiseql: failed to format {context}: {e}");
680        std::process::exit(2);
681    })
682}
683
684fn handle_introspection_flags() -> Option<i32> {
685    let args: Vec<String> = env::args().collect();
686
687    if args.iter().any(|a| a == "--help-json") {
688        let cmd = Cli::command();
689        let version = env!("CARGO_PKG_VERSION");
690        let help = crate::introspection::extract_cli_help(&cmd, version);
691        let result =
692            crate::output::CommandResult::success("help", serialize_or_exit(&help, "help output"));
693        println!("{}", pretty_or_exit(&result, "command result"));
694        return Some(0);
695    }
696
697    if args.iter().any(|a| a == "--list-commands") {
698        let cmd = Cli::command();
699        let commands = crate::introspection::list_commands(&cmd);
700        let result = crate::output::CommandResult::success(
701            "list-commands",
702            serialize_or_exit(&commands, "command list"),
703        );
704        println!("{}", pretty_or_exit(&result, "command result"));
705        return Some(0);
706    }
707
708    let idx = args.iter().position(|a| a == "--show-output-schema")?;
709    let available = crate::output_schemas::list_schema_commands().join(", ");
710
711    let Some(cmd_name) = args.get(idx + 1) else {
712        let result = crate::output::CommandResult::error(
713            "show-output-schema",
714            &format!("Missing command name. Available: {available}"),
715            "MISSING_ARGUMENT",
716        );
717        println!("{}", pretty_or_exit(&result, "command result"));
718        return Some(1);
719    };
720
721    if let Some(schema) = crate::output_schemas::get_output_schema(cmd_name) {
722        let result = crate::output::CommandResult::success(
723            "show-output-schema",
724            serialize_or_exit(&schema, "output schema"),
725        );
726        println!("{}", pretty_or_exit(&result, "command result"));
727        return Some(0);
728    }
729
730    let result = crate::output::CommandResult::error(
731        "show-output-schema",
732        &format!("Unknown command: {cmd_name}. Available: {available}"),
733        "UNKNOWN_COMMAND",
734    );
735    println!("{}", pretty_or_exit(&result, "command result"));
736    Some(1)
737}