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