1use crate::app;
4use crate::codegen::{
5 self, CodegenOutcome, CodegenRequest, CodegenTargetKind, DtoConfig, NodeDtoStyle, PythonDtoStyle, RubyDtoStyle,
6 SchemaKind, TargetLanguage,
7};
8use crate::init::{InitRequest, InitResponse};
9use anyhow::{Context, Result, bail};
10use clap::{Args, Parser, Subcommand, ValueEnum};
11use scythe_core::dialect::SqlDialect;
12use spikard_codegen::sql::DecimalMode;
13use std::ffi::OsString;
14use std::path::PathBuf;
15
16#[derive(Parser, Debug)]
18#[command(author, version, about, long_about = None)]
19struct Cli {
20 #[command(subcommand)]
21 command: Commands,
22}
23
24#[derive(Subcommand, Debug)]
25enum Commands {
26 Init(InitArgs),
28 #[cfg(feature = "mcp")]
30 Mcp(McpArgs),
31 Generate {
33 #[command(subcommand)]
34 target: GenerateCommand,
35 },
36 Testing {
38 #[command(subcommand)]
39 target: TestingCommand,
40 },
41 ValidateAsyncapi {
43 schema: PathBuf,
45 },
46 Features,
48}
49
50#[derive(Args, Debug)]
51struct InitArgs {
52 name: String,
54
55 #[arg(long, short = 'l', default_value = "python")]
57 lang: InitLanguage,
58
59 #[arg(long, short = 'd', default_value = ".")]
61 dir: PathBuf,
62}
63
64#[cfg(feature = "mcp")]
65#[derive(Args, Debug)]
66struct McpArgs {
67 #[arg(long, default_value = "stdio")]
69 transport: String,
70
71 #[arg(long, default_value = "127.0.0.1")]
73 host: String,
74
75 #[arg(long, default_value_t = 3001)]
77 port: u16,
78}
79
80#[derive(Debug, Clone, Copy, ValueEnum)]
81enum InitLanguage {
82 #[value(name = "python")]
83 Python,
84 #[value(name = "typescript")]
85 TypeScript,
86 #[value(name = "rust")]
87 Rust,
88 #[value(name = "ruby")]
89 Ruby,
90 #[value(name = "php")]
91 Php,
92 #[value(name = "elixir")]
93 Elixir,
94}
95
96impl From<InitLanguage> for TargetLanguage {
97 fn from(lang: InitLanguage) -> Self {
98 match lang {
99 InitLanguage::Python => Self::Python,
100 InitLanguage::TypeScript => Self::TypeScript,
101 InitLanguage::Rust => Self::Rust,
102 InitLanguage::Ruby => Self::Ruby,
103 InitLanguage::Php => Self::Php,
104 InitLanguage::Elixir => Self::Elixir,
105 }
106 }
107}
108
109#[derive(Subcommand, Debug)]
110enum GenerateCommand {
111 Openapi(OpenapiArgs),
113 Asyncapi(AsyncapiHandlerArgs),
115 Jsonrpc(JsonrpcArgs),
117 Graphql(GraphqlArgs),
119 Protobuf(ProtobufArgs),
121 PhpDto(PhpDtoArgs),
123 Sql(SqlArgs),
125}
126
127#[derive(Args, Debug)]
128struct SqlArgs {
129 queries: PathBuf,
132
133 #[arg(long = "schema", required = true)]
135 schema: Vec<PathBuf>,
136
137 #[arg(long, default_value = "postgresql")]
139 dialect: SqlDialectArg,
140
141 #[arg(long, short = 'o', default_value = "generated")]
143 output: PathBuf,
144
145 #[arg(long = "lang", num_args = 1..)]
147 lang: Vec<GenerateLanguage>,
148
149 #[arg(long, default_value = "string-pattern")]
152 decimal_mode: DecimalModeArg,
153
154 #[arg(long, default_value_t = false)]
156 strict: bool,
157
158 #[arg(long = "no-openapi", default_value_t = false)]
160 no_openapi: bool,
161
162 #[arg(long, default_value = "Generated API")]
164 api_title: String,
165
166 #[arg(long, default_value = "0.1.0")]
168 api_version: String,
169}
170
171#[derive(Debug, Clone, Copy, ValueEnum)]
172enum SqlDialectArg {
173 #[value(name = "postgresql", alias = "postgres", alias = "redshift", alias = "cockroachdb")]
174 PostgreSQL,
175 #[value(name = "mysql", alias = "mariadb")]
176 MySQL,
177 #[value(name = "sqlite")]
178 SQLite,
179 #[value(name = "mssql", alias = "sqlserver")]
180 MsSql,
181 #[value(name = "oracle")]
182 Oracle,
183 #[value(name = "snowflake")]
184 Snowflake,
185}
186
187impl From<SqlDialectArg> for SqlDialect {
188 fn from(d: SqlDialectArg) -> Self {
189 match d {
190 SqlDialectArg::PostgreSQL => SqlDialect::PostgreSQL,
191 SqlDialectArg::MySQL => SqlDialect::MySQL,
192 SqlDialectArg::SQLite => SqlDialect::SQLite,
193 SqlDialectArg::MsSql => SqlDialect::MsSql,
194 SqlDialectArg::Oracle => SqlDialect::Oracle,
195 SqlDialectArg::Snowflake => SqlDialect::Snowflake,
196 }
197 }
198}
199
200#[derive(Debug, Clone, Copy, ValueEnum)]
201enum DecimalModeArg {
202 #[value(name = "string-pattern")]
203 StringPattern,
204 #[value(name = "number")]
205 Number,
206}
207
208impl From<DecimalModeArg> for DecimalMode {
209 fn from(m: DecimalModeArg) -> Self {
210 match m {
211 DecimalModeArg::StringPattern => DecimalMode::StringPattern,
212 DecimalModeArg::Number => DecimalMode::Number,
213 }
214 }
215}
216
217#[derive(Args, Debug)]
218struct OpenapiArgs {
219 schema: PathBuf,
221
222 #[arg(long, short = 'l', default_value = "python")]
224 lang: GenerateLanguage,
225
226 #[arg(long, short = 'o')]
228 output: Option<PathBuf>,
229
230 #[arg(long = "dto", value_enum)]
232 dto: Option<DtoArg>,
233}
234
235#[derive(Args, Debug)]
236struct AsyncapiHandlerArgs {
237 schema: PathBuf,
239
240 #[arg(long, short = 'l')]
242 lang: GenerateLanguage,
243
244 #[arg(long, short = 'o')]
246 output: PathBuf,
247
248 #[arg(long = "dto", value_enum)]
250 dto: Option<DtoArg>,
251}
252
253#[derive(Args, Debug)]
254struct JsonrpcArgs {
255 schema: PathBuf,
257
258 #[arg(long, short = 'l', default_value = "python")]
260 lang: GenerateLanguage,
261
262 #[arg(long, short = 'o')]
264 output: Option<PathBuf>,
265}
266
267#[derive(Args, Debug)]
268struct GraphqlArgs {
269 schema: PathBuf,
271
272 #[arg(long, short = 'l', default_value = "python")]
274 lang: GenerateLanguage,
275
276 #[arg(long, short = 'o')]
278 output: Option<PathBuf>,
279
280 #[arg(long, default_value = "all")]
282 target: String,
283}
284
285#[derive(Args, Debug)]
286struct ProtobufArgs {
287 schema: PathBuf,
289
290 #[arg(long, short = 'l', default_value = "python")]
292 lang: GenerateLanguage,
293
294 #[arg(long, short = 'o')]
296 output: PathBuf,
297
298 #[arg(long, default_value = "all")]
300 target: String,
301
302 #[arg(long = "include")]
304 include: Vec<PathBuf>,
305}
306
307#[derive(Subcommand, Debug)]
308enum TestingCommand {
309 Asyncapi {
311 #[command(subcommand)]
312 target: AsyncapiTestingTarget,
313 },
314}
315
316#[derive(Subcommand, Debug)]
317enum AsyncapiTestingTarget {
318 Fixtures(AsyncFixtureArgs),
320 TestApp(AsyncTestAppArgs),
322 All(AsyncAllArgs),
324}
325
326#[derive(Args, Debug)]
327struct AsyncFixtureArgs {
328 schema: PathBuf,
330 #[arg(long, short = 'o', default_value = "testing_data")]
332 output: PathBuf,
333}
334
335#[derive(Args, Debug)]
336struct AsyncTestAppArgs {
337 schema: PathBuf,
339 #[arg(long, short = 'l')]
341 lang: GenerateLanguage,
342 #[arg(long, short = 'o')]
344 output: PathBuf,
345}
346
347#[derive(Args, Debug)]
348struct AsyncAllArgs {
349 schema: PathBuf,
351 #[arg(long, short = 'o', default_value = ".")]
353 output: PathBuf,
354}
355
356#[derive(Args, Debug)]
357struct PhpDtoArgs {
358 #[arg(long, short = 'o', default_value = "src/Generated")]
360 output: PathBuf,
361}
362
363#[derive(Debug, Clone, Copy, ValueEnum)]
364enum GenerateLanguage {
365 #[value(name = "python")]
366 Python,
367 #[value(name = "typescript")]
368 TypeScript,
369 #[value(name = "rust")]
370 Rust,
371 #[value(name = "ruby")]
372 Ruby,
373 #[value(name = "php")]
374 Php,
375 #[value(name = "elixir")]
376 Elixir,
377}
378
379impl From<GenerateLanguage> for codegen::TargetLanguage {
380 fn from(lang: GenerateLanguage) -> Self {
381 match lang {
382 GenerateLanguage::Python => Self::Python,
383 GenerateLanguage::TypeScript => Self::TypeScript,
384 GenerateLanguage::Rust => Self::Rust,
385 GenerateLanguage::Ruby => Self::Ruby,
386 GenerateLanguage::Php => Self::Php,
387 GenerateLanguage::Elixir => Self::Elixir,
388 }
389 }
390}
391
392fn apply_dto_selection(config: &mut DtoConfig, lang: GenerateLanguage, dto: DtoArg) -> Result<()> {
393 match lang {
394 GenerateLanguage::Python => match dto {
395 DtoArg::Dataclass => config.python = PythonDtoStyle::Dataclass,
396 DtoArg::Msgspec => config.python = PythonDtoStyle::Msgspec,
397 _ => bail!("DTO '{dto:?}' is not supported for Python"),
398 },
399 GenerateLanguage::TypeScript => match dto {
400 DtoArg::Zod => config.node = NodeDtoStyle::Zod,
401 _ => bail!("DTO '{dto:?}' is not supported for TypeScript"),
402 },
403 GenerateLanguage::Ruby => match dto {
404 DtoArg::DrySchema => config.ruby = RubyDtoStyle::DrySchema,
405 _ => bail!("DTO '{dto:?}' is not supported for Ruby"),
406 },
407 GenerateLanguage::Rust => match dto {
408 DtoArg::Serde => config.rust = codegen::RustDtoStyle::SerdeStruct,
409 _ => bail!("DTO '{dto:?}' is not supported for Rust"),
410 },
411 GenerateLanguage::Php => match dto {
412 DtoArg::ReadonlyClass => config.php = codegen::PhpDtoStyle::ReadonlyClass,
413 _ => bail!("DTO '{dto:?}' is not supported for PHP"),
414 },
415 GenerateLanguage::Elixir => bail!("DTO '{dto:?}' is not supported for Elixir"),
416 }
417 Ok(())
418}
419
420fn default_jsonrpc_output(lang: GenerateLanguage) -> PathBuf {
421 let ext = match lang {
422 GenerateLanguage::Python => "py",
423 GenerateLanguage::TypeScript => "ts",
424 GenerateLanguage::Rust => "rs",
425 GenerateLanguage::Ruby => "rb",
426 GenerateLanguage::Php => "php",
427 GenerateLanguage::Elixir => "ex",
428 };
429
430 PathBuf::from(format!("handlers.{ext}"))
431}
432
433fn default_graphql_output(lang: GenerateLanguage) -> PathBuf {
434 let ext = match lang {
435 GenerateLanguage::Python => "py",
436 GenerateLanguage::TypeScript => "ts",
437 GenerateLanguage::Rust => "rs",
438 GenerateLanguage::Ruby => "rb",
439 GenerateLanguage::Php => "php",
440 GenerateLanguage::Elixir => "ex",
441 };
442
443 PathBuf::from(format!("generated.{ext}"))
444}
445
446#[derive(Debug, Clone, Copy, ValueEnum)]
447enum DtoArg {
448 Dataclass,
449 Msgspec,
450 Zod,
451 DrySchema,
452 Serde,
453 ReadonlyClass,
454}
455
456pub fn run_from_env() -> Result<()> {
457 run(Cli::parse())
458}
459
460pub fn run_from<I, T>(args: I) -> Result<()>
461where
462 I: IntoIterator<Item = T>,
463 T: Into<OsString> + Clone,
464{
465 run(Cli::try_parse_from(args)?)
466}
467
468fn run(cli: Cli) -> Result<()> {
469 match cli.command {
470 Commands::Init(args) => {
471 println!("Creating new Spikard project...");
472 println!(" Project name: {}", args.name);
473 println!(" Language: {:?}", args.lang);
474 println!(" Directory: {}", args.dir.display());
475 println!();
476
477 let request = InitRequest {
478 project_name: args.name.clone(),
479 language: args.lang.into(),
480 project_dir: args.dir.join(&args.name),
481 schema_path: None,
482 };
483
484 match app::init_project(request) {
485 Ok(response) => {
486 print_init_response(response);
487 }
488 Err(e) => {
489 eprintln!("✗ Failed to create project: {e}");
490 return Err(e);
491 }
492 }
493 }
494 #[cfg(feature = "mcp")]
495 Commands::Mcp(args) => {
496 let runtime = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime for MCP server")?;
497 match args.transport.to_ascii_lowercase().as_str() {
498 "stdio" => runtime
499 .block_on(crate::mcp::start_mcp_server())
500 .map_err(|error| anyhow::anyhow!(error.to_string()))
501 .context("Failed to start MCP server over stdio")?,
502 "http" => {
503 #[cfg(not(feature = "mcp-http"))]
504 {
505 bail!("HTTP transport requires the 'mcp-http' feature");
506 }
507
508 #[cfg(feature = "mcp-http")]
509 runtime
510 .block_on(crate::mcp::start_mcp_server_http(&args.host, args.port))
511 .map_err(|error| anyhow::anyhow!(error.to_string()))
512 .with_context(|| {
513 format!("Failed to start MCP server over http://{}:{}", args.host, args.port)
514 })?;
515 }
516 other => bail!("Unknown MCP transport '{other}'. Use 'stdio' or 'http'"),
517 }
518 }
519 Commands::Generate { target } => match target {
520 GenerateCommand::PhpDto(args) => {
521 println!("Generating PHP DTO classes for Spikard...");
522 println!(" Output directory: {}", args.output.display());
523 let assets = app::generate_php_dto(&args.output)?;
524 print_codegen_outcome(CodegenOutcome::Files(assets));
525 }
526 GenerateCommand::Sql(args) => {
527 if args.lang.is_empty() {
528 bail!("At least one --lang is required for `generate sql` (e.g. --lang python --lang typescript)");
529 }
530 println!("Generating handlers from annotated SQL...");
531 println!(" Queries: {}", args.queries.display());
532 println!(" Output: {}", args.output.display());
533 let languages: Vec<TargetLanguage> = args.lang.iter().map(|l| (*l).into()).collect();
534 let request = CodegenRequest {
535 schema_path: args.queries.clone(),
536 schema_kind: SchemaKind::Sql,
537 target: CodegenTargetKind::SqlHandlers {
538 schema_paths: args.schema.clone(),
539 output: args.output,
540 dialect: args.dialect.into(),
541 languages,
542 decimal_mode: args.decimal_mode.into(),
543 strict: args.strict,
544 emit_openapi: !args.no_openapi,
545 api_title: args.api_title,
546 api_version: args.api_version,
547 },
548 dto: None,
549 };
550 let outcome = app::execute_codegen(request).context("Failed to generate handlers from SQL")?;
551 print_codegen_outcome(outcome);
552 }
553 GenerateCommand::Openapi(args) => {
554 let mut dto_config = DtoConfig::default();
555 if let Some(arg) = args.dto {
556 apply_dto_selection(&mut dto_config, args.lang, arg)?;
557 }
558 let request = CodegenRequest {
559 schema_path: args.schema.clone(),
560 schema_kind: SchemaKind::OpenApi,
561 target: CodegenTargetKind::Server {
562 language: args.lang.into(),
563 output: args.output,
564 },
565 dto: Some(dto_config),
566 };
567
568 let outcome = app::execute_codegen(request).context("Failed to generate code from OpenAPI schema")?;
569 print_codegen_outcome(outcome);
570 }
571 GenerateCommand::Asyncapi(args) => {
572 println!("Generating handler scaffolding from AsyncAPI schema...");
573 println!(" Input: {}", args.schema.display());
574 println!(" Language: {:?}", args.lang);
575 println!(" Output: {}", args.output.display());
576 let mut dto_config = DtoConfig::default();
577 if let Some(arg) = args.dto {
578 apply_dto_selection(&mut dto_config, args.lang, arg)?;
579 }
580 let request = CodegenRequest {
581 schema_path: args.schema.clone(),
582 schema_kind: SchemaKind::AsyncApi,
583 target: CodegenTargetKind::AsyncHandlers {
584 language: args.lang.into(),
585 output: args.output,
586 },
587 dto: Some(dto_config),
588 };
589 print_codegen_outcome(app::execute_codegen(request)?);
590 }
591 GenerateCommand::Jsonrpc(args) => {
592 println!("Generating JSON-RPC 2.0 handlers from OpenRPC schema...");
593 println!(" Input: {}", args.schema.display());
594 println!(" Language: {:?}", args.lang);
595 if let Some(ref path) = args.output {
596 println!(" Output: {}", path.display());
597 }
598 let request = CodegenRequest {
599 schema_path: args.schema.clone(),
600 schema_kind: SchemaKind::OpenRpc,
601 target: CodegenTargetKind::JsonRpcHandlers {
602 language: args.lang.into(),
603 output: args.output.unwrap_or_else(|| default_jsonrpc_output(args.lang)),
604 },
605 dto: None,
606 };
607
608 let outcome = app::execute_codegen(request).context("Failed to generate code from OpenRPC schema")?;
609 print_codegen_outcome(outcome);
610 }
611 GenerateCommand::Graphql(args) => {
612 println!("Generating GraphQL code from schema...");
613 println!(" Input: {}", args.schema.display());
614 println!(" Language: {:?}", args.lang);
615 println!(" Target: {}", args.target);
616 if let Some(ref path) = args.output {
617 println!(" Output: {}", path.display());
618 }
619 let output_path = args.output.clone().unwrap_or_else(|| default_graphql_output(args.lang));
620
621 let request = CodegenRequest {
622 schema_path: args.schema.clone(),
623 schema_kind: SchemaKind::GraphQL,
624 target: CodegenTargetKind::GraphQL {
625 language: args.lang.into(),
626 output: output_path,
627 target: args.target,
628 },
629 dto: None,
630 };
631
632 let outcome = app::execute_codegen(request).context("Failed to generate code from GraphQL schema")?;
633 print_codegen_outcome(outcome);
634 }
635 GenerateCommand::Protobuf(args) => {
636 println!("Generating protobuf code from schema...");
637 println!(" Input: {}", args.schema.display());
638 println!(" Language: {:?}", args.lang);
639 println!(" Target: {}", args.target);
640 println!(" Output: {}", args.output.display());
641
642 let request = CodegenRequest {
643 schema_path: args.schema.clone(),
644 schema_kind: SchemaKind::Protobuf,
645 target: CodegenTargetKind::Protobuf {
646 language: args.lang.into(),
647 output: args.output.clone(),
648 target: args.target,
649 include_paths: args.include,
650 },
651 dto: None,
652 };
653
654 let outcome = app::execute_codegen(request).context("Failed to generate protobuf code")?;
655 print_codegen_outcome(outcome);
656 }
657 },
658 Commands::Testing { target } => match target {
659 TestingCommand::Asyncapi { target } => match target {
660 AsyncapiTestingTarget::Fixtures(args) => {
661 println!("Generating test fixtures from AsyncAPI schema...");
662 println!(" Input: {}", args.schema.display());
663 println!(" Output: {}", args.output.display());
664 let request = CodegenRequest {
665 schema_path: args.schema.clone(),
666 schema_kind: SchemaKind::AsyncApi,
667 target: CodegenTargetKind::AsyncFixtures { output: args.output },
668 dto: None,
669 };
670 let files = match app::execute_codegen_unvalidated(request)? {
671 CodegenOutcome::Files(files) => files,
672 CodegenOutcome::InMemory(_) => unreachable!("Fixtures always write files"),
673 };
674 println!("\n✓ Generated {} fixture files", files.len());
675 }
676 AsyncapiTestingTarget::TestApp(args) => {
677 println!("Generating test application from AsyncAPI schema...");
678 println!(" Input: {}", args.schema.display());
679 println!(" Language: {:?}", args.lang);
680 println!(" Output: {}", args.output.display());
681 let request = CodegenRequest {
682 schema_path: args.schema.clone(),
683 schema_kind: SchemaKind::AsyncApi,
684 target: CodegenTargetKind::AsyncTestApp {
685 language: args.lang.into(),
686 output: args.output,
687 },
688 dto: None,
689 };
690 print_codegen_outcome(app::execute_codegen_unvalidated(request)?);
691 }
692 AsyncapiTestingTarget::All(args) => {
693 println!("Generating all assets from AsyncAPI schema...");
694 println!(" Input: {}", args.schema.display());
695 println!(" Output directory: {}", args.output.display());
696 let request = CodegenRequest {
697 schema_path: args.schema.clone(),
698 schema_kind: SchemaKind::AsyncApi,
699 target: CodegenTargetKind::AsyncAll { output: args.output },
700 dto: None,
701 };
702 let files = match app::execute_codegen_unvalidated(request)? {
703 CodegenOutcome::Files(files) => files,
704 CodegenOutcome::InMemory(_) => unreachable!("AsyncAPI bundle writes files"),
705 };
706 println!("\n✓ Generated {} assets:", files.len());
707 for asset in files {
708 println!(" - {} -> {}", asset.description, asset.path.display());
709 }
710 }
711 },
712 },
713 Commands::Features => {
714 print_feature_summary(app::feature_summary());
715 }
716 Commands::ValidateAsyncapi { schema } => {
717 print_asyncapi_validation(app::validate_asyncapi_schema(&schema)?);
718 }
719 }
720
721 Ok(())
722}
723
724fn print_init_response(response: InitResponse) {
725 println!("✓ Project created successfully!");
726 println!();
727 println!("Created {} files:", response.files_created.len());
728 for file in response.files_created {
729 println!(" - {}", file.display());
730 }
731 println!();
732 println!("Next steps:");
733 for (i, step) in response.next_steps.iter().enumerate() {
734 println!(" {}. {}", i + 1, step);
735 }
736}
737
738fn print_codegen_outcome(outcome: CodegenOutcome) {
739 match outcome {
740 CodegenOutcome::InMemory(code) => println!("{code}"),
741 CodegenOutcome::Files(files) => {
742 for asset in files {
743 println!("✓ Generated {} at {}", asset.description, asset.path.display());
744 }
745 }
746 }
747}
748
749fn print_feature_summary(summary: app::FeatureSummary) {
750 println!("Spikard - High-performance HTTP framework\n");
751 println!("Rust Core: {}", if summary.rust_core { "✓" } else { "✗" });
752 println!("\nLanguage Bindings:");
753 for binding in &summary.language_bindings {
754 println!(" {}: {}", binding.name, binding.install_hint);
755 }
756 println!("\nUsage:");
757 for binding in &summary.language_bindings {
758 println!(" {}: {}", binding.name, binding.usage_hint);
759 }
760 println!("\nDocumentation: {}", summary.documentation_url);
761}
762
763fn print_asyncapi_validation(summary: app::AsyncApiValidationSummary) {
764 println!("✓ AsyncAPI schema is valid");
765 println!(" Spec Version: {}", summary.spec_version);
766 println!(" Title: {}", summary.title);
767 println!(" API Version: {}", summary.api_version);
768 println!(" Primary Protocol: {}", summary.primary_protocol);
769 println!(" Channels: {}", summary.channel_count);
770 println!("\nSchema validated successfully!");
771}