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