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, IntrospectCommands, MigrateCommands, SchemaCommands,
10    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        } => match command {
220            Some(ValidateCommands::Facts { schema, database }) => {
221                let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
222                commands::validate_facts::run(std::path::Path::new(&schema), &database, &formatter)
223                    .await
224            },
225            None => match input {
226                Some(input) => {
227                    let opts = commands::validate::ValidateOptions {
228                        check_cycles,
229                        check_unused,
230                        strict,
231                        filter_types: types,
232                    };
233                    match commands::validate::run_with_options(&input, opts) {
234                        Ok(result) => {
235                            println!(
236                                "{}",
237                                output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
238                            );
239                            if result.status == "validation-failed" {
240                                Err(anyhow::anyhow!("Validation failed"))
241                            } else {
242                                Ok(())
243                            }
244                        },
245                        Err(e) => Err(e),
246                    }
247                },
248                None => Err(anyhow::anyhow!("INPUT required when no subcommand provided")),
249            },
250        },
251
252        Commands::Introspect { command } => match command {
253            IntrospectCommands::Facts { database, format } => {
254                match commands::introspect_facts::OutputFormat::parse(&format) {
255                    Ok(fmt) => {
256                        let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
257                        commands::introspect_facts::run(&database, fmt, &formatter).await
258                    },
259                    Err(e) => Err(anyhow::anyhow!(e)),
260                }
261            },
262        },
263
264        Commands::Generate {
265            input,
266            language,
267            output,
268        } => match commands::init::Language::from_str(&language) {
269            Ok(lang) => commands::generate::run(&input, lang, output.as_deref()),
270            Err(e) => Err(anyhow::anyhow!(e)),
271        },
272
273        Commands::Init {
274            project_name,
275            language,
276            database,
277            size,
278            no_git,
279        } => {
280            match (
281                commands::init::Language::from_str(&language),
282                commands::init::Database::from_str(&database),
283                commands::init::ProjectSize::from_str(&size),
284            ) {
285                (Ok(lang), Ok(db), Ok(sz)) => {
286                    let config = commands::init::InitConfig {
287                        project_name,
288                        language: lang,
289                        database: db,
290                        size: sz,
291                        no_git,
292                    };
293                    commands::init::run(&config)
294                },
295                (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => Err(anyhow::anyhow!(e)),
296            }
297        },
298
299        Commands::Migrate { command } => {
300            let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
301            match command {
302                MigrateCommands::Up { database, dir } => {
303                    let db_url = commands::migrate::resolve_database_url(database.as_deref());
304                    match db_url {
305                        Ok(url) => {
306                            let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
307                            let action = commands::migrate::MigrateAction::Up {
308                                database_url: url,
309                                dir:          mig_dir,
310                            };
311                            commands::migrate::run(&action, &formatter)
312                        },
313                        Err(e) => Err(e),
314                    }
315                },
316                MigrateCommands::Down {
317                    database,
318                    dir,
319                    steps,
320                } => {
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::Down {
326                                database_url: url,
327                                dir: mig_dir,
328                                steps,
329                            };
330                            commands::migrate::run(&action, &formatter)
331                        },
332                        Err(e) => Err(e),
333                    }
334                },
335                MigrateCommands::Status { database, dir } => {
336                    let db_url = commands::migrate::resolve_database_url(database.as_deref());
337                    match db_url {
338                        Ok(url) => {
339                            let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
340                            let action = commands::migrate::MigrateAction::Status {
341                                database_url: url,
342                                dir:          mig_dir,
343                            };
344                            commands::migrate::run(&action, &formatter)
345                        },
346                        Err(e) => Err(e),
347                    }
348                },
349                MigrateCommands::Create { name, dir } => {
350                    let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
351                    let action = commands::migrate::MigrateAction::Create { name, dir: mig_dir };
352                    commands::migrate::run(&action, &formatter)
353                },
354                MigrateCommands::Generate { name, dir } => {
355                    let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
356                    let action = commands::migrate::MigrateAction::Generate { name, dir: mig_dir };
357                    commands::migrate::run(&action, &formatter)
358                },
359                MigrateCommands::Validate { dir } => {
360                    let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
361                    let action = commands::migrate::MigrateAction::Validate { dir: mig_dir };
362                    commands::migrate::run(&action, &formatter)
363                },
364                MigrateCommands::Preflight { dir } => {
365                    let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
366                    let action = commands::migrate::MigrateAction::Preflight { dir: mig_dir };
367                    commands::migrate::run(&action, &formatter)
368                },
369            }
370        },
371
372        Commands::Sbom { format, output } => match commands::sbom::SbomFormat::from_str(&format) {
373            Ok(fmt) => commands::sbom::run(fmt, output.as_deref()),
374            Err(e) => Err(anyhow::anyhow!(e)),
375        },
376
377        #[cfg(feature = "run-server")]
378        Commands::Run {
379            input,
380            database,
381            port,
382            bind,
383            watch,
384            introspection,
385        } => commands::run::run(input.as_deref(), database, port, bind, watch, introspection).await,
386
387        Commands::ValidateDocuments { manifest } => {
388            let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
389            match commands::validate_documents::run(&manifest, &formatter) {
390                Ok(true) => Ok(()),
391                Ok(false) => {
392                    process::exit(2);
393                },
394                Err(e) => Err(e),
395            }
396        },
397
398        Commands::Serve { schema, port } => commands::serve::run(&schema, port).await,
399
400        Commands::Setup { database, dry_run } => {
401            let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
402            commands::setup::run(database.as_deref(), dry_run, &formatter).await
403        },
404
405        Commands::Schema { command } => match command {
406            SchemaCommands::Metadata { server, token } => {
407                commands::schema::metadata::run(&server, token.as_deref()).await
408            },
409        },
410
411        Commands::Doctor {
412            config,
413            schema,
414            db_url,
415            json: json_flag,
416        } => {
417            let all_passed = commands::doctor::run(&config, &schema, db_url.as_deref(), json_flag);
418            if all_passed {
419                Ok(())
420            } else {
421                process::exit(1);
422            }
423        },
424    };
425
426    if let Err(e) = result {
427        let debug_info = if debug_output {
428            Some(format!("{e:?}"))
429        } else {
430            None
431        };
432        eprintln!("{}", format_cli_error(&e.to_string(), debug_info.as_deref(), json_output, 1));
433        process::exit(1);
434    }
435}
436
437/// Format a CLI error as either plain text or a JSON object for machine-readable output.
438///
439/// When `json` is `true`, produces a JSON object:
440/// ```json
441/// { "error": { "message": "...", "code": 1 } }
442/// ```
443/// When `json` is `false`, produces a human-readable string:
444/// ```text
445/// Error: <message>
446/// ```
447/// If `debug_info` is provided and `json` is `false`, a debug section is appended.
448pub(crate) fn format_cli_error(
449    message: &str,
450    debug_info: Option<&str>,
451    json: bool,
452    code: i32,
453) -> String {
454    if json {
455        serde_json::json!({
456            "error": {
457                "message": message,
458                "code": code
459            }
460        })
461        .to_string()
462    } else {
463        let mut out = format!("Error: {message}");
464        if let Some(debug) = debug_info {
465            out.push_str("\n\nDebug info:\n");
466            out.push_str(debug);
467        }
468        out
469    }
470}
471
472fn init_logging(verbose: bool, debug: bool) {
473    let filter = if debug {
474        "fraiseql=debug,fraiseql_core=debug"
475    } else if verbose {
476        "fraiseql=info,fraiseql_core=info"
477    } else {
478        "fraiseql=warn,fraiseql_core=warn"
479    };
480
481    tracing_subscriber::registry()
482        .with(
483            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into()),
484        )
485        .with(tracing_subscriber::fmt::layer())
486        .init();
487}
488
489/// Serialize a value to a JSON `Value`, printing to stderr and exiting with code 2 on failure.
490fn serialize_or_exit<T: serde::Serialize>(value: &T, context: &str) -> serde_json::Value {
491    serde_json::to_value(value).unwrap_or_else(|e| {
492        eprintln!("fraiseql: failed to serialize {context}: {e}");
493        std::process::exit(2);
494    })
495}
496
497/// Serialize a value to pretty-printed JSON, printing to stderr and exiting with code 2 on failure.
498fn pretty_or_exit<T: serde::Serialize>(value: &T, context: &str) -> String {
499    serde_json::to_string_pretty(value).unwrap_or_else(|e| {
500        eprintln!("fraiseql: failed to format {context}: {e}");
501        std::process::exit(2);
502    })
503}
504
505fn handle_introspection_flags() -> Option<i32> {
506    let args: Vec<String> = env::args().collect();
507
508    if args.iter().any(|a| a == "--help-json") {
509        let cmd = Cli::command();
510        let version = env!("CARGO_PKG_VERSION");
511        let help = crate::introspection::extract_cli_help(&cmd, version);
512        let result =
513            crate::output::CommandResult::success("help", serialize_or_exit(&help, "help output"));
514        println!("{}", pretty_or_exit(&result, "command result"));
515        return Some(0);
516    }
517
518    if args.iter().any(|a| a == "--list-commands") {
519        let cmd = Cli::command();
520        let commands = crate::introspection::list_commands(&cmd);
521        let result = crate::output::CommandResult::success(
522            "list-commands",
523            serialize_or_exit(&commands, "command list"),
524        );
525        println!("{}", pretty_or_exit(&result, "command result"));
526        return Some(0);
527    }
528
529    let idx = args.iter().position(|a| a == "--show-output-schema")?;
530    let available = crate::output_schemas::list_schema_commands().join(", ");
531
532    let Some(cmd_name) = args.get(idx + 1) else {
533        let result = crate::output::CommandResult::error(
534            "show-output-schema",
535            &format!("Missing command name. Available: {available}"),
536            "MISSING_ARGUMENT",
537        );
538        println!("{}", pretty_or_exit(&result, "command result"));
539        return Some(1);
540    };
541
542    if let Some(schema) = crate::output_schemas::get_output_schema(cmd_name) {
543        let result = crate::output::CommandResult::success(
544            "show-output-schema",
545            serialize_or_exit(&schema, "output schema"),
546        );
547        println!("{}", pretty_or_exit(&result, "command result"));
548        return Some(0);
549    }
550
551    let result = crate::output::CommandResult::error(
552        "show-output-schema",
553        &format!("Unknown command: {cmd_name}. Available: {available}"),
554        "UNKNOWN_COMMAND",
555    );
556    println!("{}", pretty_or_exit(&result, "command result"));
557    Some(1)
558}