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    ///
458    /// Server and database settings can be declared in fraiseql.toml under [server]
459    /// and [database] sections.  CLI flags take precedence over TOML settings, which
460    /// take precedence over defaults.  The database URL is resolved in this order:
461    /// --database flag > DATABASE_URL env var > [database].url in fraiseql.toml.
462    #[command(after_help = "\
463EXAMPLES:
464    fraiseql run
465    fraiseql run fraiseql.toml --database postgres://localhost/mydb
466    fraiseql run --port 3000 --watch
467    fraiseql run schema.json --introspection
468
469TOML CONFIG:
470    [server]
471    host = \"127.0.0.1\"
472    port = 9000
473
474    [server.cors]
475    origins = [\"https://app.example.com\"]
476
477    [database]
478    url      = \"${DATABASE_URL}\"
479    pool_min = 2
480    pool_max = 20")]
481    Run {
482        /// Input file path (fraiseql.toml or schema.json); auto-detected if omitted
483        #[arg(value_name = "INPUT")]
484        input: Option<String>,
485
486        /// Database URL (overrides [database].url in fraiseql.toml and DATABASE_URL env var)
487        #[arg(short, long, value_name = "DATABASE_URL")]
488        database: Option<String>,
489
490        /// Port to listen on (overrides [server].port in fraiseql.toml)
491        #[arg(short, long, value_name = "PORT")]
492        port: Option<u16>,
493
494        /// Bind address (overrides [server].host in fraiseql.toml)
495        #[arg(long, value_name = "HOST")]
496        bind: Option<String>,
497
498        /// Watch input file for changes and hot-reload the server
499        #[arg(short, long)]
500        watch: bool,
501
502        /// Enable the GraphQL introspection endpoint (no auth required)
503        #[arg(long)]
504        introspection: bool,
505    },
506
507    /// Development server with hot-reload
508    #[command(hide = true)] // Hide until implemented
509    Serve {
510        /// Schema.json file path to watch
511        #[arg(value_name = "SCHEMA")]
512        schema: String,
513
514        /// Port to listen on
515        #[arg(short, long, default_value = "8080")]
516        port: u16,
517    },
518}
519
520#[derive(Subcommand)]
521enum ValidateCommands {
522    /// Validate that declared fact tables match database schema
523    Facts {
524        /// Schema.json file path
525        #[arg(short, long, value_name = "SCHEMA")]
526        schema: String,
527
528        /// Database connection string
529        #[arg(short, long, value_name = "DATABASE_URL")]
530        database: String,
531    },
532}
533
534#[derive(Subcommand)]
535enum FederationCommands {
536    /// Export federation graph
537    Graph {
538        /// Path to schema.compiled.json
539        #[arg(value_name = "SCHEMA")]
540        schema: String,
541
542        /// Output format (json, dot, mermaid)
543        #[arg(short, long, value_name = "FORMAT", default_value = "json")]
544        format: String,
545    },
546}
547
548#[derive(Subcommand)]
549enum IntrospectCommands {
550    /// Introspect database for fact tables (tf_* tables)
551    Facts {
552        /// Database connection string
553        #[arg(short, long, value_name = "DATABASE_URL")]
554        database: String,
555
556        /// Output format (python, json)
557        #[arg(short, long, value_name = "FORMAT", default_value = "python")]
558        format: String,
559    },
560}
561
562#[derive(Subcommand)]
563enum MigrateCommands {
564    /// Apply pending migrations
565    Up {
566        /// Database connection URL
567        #[arg(short, long, value_name = "DATABASE_URL")]
568        database: Option<String>,
569
570        /// Migration directory
571        #[arg(long, value_name = "DIR")]
572        dir: Option<String>,
573    },
574
575    /// Roll back migrations
576    Down {
577        /// Database connection URL
578        #[arg(short, long, value_name = "DATABASE_URL")]
579        database: Option<String>,
580
581        /// Migration directory
582        #[arg(long, value_name = "DIR")]
583        dir: Option<String>,
584
585        /// Number of migrations to roll back
586        #[arg(long, default_value = "1")]
587        steps: u32,
588    },
589
590    /// Show migration status
591    Status {
592        /// Database connection URL
593        #[arg(short, long, value_name = "DATABASE_URL")]
594        database: Option<String>,
595
596        /// Migration directory
597        #[arg(long, value_name = "DIR")]
598        dir: Option<String>,
599    },
600
601    /// Create a new migration file
602    Create {
603        /// Migration name
604        #[arg(value_name = "NAME")]
605        name: String,
606
607        /// Migration directory
608        #[arg(long, value_name = "DIR")]
609        dir: Option<String>,
610    },
611}
612
613/// Run the FraiseQL CLI. Called from both the `fraiseql-cli` and `fraiseql` binary entry points.
614pub async fn run() {
615    use crate::{commands, output};
616
617    if let Some(code) = handle_introspection_flags() {
618        process::exit(code);
619    }
620
621    let cli = Cli::parse();
622
623    init_logging(cli.verbose, cli.debug);
624
625    let result = match cli.command {
626        Commands::Compile {
627            input,
628            types,
629            schema_dir,
630            type_files,
631            query_files,
632            mutation_files,
633            output,
634            check,
635            database,
636        } => {
637            commands::compile::run(
638                &input,
639                types.as_deref(),
640                schema_dir.as_deref(),
641                type_files,
642                query_files,
643                mutation_files,
644                &output,
645                check,
646                database.as_deref(),
647            )
648            .await
649        },
650
651        Commands::Extract {
652            input,
653            language,
654            recursive,
655            output,
656        } => commands::extract::run(&input, language.as_deref(), recursive, &output),
657
658        Commands::Explain { query } => match commands::explain::run(&query) {
659            Ok(result) => {
660                println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
661                Ok(())
662            },
663            Err(e) => Err(e),
664        },
665
666        Commands::Cost { query } => match commands::cost::run(&query) {
667            Ok(result) => {
668                println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
669                Ok(())
670            },
671            Err(e) => Err(e),
672        },
673
674        Commands::Analyze { schema } => match commands::analyze::run(&schema) {
675            Ok(result) => {
676                println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
677                Ok(())
678            },
679            Err(e) => Err(e),
680        },
681
682        Commands::DependencyGraph { schema, format } => {
683            match commands::dependency_graph::GraphFormat::from_str(&format) {
684                Ok(fmt) => match commands::dependency_graph::run(&schema, fmt) {
685                    Ok(result) => {
686                        println!(
687                            "{}",
688                            output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
689                        );
690                        Ok(())
691                    },
692                    Err(e) => Err(e),
693                },
694                Err(e) => Err(anyhow::anyhow!(e)),
695            }
696        },
697
698        Commands::Lint {
699            schema,
700            federation: _,
701            cost: _,
702            cache: _,
703            auth: _,
704            compilation: _,
705            fail_on_critical,
706            fail_on_warning,
707            verbose: _,
708        } => {
709            let opts = commands::lint::LintOptions {
710                fail_on_critical,
711                fail_on_warning,
712            };
713            match commands::lint::run(&schema, opts) {
714                Ok(result) => {
715                    println!(
716                        "{}",
717                        output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
718                    );
719                    Ok(())
720                },
721                Err(e) => Err(e),
722            }
723        },
724
725        Commands::Federation { command } => match command {
726            FederationCommands::Graph { schema, format } => {
727                match commands::federation::graph::GraphFormat::from_str(&format) {
728                    Ok(fmt) => match commands::federation::graph::run(&schema, fmt) {
729                        Ok(result) => {
730                            println!(
731                                "{}",
732                                output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
733                            );
734                            Ok(())
735                        },
736                        Err(e) => Err(e),
737                    },
738                    Err(e) => Err(anyhow::anyhow!(e)),
739                }
740            },
741        },
742
743        Commands::GenerateViews {
744            schema,
745            entity,
746            view,
747            refresh_strategy,
748            output,
749            include_composition_views,
750            include_monitoring,
751            validate,
752            gen_verbose,
753        } => match commands::generate_views::RefreshStrategy::parse(&refresh_strategy) {
754            Ok(refresh_strat) => {
755                let config = commands::generate_views::GenerateViewsConfig {
756                    schema_path: schema,
757                    entity,
758                    view,
759                    refresh_strategy: refresh_strat,
760                    output,
761                    include_composition_views,
762                    include_monitoring,
763                    validate_only: validate,
764                    verbose: cli.verbose || gen_verbose,
765                };
766
767                commands::generate_views::run(config)
768            },
769            Err(e) => Err(anyhow::anyhow!(e)),
770        },
771
772        Commands::Validate {
773            command,
774            input,
775            check_cycles,
776            check_unused,
777            strict,
778            types,
779        } => match command {
780            Some(ValidateCommands::Facts { schema, database }) => {
781                commands::validate_facts::run(std::path::Path::new(&schema), &database).await
782            },
783            None => match input {
784                Some(input) => {
785                    let opts = commands::validate::ValidateOptions {
786                        check_cycles,
787                        check_unused,
788                        strict,
789                        filter_types: types,
790                    };
791                    match commands::validate::run_with_options(&input, opts) {
792                        Ok(result) => {
793                            println!(
794                                "{}",
795                                output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
796                            );
797                            if result.status == "validation-failed" {
798                                Err(anyhow::anyhow!("Validation failed"))
799                            } else {
800                                Ok(())
801                            }
802                        },
803                        Err(e) => Err(e),
804                    }
805                },
806                None => Err(anyhow::anyhow!("INPUT required when no subcommand provided")),
807            },
808        },
809
810        Commands::Introspect { command } => match command {
811            IntrospectCommands::Facts { database, format } => {
812                match commands::introspect_facts::OutputFormat::parse(&format) {
813                    Ok(fmt) => commands::introspect_facts::run(&database, fmt).await,
814                    Err(e) => Err(anyhow::anyhow!(e)),
815                }
816            },
817        },
818
819        Commands::Generate {
820            input,
821            language,
822            output,
823        } => match commands::init::Language::from_str(&language) {
824            Ok(lang) => commands::generate::run(&input, lang, output.as_deref()),
825            Err(e) => Err(anyhow::anyhow!(e)),
826        },
827
828        Commands::Init {
829            project_name,
830            language,
831            database,
832            size,
833            no_git,
834        } => {
835            match (
836                commands::init::Language::from_str(&language),
837                commands::init::Database::from_str(&database),
838                commands::init::ProjectSize::from_str(&size),
839            ) {
840                (Ok(lang), Ok(db), Ok(sz)) => {
841                    let config = commands::init::InitConfig {
842                        project_name,
843                        language: lang,
844                        database: db,
845                        size: sz,
846                        no_git,
847                    };
848                    commands::init::run(&config)
849                },
850                (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => Err(anyhow::anyhow!(e)),
851            }
852        },
853
854        Commands::Migrate { command } => match command {
855            MigrateCommands::Up { database, dir } => {
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::Up {
861                            database_url: url,
862                            dir:          mig_dir,
863                        };
864                        commands::migrate::run(&action)
865                    },
866                    Err(e) => Err(e),
867                }
868            },
869            MigrateCommands::Down {
870                database,
871                dir,
872                steps,
873            } => {
874                let db_url = commands::migrate::resolve_database_url(database.as_deref());
875                match db_url {
876                    Ok(url) => {
877                        let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
878                        let action = commands::migrate::MigrateAction::Down {
879                            database_url: url,
880                            dir: mig_dir,
881                            steps,
882                        };
883                        commands::migrate::run(&action)
884                    },
885                    Err(e) => Err(e),
886                }
887            },
888            MigrateCommands::Status { database, dir } => {
889                let db_url = commands::migrate::resolve_database_url(database.as_deref());
890                match db_url {
891                    Ok(url) => {
892                        let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
893                        let action = commands::migrate::MigrateAction::Status {
894                            database_url: url,
895                            dir:          mig_dir,
896                        };
897                        commands::migrate::run(&action)
898                    },
899                    Err(e) => Err(e),
900                }
901            },
902            MigrateCommands::Create { name, dir } => {
903                let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
904                let action = commands::migrate::MigrateAction::Create { name, dir: mig_dir };
905                commands::migrate::run(&action)
906            },
907        },
908
909        Commands::Sbom { format, output } => match commands::sbom::SbomFormat::from_str(&format) {
910            Ok(fmt) => commands::sbom::run(fmt, output.as_deref()),
911            Err(e) => Err(anyhow::anyhow!(e)),
912        },
913
914        Commands::Run {
915            input,
916            database,
917            port,
918            bind,
919            watch,
920            introspection,
921        } => {
922            commands::run::run(input.as_deref(), database, port, bind, watch, introspection).await
923        },
924
925        Commands::Serve { schema, port } => commands::serve::run(&schema, port).await,
926    };
927
928    if let Err(e) = result {
929        eprintln!("Error: {e}");
930        if cli.debug {
931            eprintln!("\nDebug info:");
932            eprintln!("{e:?}");
933        }
934        process::exit(1);
935    }
936}
937
938fn init_logging(verbose: bool, debug: bool) {
939    let filter = if debug {
940        "fraiseql=debug,fraiseql_core=debug"
941    } else if verbose {
942        "fraiseql=info,fraiseql_core=info"
943    } else {
944        "fraiseql=warn,fraiseql_core=warn"
945    };
946
947    tracing_subscriber::registry()
948        .with(
949            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into()),
950        )
951        .with(tracing_subscriber::fmt::layer())
952        .init();
953}
954
955fn handle_introspection_flags() -> Option<i32> {
956    let args: Vec<String> = env::args().collect();
957
958    if args.iter().any(|a| a == "--help-json") {
959        let cmd = Cli::command();
960        let version = env!("CARGO_PKG_VERSION");
961        let help = crate::introspection::extract_cli_help(&cmd, version);
962        let result = crate::output::CommandResult::success(
963            "help",
964            serde_json::to_value(&help).expect("CliHelp is always serializable"),
965        );
966        println!(
967            "{}",
968            serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
969        );
970        return Some(0);
971    }
972
973    if args.iter().any(|a| a == "--list-commands") {
974        let cmd = Cli::command();
975        let commands = crate::introspection::list_commands(&cmd);
976        let result = crate::output::CommandResult::success(
977            "list-commands",
978            serde_json::to_value(&commands).expect("command list is always serializable"),
979        );
980        println!(
981            "{}",
982            serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
983        );
984        return Some(0);
985    }
986
987    let idx = args.iter().position(|a| a == "--show-output-schema")?;
988    let available = crate::output_schemas::list_schema_commands().join(", ");
989
990    let Some(cmd_name) = args.get(idx + 1) else {
991        let result = crate::output::CommandResult::error(
992            "show-output-schema",
993            &format!("Missing command name. Available: {available}"),
994            "MISSING_ARGUMENT",
995        );
996        println!(
997            "{}",
998            serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
999        );
1000        return Some(1);
1001    };
1002
1003    if let Some(schema) = crate::output_schemas::get_output_schema(cmd_name) {
1004        let result = crate::output::CommandResult::success(
1005            "show-output-schema",
1006            serde_json::to_value(&schema).expect("output schema is always serializable"),
1007        );
1008        println!(
1009            "{}",
1010            serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
1011        );
1012        return Some(0);
1013    }
1014
1015    let result = crate::output::CommandResult::error(
1016        "show-output-schema",
1017        &format!("Unknown command: {cmd_name}. Available: {available}"),
1018        "UNKNOWN_COMMAND",
1019    );
1020    println!(
1021        "{}",
1022        serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
1023    );
1024    Some(1)
1025}