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