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