Skip to main content

fraiseql_cli/
lib.rs

1//! FraiseQL CLI library - exposes internal modules for testing and reuse
2
3#![warn(clippy::all)]
4#![warn(clippy::pedantic)]
5#![allow(clippy::format_push_string)]
6#![allow(clippy::option_if_let_else)]
7#![allow(clippy::needless_pass_by_value)]
8#![allow(clippy::must_use_candidate)]
9#![allow(clippy::module_name_repetitions)]
10#![allow(clippy::missing_errors_doc)]
11#![allow(clippy::doc_markdown)]
12#![allow(clippy::too_many_lines)]
13#![allow(clippy::unnecessary_wraps)]
14#![allow(clippy::match_same_arms)]
15#![allow(clippy::similar_names)]
16#![allow(clippy::struct_excessive_bools)]
17#![allow(clippy::derive_partial_eq_without_eq)]
18#![allow(clippy::missing_const_for_fn)] // Reason: const fn not stable for all patterns used
19
20pub mod commands;
21pub mod config;
22pub mod introspection;
23pub mod output;
24pub mod output_schemas;
25pub mod schema;
26
27use std::{env, process, str::FromStr};
28
29use clap::{CommandFactory, Parser, Subcommand};
30use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
31
32/// Exit codes documented in help text
33const EXIT_CODES_HELP: &str = "\
34EXIT CODES:
35    0  Success - Command completed successfully
36    1  Error - Command failed with an error
37    2  Validation failed - Schema or input validation failed";
38
39/// FraiseQL CLI - Compile GraphQL schemas to optimized SQL execution
40#[derive(Parser)]
41#[command(name = "fraiseql")]
42#[command(author, version, about, long_about = None)]
43#[command(propagate_version = true)]
44#[command(after_help = EXIT_CODES_HELP)]
45struct Cli {
46    /// Enable verbose logging
47    #[arg(short, long, global = true)]
48    verbose: bool,
49
50    /// Enable debug logging
51    #[arg(short, long, global = true)]
52    debug: bool,
53
54    /// Output as JSON (machine-readable)
55    #[arg(long, global = true)]
56    json: bool,
57
58    /// Suppress output (exit code only)
59    #[arg(short, long, global = true)]
60    quiet: bool,
61
62    #[command(subcommand)]
63    command: Commands,
64}
65
66#[derive(Subcommand)]
67enum Commands {
68    /// Compile schema to optimized schema.compiled.json
69    ///
70    /// Supports three workflows:
71    /// 1. TOML-only: fraiseql compile fraiseql.toml
72    /// 2. Language + TOML: fraiseql compile fraiseql.toml --types types.json
73    /// 3. Legacy JSON: fraiseql compile schema.json
74    #[command(after_help = "\
75EXAMPLES:
76    fraiseql compile fraiseql.toml
77    fraiseql compile fraiseql.toml --types types.json
78    fraiseql compile schema.json -o schema.compiled.json
79    fraiseql compile fraiseql.toml --check")]
80    Compile {
81        /// Input file path: fraiseql.toml (TOML) or schema.json (legacy)
82        #[arg(value_name = "INPUT")]
83        input: String,
84
85        /// Optional types.json from language implementation (used with fraiseql.toml)
86        #[arg(long, value_name = "TYPES")]
87        types: Option<String>,
88
89        /// Directory for auto-discovery of schema files (recursive *.json)
90        #[arg(long, value_name = "DIR")]
91        schema_dir: Option<String>,
92
93        /// Type files (repeatable): fraiseql compile fraiseql.toml --type-file a.json --type-file
94        /// b.json
95        #[arg(long = "type-file", value_name = "FILE")]
96        type_files: Vec<String>,
97
98        /// Query files (repeatable)
99        #[arg(long = "query-file", value_name = "FILE")]
100        query_files: Vec<String>,
101
102        /// Mutation files (repeatable)
103        #[arg(long = "mutation-file", value_name = "FILE")]
104        mutation_files: Vec<String>,
105
106        /// Output schema.compiled.json file path
107        #[arg(
108            short,
109            long,
110            value_name = "OUTPUT",
111            default_value = "schema.compiled.json"
112        )]
113        output: String,
114
115        /// Validate only, don't write output
116        #[arg(long)]
117        check: bool,
118
119        /// Optional database URL for indexed column validation
120        /// When provided, validates that indexed columns exist in database views
121        #[arg(long, value_name = "DATABASE_URL")]
122        database: Option<String>,
123    },
124
125    /// Extract schema from annotated source files
126    ///
127    /// Parses FraiseQL annotations in any supported language and generates schema.json.
128    /// No language runtime required — pure text processing.
129    #[command(after_help = "\
130EXAMPLES:
131    fraiseql extract schema/schema.py
132    fraiseql extract schema/ --recursive
133    fraiseql extract schema.rs --language rust -o schema.json")]
134    Extract {
135        /// Source file(s) or directory to extract from
136        #[arg(value_name = "INPUT")]
137        input: Vec<String>,
138
139        /// Override language detection (python, typescript, rust, java, kotlin, go, csharp, swift,
140        /// scala)
141        #[arg(short, long)]
142        language: Option<String>,
143
144        /// Recursively scan directories
145        #[arg(short, long)]
146        recursive: bool,
147
148        /// Output file path
149        #[arg(short, long, default_value = "schema.json")]
150        output: String,
151    },
152
153    /// Explain query execution plan and complexity
154    ///
155    /// Shows GraphQL query execution plan, SQL, and complexity analysis.
156    #[command(after_help = "\
157EXAMPLES:
158    fraiseql explain '{ users { id name } }'
159    fraiseql explain '{ user(id: 1) { posts { title } } }' --json")]
160    Explain {
161        /// GraphQL query string
162        #[arg(value_name = "QUERY")]
163        query: String,
164    },
165
166    /// Calculate query complexity score
167    ///
168    /// Quick analysis of query complexity (depth, field count, score).
169    #[command(after_help = "\
170EXAMPLES:
171    fraiseql cost '{ users { id name } }'
172    fraiseql cost '{ deeply { nested { query { here } } } }' --json")]
173    Cost {
174        /// GraphQL query string
175        #[arg(value_name = "QUERY")]
176        query: String,
177    },
178
179    /// Analyze schema for optimization opportunities
180    ///
181    /// Provides recommendations across 6 categories:
182    /// performance, security, federation, complexity, caching, indexing
183    #[command(after_help = "\
184EXAMPLES:
185    fraiseql analyze schema.compiled.json
186    fraiseql analyze schema.compiled.json --json")]
187    Analyze {
188        /// Path to schema.compiled.json
189        #[arg(value_name = "SCHEMA")]
190        schema: String,
191    },
192
193    /// Analyze schema type dependencies
194    ///
195    /// Exports dependency graph, detects cycles, and finds unused types.
196    /// Supports multiple output formats for visualization and CI integration.
197    #[command(after_help = "\
198EXAMPLES:
199    fraiseql dependency-graph schema.compiled.json
200    fraiseql dependency-graph schema.compiled.json -f dot > graph.dot
201    fraiseql dependency-graph schema.compiled.json -f mermaid
202    fraiseql dependency-graph schema.compiled.json --json")]
203    DependencyGraph {
204        /// Path to schema.compiled.json
205        #[arg(value_name = "SCHEMA")]
206        schema: String,
207
208        /// Output format (json, dot, mermaid, d2, console)
209        #[arg(short, long, value_name = "FORMAT", default_value = "json")]
210        format: String,
211    },
212
213    /// Export federation dependency graph
214    ///
215    /// Visualize federation structure in multiple formats.
216    #[command(after_help = "\
217EXAMPLES:
218    fraiseql federation graph schema.compiled.json
219    fraiseql federation graph schema.compiled.json -f dot
220    fraiseql federation graph schema.compiled.json -f mermaid")]
221    Federation {
222        /// Schema path (positional argument passed to subcommand)
223        #[command(subcommand)]
224        command: FederationCommands,
225    },
226
227    /// Lint schema for FraiseQL design quality
228    ///
229    /// Analyzes schema using FraiseQL-calibrated design rules.
230    /// Detects JSONB batching issues, compilation problems, auth boundaries, etc.
231    #[command(after_help = "\
232EXAMPLES:
233    fraiseql lint schema.json
234    fraiseql lint schema.compiled.json --federation
235    fraiseql lint schema.json --fail-on-critical
236    fraiseql lint schema.json --json")]
237    Lint {
238        /// Path to schema.json or schema.compiled.json
239        #[arg(value_name = "SCHEMA")]
240        schema: String,
241
242        /// Only show federation audit
243        #[arg(long)]
244        federation: bool,
245
246        /// Only show cost audit
247        #[arg(long)]
248        cost: bool,
249
250        /// Only show cache audit
251        #[arg(long)]
252        cache: bool,
253
254        /// Only show auth audit
255        #[arg(long)]
256        auth: bool,
257
258        /// Only show compilation audit
259        #[arg(long)]
260        compilation: bool,
261
262        /// Exit with error if any critical issues found
263        #[arg(long)]
264        fail_on_critical: bool,
265
266        /// Exit with error if any warning or critical issues found
267        #[arg(long)]
268        fail_on_warning: bool,
269
270        /// Show detailed issue descriptions
271        #[arg(long)]
272        verbose: bool,
273    },
274
275    /// Generate DDL for Arrow views (va_*, tv_*, ta_*)
276    #[command(after_help = "\
277EXAMPLES:
278    fraiseql generate-views -s schema.json -e User --view va_users
279    fraiseql generate-views -s schema.json -e Order --view tv_orders --refresh-strategy scheduled")]
280    GenerateViews {
281        /// Path to schema.json
282        #[arg(short, long, value_name = "SCHEMA")]
283        schema: String,
284
285        /// Entity name from schema
286        #[arg(short, long, value_name = "NAME")]
287        entity: String,
288
289        /// View name (must start with va_, tv_, or ta_)
290        #[arg(long, value_name = "NAME")]
291        view: String,
292
293        /// Refresh strategy (trigger-based or scheduled)
294        #[arg(long, value_name = "STRATEGY", default_value = "trigger-based")]
295        refresh_strategy: String,
296
297        /// Output file path (default: {view}.sql)
298        #[arg(short, long, value_name = "PATH")]
299        output: Option<String>,
300
301        /// Include helper/composition views
302        #[arg(long, default_value = "true")]
303        include_composition_views: bool,
304
305        /// Include monitoring functions
306        #[arg(long, default_value = "true")]
307        include_monitoring: bool,
308
309        /// Validate only, don't write file
310        #[arg(long)]
311        validate: bool,
312
313        /// Show generation steps (use global --verbose flag)
314        #[arg(long, action = clap::ArgAction::SetTrue)]
315        gen_verbose: bool,
316    },
317
318    /// Validate schema.json or fact tables
319    ///
320    /// Performs comprehensive schema validation including:
321    /// - JSON structure validation
322    /// - Type reference validation
323    /// - Circular dependency detection (with --check-cycles)
324    /// - Unused type detection (with --check-unused)
325    #[command(after_help = "\
326EXAMPLES:
327    fraiseql validate schema.json
328    fraiseql validate schema.json --check-unused
329    fraiseql validate schema.json --strict
330    fraiseql validate facts -s schema.json -d postgres://localhost/db")]
331    Validate {
332        #[command(subcommand)]
333        command: Option<ValidateCommands>,
334
335        /// Schema.json file path to validate (if no subcommand)
336        #[arg(value_name = "INPUT")]
337        input: Option<String>,
338
339        /// Check for circular dependencies between types
340        #[arg(long, default_value = "true")]
341        check_cycles: bool,
342
343        /// Check for unused types (no incoming references)
344        #[arg(long)]
345        check_unused: bool,
346
347        /// Strict mode: treat warnings as errors (unused types become errors)
348        #[arg(long)]
349        strict: bool,
350
351        /// Only analyze specific type(s) - comma-separated list
352        #[arg(long, value_name = "TYPES", value_delimiter = ',')]
353        types: Vec<String>,
354    },
355
356    /// Introspect database for fact tables and output suggestions
357    #[command(after_help = "\
358EXAMPLES:
359    fraiseql introspect facts -d postgres://localhost/db
360    fraiseql introspect facts -d postgres://localhost/db -f json")]
361    Introspect {
362        #[command(subcommand)]
363        command: IntrospectCommands,
364    },
365
366    /// Generate authoring-language source from schema.json
367    ///
368    /// The inverse of `fraiseql extract`: reads a schema.json and produces annotated
369    /// source code in any of the 9 supported authoring languages.
370    #[command(after_help = "\
371EXAMPLES:
372    fraiseql generate schema.json --language python
373    fraiseql generate schema.json --language rust -o schema.rs
374    fraiseql generate schema.json --language typescript")]
375    Generate {
376        /// Path to schema.json
377        #[arg(value_name = "INPUT")]
378        input: String,
379
380        /// Target language (python, typescript, rust, java, kotlin, go, csharp, swift, scala)
381        #[arg(short, long)]
382        language: String,
383
384        /// Output file path (default: schema.<ext> based on language)
385        #[arg(short, long)]
386        output: Option<String>,
387    },
388
389    /// Initialize a new FraiseQL project
390    ///
391    /// Creates project directory with fraiseql.toml, schema.json,
392    /// database DDL structure, and authoring skeleton.
393    #[command(after_help = "\
394EXAMPLES:
395    fraiseql init my-app
396    fraiseql init my-app --language typescript --database postgres
397    fraiseql init my-app --size xs --no-git")]
398    Init {
399        /// Project name (used as directory name)
400        #[arg(value_name = "PROJECT_NAME")]
401        project_name: String,
402
403        /// Authoring language (python, typescript, rust, java, kotlin, go, csharp, swift, scala)
404        #[arg(short, long, default_value = "python")]
405        language: String,
406
407        /// Target database (postgres, mysql, sqlite, sqlserver)
408        #[arg(long, default_value = "postgres")]
409        database: String,
410
411        /// Project size: xs (single file), s (flat dirs), m (per-entity dirs)
412        #[arg(long, default_value = "s")]
413        size: String,
414
415        /// Skip git init
416        #[arg(long)]
417        no_git: bool,
418    },
419
420    /// Run database migrations
421    ///
422    /// Wraps confiture for a unified migration experience.
423    /// Reads database URL from --database, fraiseql.toml, or DATABASE_URL env var.
424    #[command(after_help = "\
425EXAMPLES:
426    fraiseql migrate up --database postgres://localhost/mydb
427    fraiseql migrate down --steps 1
428    fraiseql migrate status
429    fraiseql migrate create add_posts_table")]
430    Migrate {
431        #[command(subcommand)]
432        command: MigrateCommands,
433    },
434
435    /// Generate Software Bill of Materials
436    ///
437    /// Parses Cargo.lock and fraiseql.toml to produce a compliance-ready SBOM.
438    #[command(after_help = "\
439EXAMPLES:
440    fraiseql sbom
441    fraiseql sbom --format spdx
442    fraiseql sbom --format cyclonedx --output sbom.json")]
443    Sbom {
444        /// Output format (cyclonedx, spdx)
445        #[arg(short, long, default_value = "cyclonedx")]
446        format: String,
447
448        /// Output file path (default: stdout)
449        #[arg(short, long, value_name = "FILE")]
450        output: Option<String>,
451    },
452
453    /// Compile schema and immediately start the GraphQL server
454    ///
455    /// Compiles the schema in-memory (no disk artifact) and starts the HTTP server.
456    /// With --watch, the server hot-reloads whenever the schema file changes.
457    #[command(after_help = "\
458EXAMPLES:
459    fraiseql run
460    fraiseql run fraiseql.toml --database postgres://localhost/mydb
461    fraiseql run --port 3000 --watch
462    fraiseql run schema.json --introspection")]
463    Run {
464        /// Input file path (fraiseql.toml or schema.json); auto-detected if omitted
465        #[arg(value_name = "INPUT")]
466        input: Option<String>,
467
468        /// Database URL (falls back to DATABASE_URL env var)
469        #[arg(short, long, value_name = "DATABASE_URL")]
470        database: Option<String>,
471
472        /// Port to listen on
473        #[arg(short, long, default_value = "8080")]
474        port: u16,
475
476        /// Bind address
477        #[arg(long, default_value = "0.0.0.0")]
478        bind: String,
479
480        /// Watch input file for changes and hot-reload the server
481        #[arg(short, long)]
482        watch: bool,
483
484        /// Enable the GraphQL introspection endpoint (no auth required)
485        #[arg(long)]
486        introspection: bool,
487    },
488
489    /// Development server with hot-reload
490    #[command(hide = true)] // Hide until implemented
491    Serve {
492        /// Schema.json file path to watch
493        #[arg(value_name = "SCHEMA")]
494        schema: String,
495
496        /// Port to listen on
497        #[arg(short, long, default_value = "8080")]
498        port: u16,
499    },
500}
501
502#[derive(Subcommand)]
503enum ValidateCommands {
504    /// Validate that declared fact tables match database schema
505    Facts {
506        /// Schema.json file path
507        #[arg(short, long, value_name = "SCHEMA")]
508        schema: String,
509
510        /// Database connection string
511        #[arg(short, long, value_name = "DATABASE_URL")]
512        database: String,
513    },
514}
515
516#[derive(Subcommand)]
517enum FederationCommands {
518    /// Export federation graph
519    Graph {
520        /// Path to schema.compiled.json
521        #[arg(value_name = "SCHEMA")]
522        schema: String,
523
524        /// Output format (json, dot, mermaid)
525        #[arg(short, long, value_name = "FORMAT", default_value = "json")]
526        format: String,
527    },
528}
529
530#[derive(Subcommand)]
531enum IntrospectCommands {
532    /// Introspect database for fact tables (tf_* tables)
533    Facts {
534        /// Database connection string
535        #[arg(short, long, value_name = "DATABASE_URL")]
536        database: String,
537
538        /// Output format (python, json)
539        #[arg(short, long, value_name = "FORMAT", default_value = "python")]
540        format: String,
541    },
542}
543
544#[derive(Subcommand)]
545enum MigrateCommands {
546    /// Apply pending migrations
547    Up {
548        /// Database connection URL
549        #[arg(short, long, value_name = "DATABASE_URL")]
550        database: Option<String>,
551
552        /// Migration directory
553        #[arg(long, value_name = "DIR")]
554        dir: Option<String>,
555    },
556
557    /// Roll back migrations
558    Down {
559        /// Database connection URL
560        #[arg(short, long, value_name = "DATABASE_URL")]
561        database: Option<String>,
562
563        /// Migration directory
564        #[arg(long, value_name = "DIR")]
565        dir: Option<String>,
566
567        /// Number of migrations to roll back
568        #[arg(long, default_value = "1")]
569        steps: u32,
570    },
571
572    /// Show migration status
573    Status {
574        /// Database connection URL
575        #[arg(short, long, value_name = "DATABASE_URL")]
576        database: Option<String>,
577
578        /// Migration directory
579        #[arg(long, value_name = "DIR")]
580        dir: Option<String>,
581    },
582
583    /// Create a new migration file
584    Create {
585        /// Migration name
586        #[arg(value_name = "NAME")]
587        name: String,
588
589        /// Migration directory
590        #[arg(long, value_name = "DIR")]
591        dir: Option<String>,
592    },
593}
594
595/// Run the FraiseQL CLI. Called from both the `fraiseql-cli` and `fraiseql` binary entry points.
596pub async fn run() {
597    use crate::{commands, output};
598
599    if let Some(code) = handle_introspection_flags() {
600        process::exit(code);
601    }
602
603    let cli = Cli::parse();
604
605    init_logging(cli.verbose, cli.debug);
606
607    let result = match cli.command {
608        Commands::Compile {
609            input,
610            types,
611            schema_dir,
612            type_files,
613            query_files,
614            mutation_files,
615            output,
616            check,
617            database,
618        } => {
619            commands::compile::run(
620                &input,
621                types.as_deref(),
622                schema_dir.as_deref(),
623                type_files,
624                query_files,
625                mutation_files,
626                &output,
627                check,
628                database.as_deref(),
629            )
630            .await
631        },
632
633        Commands::Extract {
634            input,
635            language,
636            recursive,
637            output,
638        } => commands::extract::run(&input, language.as_deref(), recursive, &output),
639
640        Commands::Explain { query } => match commands::explain::run(&query) {
641            Ok(result) => {
642                println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
643                Ok(())
644            },
645            Err(e) => Err(e),
646        },
647
648        Commands::Cost { query } => match commands::cost::run(&query) {
649            Ok(result) => {
650                println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
651                Ok(())
652            },
653            Err(e) => Err(e),
654        },
655
656        Commands::Analyze { schema } => match commands::analyze::run(&schema) {
657            Ok(result) => {
658                println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
659                Ok(())
660            },
661            Err(e) => Err(e),
662        },
663
664        Commands::DependencyGraph { schema, format } => {
665            match commands::dependency_graph::GraphFormat::from_str(&format) {
666                Ok(fmt) => match commands::dependency_graph::run(&schema, fmt) {
667                    Ok(result) => {
668                        println!(
669                            "{}",
670                            output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
671                        );
672                        Ok(())
673                    },
674                    Err(e) => Err(e),
675                },
676                Err(e) => Err(anyhow::anyhow!(e)),
677            }
678        },
679
680        Commands::Lint {
681            schema,
682            federation: _,
683            cost: _,
684            cache: _,
685            auth: _,
686            compilation: _,
687            fail_on_critical,
688            fail_on_warning,
689            verbose: _,
690        } => {
691            let opts = commands::lint::LintOptions {
692                fail_on_critical,
693                fail_on_warning,
694            };
695            match commands::lint::run(&schema, opts) {
696                Ok(result) => {
697                    println!(
698                        "{}",
699                        output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
700                    );
701                    Ok(())
702                },
703                Err(e) => Err(e),
704            }
705        },
706
707        Commands::Federation { command } => match command {
708            FederationCommands::Graph { schema, format } => {
709                match commands::federation::graph::GraphFormat::from_str(&format) {
710                    Ok(fmt) => match commands::federation::graph::run(&schema, fmt) {
711                        Ok(result) => {
712                            println!(
713                                "{}",
714                                output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
715                            );
716                            Ok(())
717                        },
718                        Err(e) => Err(e),
719                    },
720                    Err(e) => Err(anyhow::anyhow!(e)),
721                }
722            },
723        },
724
725        Commands::GenerateViews {
726            schema,
727            entity,
728            view,
729            refresh_strategy,
730            output,
731            include_composition_views,
732            include_monitoring,
733            validate,
734            gen_verbose,
735        } => match commands::generate_views::RefreshStrategy::parse(&refresh_strategy) {
736            Ok(refresh_strat) => {
737                let config = commands::generate_views::GenerateViewsConfig {
738                    schema_path: schema,
739                    entity,
740                    view,
741                    refresh_strategy: refresh_strat,
742                    output,
743                    include_composition_views,
744                    include_monitoring,
745                    validate_only: validate,
746                    verbose: cli.verbose || gen_verbose,
747                };
748
749                commands::generate_views::run(config)
750            },
751            Err(e) => Err(anyhow::anyhow!(e)),
752        },
753
754        Commands::Validate {
755            command,
756            input,
757            check_cycles,
758            check_unused,
759            strict,
760            types,
761        } => match command {
762            Some(ValidateCommands::Facts { schema, database }) => {
763                commands::validate_facts::run(std::path::Path::new(&schema), &database).await
764            },
765            None => match input {
766                Some(input) => {
767                    let opts = commands::validate::ValidateOptions {
768                        check_cycles,
769                        check_unused,
770                        strict,
771                        filter_types: types,
772                    };
773                    match commands::validate::run_with_options(&input, opts) {
774                        Ok(result) => {
775                            println!(
776                                "{}",
777                                output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
778                            );
779                            if result.status == "validation-failed" {
780                                Err(anyhow::anyhow!("Validation failed"))
781                            } else {
782                                Ok(())
783                            }
784                        },
785                        Err(e) => Err(e),
786                    }
787                },
788                None => Err(anyhow::anyhow!("INPUT required when no subcommand provided")),
789            },
790        },
791
792        Commands::Introspect { command } => match command {
793            IntrospectCommands::Facts { database, format } => {
794                match commands::introspect_facts::OutputFormat::parse(&format) {
795                    Ok(fmt) => commands::introspect_facts::run(&database, fmt).await,
796                    Err(e) => Err(anyhow::anyhow!(e)),
797                }
798            },
799        },
800
801        Commands::Generate {
802            input,
803            language,
804            output,
805        } => match commands::init::Language::from_str(&language) {
806            Ok(lang) => commands::generate::run(&input, lang, output.as_deref()),
807            Err(e) => Err(anyhow::anyhow!(e)),
808        },
809
810        Commands::Init {
811            project_name,
812            language,
813            database,
814            size,
815            no_git,
816        } => {
817            match (
818                commands::init::Language::from_str(&language),
819                commands::init::Database::from_str(&database),
820                commands::init::ProjectSize::from_str(&size),
821            ) {
822                (Ok(lang), Ok(db), Ok(sz)) => {
823                    let config = commands::init::InitConfig {
824                        project_name,
825                        language: lang,
826                        database: db,
827                        size: sz,
828                        no_git,
829                    };
830                    commands::init::run(&config)
831                },
832                (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => Err(anyhow::anyhow!(e)),
833            }
834        },
835
836        Commands::Migrate { command } => match command {
837            MigrateCommands::Up { database, dir } => {
838                let db_url = commands::migrate::resolve_database_url(database.as_deref());
839                match db_url {
840                    Ok(url) => {
841                        let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
842                        let action = commands::migrate::MigrateAction::Up {
843                            database_url: url,
844                            dir:          mig_dir,
845                        };
846                        commands::migrate::run(&action)
847                    },
848                    Err(e) => Err(e),
849                }
850            },
851            MigrateCommands::Down {
852                database,
853                dir,
854                steps,
855            } => {
856                let db_url = commands::migrate::resolve_database_url(database.as_deref());
857                match db_url {
858                    Ok(url) => {
859                        let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
860                        let action = commands::migrate::MigrateAction::Down {
861                            database_url: url,
862                            dir: mig_dir,
863                            steps,
864                        };
865                        commands::migrate::run(&action)
866                    },
867                    Err(e) => Err(e),
868                }
869            },
870            MigrateCommands::Status { database, dir } => {
871                let db_url = commands::migrate::resolve_database_url(database.as_deref());
872                match db_url {
873                    Ok(url) => {
874                        let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
875                        let action = commands::migrate::MigrateAction::Status {
876                            database_url: url,
877                            dir:          mig_dir,
878                        };
879                        commands::migrate::run(&action)
880                    },
881                    Err(e) => Err(e),
882                }
883            },
884            MigrateCommands::Create { name, dir } => {
885                let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
886                let action = commands::migrate::MigrateAction::Create { name, dir: mig_dir };
887                commands::migrate::run(&action)
888            },
889        },
890
891        Commands::Sbom { format, output } => match commands::sbom::SbomFormat::from_str(&format) {
892            Ok(fmt) => commands::sbom::run(fmt, output.as_deref()),
893            Err(e) => Err(anyhow::anyhow!(e)),
894        },
895
896        Commands::Run {
897            input,
898            database,
899            port,
900            bind,
901            watch,
902            introspection,
903        } => commands::run::run(input.as_deref(), database, port, bind, watch, introspection).await,
904
905        Commands::Serve { schema, port } => commands::serve::run(&schema, port).await,
906    };
907
908    if let Err(e) = result {
909        eprintln!("Error: {e}");
910        if cli.debug {
911            eprintln!("\nDebug info:");
912            eprintln!("{e:?}");
913        }
914        process::exit(1);
915    }
916}
917
918fn init_logging(verbose: bool, debug: bool) {
919    let filter = if debug {
920        "fraiseql=debug,fraiseql_core=debug"
921    } else if verbose {
922        "fraiseql=info,fraiseql_core=info"
923    } else {
924        "fraiseql=warn,fraiseql_core=warn"
925    };
926
927    tracing_subscriber::registry()
928        .with(
929            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into()),
930        )
931        .with(tracing_subscriber::fmt::layer())
932        .init();
933}
934
935fn handle_introspection_flags() -> Option<i32> {
936    let args: Vec<String> = env::args().collect();
937
938    if args.iter().any(|a| a == "--help-json") {
939        let cmd = Cli::command();
940        let version = env!("CARGO_PKG_VERSION");
941        let help = crate::introspection::extract_cli_help(&cmd, version);
942        let result = crate::output::CommandResult::success(
943            "help",
944            serde_json::to_value(&help).expect("CliHelp is always serializable"),
945        );
946        println!(
947            "{}",
948            serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
949        );
950        return Some(0);
951    }
952
953    if args.iter().any(|a| a == "--list-commands") {
954        let cmd = Cli::command();
955        let commands = crate::introspection::list_commands(&cmd);
956        let result = crate::output::CommandResult::success(
957            "list-commands",
958            serde_json::to_value(&commands).expect("command list is always serializable"),
959        );
960        println!(
961            "{}",
962            serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
963        );
964        return Some(0);
965    }
966
967    let idx = args.iter().position(|a| a == "--show-output-schema")?;
968    let available = crate::output_schemas::list_schema_commands().join(", ");
969
970    let Some(cmd_name) = args.get(idx + 1) else {
971        let result = crate::output::CommandResult::error(
972            "show-output-schema",
973            &format!("Missing command name. Available: {available}"),
974            "MISSING_ARGUMENT",
975        );
976        println!(
977            "{}",
978            serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
979        );
980        return Some(1);
981    };
982
983    if let Some(schema) = crate::output_schemas::get_output_schema(cmd_name) {
984        let result = crate::output::CommandResult::success(
985            "show-output-schema",
986            serde_json::to_value(&schema).expect("output schema is always serializable"),
987        );
988        println!(
989            "{}",
990            serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
991        );
992        return Some(0);
993    }
994
995    let result = crate::output::CommandResult::error(
996        "show-output-schema",
997        &format!("Unknown command: {cmd_name}. Available: {available}"),
998        "UNKNOWN_COMMAND",
999    );
1000    println!(
1001        "{}",
1002        serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
1003    );
1004    Some(1)
1005}