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    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
467    if let Err(e) = result {
468        let debug_info = if debug_output {
469            Some(format!("{e:?}"))
470        } else {
471            None
472        };
473        eprintln!("{}", format_cli_error(&e.to_string(), debug_info.as_deref(), json_output, 1));
474        process::exit(1);
475    }
476}
477
478/// Format a CLI error as either plain text or a JSON object for machine-readable output.
479///
480/// When `json` is `true`, produces a JSON object:
481/// ```json
482/// { "error": { "message": "...", "code": 1 } }
483/// ```
484/// When `json` is `false`, produces a human-readable string:
485/// ```text
486/// Error: <message>
487/// ```
488/// If `debug_info` is provided and `json` is `false`, a debug section is appended.
489pub(crate) fn format_cli_error(
490    message: &str,
491    debug_info: Option<&str>,
492    json: bool,
493    code: i32,
494) -> String {
495    if json {
496        serde_json::json!({
497            "error": {
498                "message": message,
499                "code": code
500            }
501        })
502        .to_string()
503    } else {
504        let mut out = format!("Error: {message}");
505        if let Some(debug) = debug_info {
506            out.push_str("\n\nDebug info:\n");
507            out.push_str(debug);
508        }
509        out
510    }
511}
512
513fn init_logging(verbose: bool, debug: bool) {
514    let filter = if debug {
515        "fraiseql=debug,fraiseql_core=debug"
516    } else if verbose {
517        "fraiseql=info,fraiseql_core=info"
518    } else {
519        "fraiseql=warn,fraiseql_core=warn"
520    };
521
522    tracing_subscriber::registry()
523        .with(
524            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into()),
525        )
526        .with(tracing_subscriber::fmt::layer())
527        .init();
528}
529
530/// Serialize a value to a JSON `Value`, printing to stderr and exiting with code 2 on failure.
531fn serialize_or_exit<T: serde::Serialize>(value: &T, context: &str) -> serde_json::Value {
532    serde_json::to_value(value).unwrap_or_else(|e| {
533        eprintln!("fraiseql: failed to serialize {context}: {e}");
534        std::process::exit(2);
535    })
536}
537
538/// Serialize a value to pretty-printed JSON, printing to stderr and exiting with code 2 on failure.
539fn pretty_or_exit<T: serde::Serialize>(value: &T, context: &str) -> String {
540    serde_json::to_string_pretty(value).unwrap_or_else(|e| {
541        eprintln!("fraiseql: failed to format {context}: {e}");
542        std::process::exit(2);
543    })
544}
545
546fn handle_introspection_flags() -> Option<i32> {
547    let args: Vec<String> = env::args().collect();
548
549    if args.iter().any(|a| a == "--help-json") {
550        let cmd = Cli::command();
551        let version = env!("CARGO_PKG_VERSION");
552        let help = crate::introspection::extract_cli_help(&cmd, version);
553        let result =
554            crate::output::CommandResult::success("help", serialize_or_exit(&help, "help output"));
555        println!("{}", pretty_or_exit(&result, "command result"));
556        return Some(0);
557    }
558
559    if args.iter().any(|a| a == "--list-commands") {
560        let cmd = Cli::command();
561        let commands = crate::introspection::list_commands(&cmd);
562        let result = crate::output::CommandResult::success(
563            "list-commands",
564            serialize_or_exit(&commands, "command list"),
565        );
566        println!("{}", pretty_or_exit(&result, "command result"));
567        return Some(0);
568    }
569
570    let idx = args.iter().position(|a| a == "--show-output-schema")?;
571    let available = crate::output_schemas::list_schema_commands().join(", ");
572
573    let Some(cmd_name) = args.get(idx + 1) else {
574        let result = crate::output::CommandResult::error(
575            "show-output-schema",
576            &format!("Missing command name. Available: {available}"),
577            "MISSING_ARGUMENT",
578        );
579        println!("{}", pretty_or_exit(&result, "command result"));
580        return Some(1);
581    };
582
583    if let Some(schema) = crate::output_schemas::get_output_schema(cmd_name) {
584        let result = crate::output::CommandResult::success(
585            "show-output-schema",
586            serialize_or_exit(&schema, "output schema"),
587        );
588        println!("{}", pretty_or_exit(&result, "command result"));
589        return Some(0);
590    }
591
592    let result = crate::output::CommandResult::error(
593        "show-output-schema",
594        &format!("Unknown command: {cmd_name}. Available: {available}"),
595        "UNKNOWN_COMMAND",
596    );
597    println!("{}", pretty_or_exit(&result, "command result"));
598    Some(1)
599}