Skip to main content

spikard_cli/codegen/
engine.rs

1use super::asyncapi::{Protocol, parse_asyncapi_schema};
2use super::asyncapi::{
3    generate_elixir_handler_app, generate_elixir_test_app, generate_nodejs_handler_app, generate_nodejs_test_app,
4    generate_php_handler_app, generate_php_test_app, generate_python_handler_app, generate_python_test_app,
5    generate_ruby_handler_app, generate_ruby_test_app, generate_rust_handler_app, generate_rust_test_app,
6};
7use super::graphql::generators::GraphQLGenerator;
8use super::graphql::generators::elixir::ElixirGenerator;
9use super::graphql::generators::php::PhpGenerator;
10use super::graphql::generators::python::PythonGenerator;
11use super::graphql::generators::ruby::RubyGenerator;
12use super::graphql::generators::typescript::TypeScriptGenerator;
13use super::graphql::{RustGenerator, parse_graphql_schema};
14use super::openrpc::{
15    generate_elixir_handler_app as generate_openrpc_elixir_handler,
16    generate_php_handler_app as generate_openrpc_php_handler,
17    generate_python_handler_app as generate_openrpc_python_handler,
18    generate_ruby_handler_app as generate_openrpc_ruby_handler,
19    generate_rust_handler_app as generate_openrpc_rust_handler,
20    generate_typescript_handler_app as generate_openrpc_typescript_handler, parse_openrpc_schema,
21};
22use super::quality::QualityValidator;
23use super::sql::{SqlCodegenConfig, generate_from_sql_dir};
24use super::{DtoConfig, TargetLanguage, detect_primary_protocol, generate_fixtures};
25use crate::codegen::generate_from_openapi;
26use anyhow::{Context, Result, bail};
27use asyncapiv3::spec::AsyncApiV3Spec;
28use heck::ToKebabCase;
29use scythe_core::dialect::SqlDialect;
30use spikard_codegen::sql::DecimalMode;
31use std::fs;
32use std::path::{Path, PathBuf};
33
34/// Code generation schema families supported by the CLI
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum SchemaKind {
37    OpenApi,
38    AsyncApi,
39    OpenRpc,
40    GraphQL,
41    Protobuf,
42    /// Annotated SQL queries (consumed via scythe + the `spikard_codegen::sql` module)
43    Sql,
44}
45
46/// Type of artifact to generate for a schema
47#[derive(Clone)]
48pub enum CodegenTargetKind {
49    /// Generate server handler code for a language (`OpenAPI` today)
50    Server {
51        language: TargetLanguage,
52        output: Option<PathBuf>,
53    },
54    /// Generate `AsyncAPI` fixtures (SSE/WebSocket)
55    AsyncFixtures { output: PathBuf },
56    /// Generate `AsyncAPI` test application for a language
57    AsyncTestApp { language: TargetLanguage, output: PathBuf },
58    /// Generate `AsyncAPI` handler scaffolding for a language
59    AsyncHandlers { language: TargetLanguage, output: PathBuf },
60    /// Generate fixtures + test applications for all `AsyncAPI` languages
61    AsyncAll { output: PathBuf },
62    /// Generate JSON-RPC handler scaffolding for a language
63    JsonRpcHandlers { language: TargetLanguage, output: PathBuf },
64    /// Generate GraphQL types, resolvers, or schema for a language
65    GraphQL {
66        language: TargetLanguage,
67        output: PathBuf,
68        target: String,
69    },
70    /// Generate Protobuf messages and gRPC services
71    Protobuf {
72        language: TargetLanguage,
73        output: PathBuf,
74        target: String,
75        include_paths: Vec<PathBuf>,
76    },
77    /// Generate routes + OpenAPI + sidecar from annotated SQL queries
78    SqlHandlers {
79        schema_paths: Vec<PathBuf>,
80        output: PathBuf,
81        dialect: SqlDialect,
82        languages: Vec<TargetLanguage>,
83        decimal_mode: DecimalMode,
84        strict: bool,
85        emit_openapi: bool,
86        api_title: String,
87        api_version: String,
88    },
89}
90
91/// Request executed by the code generation engine
92#[derive(Debug, Clone)]
93pub struct CodegenRequest {
94    pub schema_path: PathBuf,
95    pub schema_kind: SchemaKind,
96    pub target: CodegenTargetKind,
97    pub dto: Option<DtoConfig>,
98}
99
100/// Represents an asset emitted by the code generation engine
101#[derive(Debug, Clone, serde::Serialize)]
102pub struct GeneratedAsset {
103    pub path: PathBuf,
104    pub description: String,
105}
106
107/// Output of the engine run
108#[derive(Debug, Clone, serde::Serialize)]
109pub enum CodegenOutcome {
110    /// Generated code that should be printed to stdout (no file requested)
111    InMemory(String),
112    /// Files that were written to disk
113    Files(Vec<GeneratedAsset>),
114}
115
116/// Code generation runtime orchestrating schema parsing and artifact generation
117pub struct CodegenEngine;
118
119impl CodegenEngine {
120    pub fn execute(request: CodegenRequest) -> Result<CodegenOutcome> {
121        Self::execute_impl(request, false)
122    }
123
124    pub fn execute_validated(request: CodegenRequest) -> Result<CodegenOutcome> {
125        Self::execute_impl(request, true)
126    }
127
128    fn execute_impl(request: CodegenRequest, validate: bool) -> Result<CodegenOutcome> {
129        match (&request.schema_kind, &request.target) {
130            (SchemaKind::OpenApi, CodegenTargetKind::Server { language, output }) => {
131                let dto = request.dto.clone().unwrap_or_default();
132                let code = generate_from_openapi(&request.schema_path, *language, &dto)?;
133                if validate {
134                    Self::validate_generated_code(*language, &code)?;
135                }
136
137                if let Some(path) = output {
138                    Ok(CodegenOutcome::Files(vec![Self::write_asset(
139                        path,
140                        format!("{} server handlers", language_name(*language)),
141                        &code,
142                    )?]))
143                } else {
144                    Ok(CodegenOutcome::InMemory(code))
145                }
146            }
147            (SchemaKind::AsyncApi, CodegenTargetKind::AsyncFixtures { output }) => {
148                let spec = parse_asyncapi_schema(&request.schema_path)
149                    .context("Failed to parse AsyncAPI schema for fixture generation")?;
150                let protocol = detect_primary_protocol(&spec)?;
151                let paths = Self::generate_asyncapi_fixtures(&spec, protocol, output)?;
152                Ok(CodegenOutcome::Files(paths))
153            }
154            (SchemaKind::AsyncApi, CodegenTargetKind::AsyncTestApp { language, output }) => {
155                let spec = parse_asyncapi_schema(&request.schema_path)
156                    .context("Failed to parse AsyncAPI schema for test app generation")?;
157                let protocol = detect_primary_protocol(&spec)?;
158                let asset = Self::generate_asyncapi_app(&spec, protocol, *language, output, validate)?;
159                Ok(CodegenOutcome::Files(vec![asset]))
160            }
161            (SchemaKind::AsyncApi, CodegenTargetKind::AsyncHandlers { language, output }) => {
162                let spec = parse_asyncapi_schema(&request.schema_path)
163                    .context("Failed to parse AsyncAPI schema for handler generation")?;
164                let protocol = detect_primary_protocol(&spec)?;
165                let asset = Self::generate_asyncapi_handler(&spec, protocol, *language, output, validate)?;
166                Ok(CodegenOutcome::Files(vec![asset]))
167            }
168            (SchemaKind::AsyncApi, CodegenTargetKind::AsyncAll { output }) => {
169                let spec = parse_asyncapi_schema(&request.schema_path)
170                    .context("Failed to parse AsyncAPI schema for all-assets generation")?;
171                let protocol = detect_primary_protocol(&spec)?;
172                let assets = Self::generate_asyncapi_bundle(&spec, protocol, output, validate)?;
173                Ok(CodegenOutcome::Files(assets))
174            }
175            (SchemaKind::OpenRpc, CodegenTargetKind::JsonRpcHandlers { language, output }) => {
176                let spec = parse_openrpc_schema(&request.schema_path)
177                    .context("Failed to parse OpenRPC schema for handler generation")?;
178                let asset = Self::generate_openrpc_handler(&spec, *language, output, validate)?;
179                Ok(CodegenOutcome::Files(vec![asset]))
180            }
181            (
182                SchemaKind::GraphQL,
183                CodegenTargetKind::GraphQL {
184                    language,
185                    output,
186                    target,
187                },
188            ) => {
189                let assets = Self::generate_graphql_code(&request.schema_path, *language, output, target, validate)
190                    .context("Failed to generate code from GraphQL schema")?;
191                Ok(CodegenOutcome::Files(assets))
192            }
193            (
194                SchemaKind::Protobuf,
195                CodegenTargetKind::Protobuf {
196                    language,
197                    output,
198                    target,
199                    include_paths,
200                },
201            ) => {
202                let schema = super::protobuf::parse_proto_schema_with_includes(&request.schema_path, include_paths)?;
203
204                let proto_target = match target.as_str() {
205                    "all" => super::protobuf::generators::ProtobufTarget::All,
206                    "messages" => super::protobuf::generators::ProtobufTarget::Messages,
207                    "services" => super::protobuf::generators::ProtobufTarget::Services,
208                    _ => bail!("Invalid protobuf target: {target}. Use 'all', 'messages', or 'services'"),
209                };
210
211                let code = match language {
212                    TargetLanguage::Python => super::protobuf::generate_python_protobuf(&schema, &proto_target)?,
213                    TargetLanguage::TypeScript => {
214                        super::protobuf::generate_typescript_protobuf(&schema, &proto_target)?
215                    }
216                    TargetLanguage::Ruby => super::protobuf::generate_ruby_protobuf(&schema, &proto_target)?,
217                    TargetLanguage::Php => super::protobuf::generate_php_protobuf(&schema, &proto_target)?,
218                    TargetLanguage::Rust => super::protobuf::generate_rust_protobuf(&schema, &proto_target)?,
219                    TargetLanguage::Elixir => super::protobuf::generate_elixir_protobuf(&schema, &proto_target)?,
220                };
221                if validate {
222                    Self::validate_generated_code(*language, &code)?;
223                }
224
225                Ok(CodegenOutcome::Files(vec![Self::write_asset(
226                    output,
227                    format!("{} Protobuf code", language_name(*language)),
228                    &code,
229                )?]))
230            }
231            (
232                SchemaKind::Sql,
233                CodegenTargetKind::SqlHandlers {
234                    schema_paths,
235                    output,
236                    dialect,
237                    languages,
238                    decimal_mode,
239                    strict,
240                    emit_openapi,
241                    api_title,
242                    api_version,
243                },
244            ) => {
245                let config = SqlCodegenConfig {
246                    schema_paths: schema_paths.clone(),
247                    queries_dir: request.schema_path.clone(),
248                    output_dir: output.clone(),
249                    dialect: *dialect,
250                    languages: languages.clone(),
251                    decimal_mode: *decimal_mode,
252                    strict: *strict,
253                    emit_openapi: *emit_openapi,
254                    api_title: api_title.clone(),
255                    api_version: api_version.clone(),
256                };
257                let output = generate_from_sql_dir(config).context("Failed to generate handlers from annotated SQL")?;
258                Ok(CodegenOutcome::Files(output.assets))
259            }
260            _ => bail!(
261                "Unsupported schema/target combination: {:?} -> {:?}",
262                request.schema_kind,
263                request.target
264            ),
265        }
266    }
267
268    fn generate_asyncapi_fixtures(
269        spec: &AsyncApiV3Spec,
270        protocol: Protocol,
271        output: &Path,
272    ) -> Result<Vec<GeneratedAsset>> {
273        let fixture_paths = generate_fixtures(spec, output, protocol)?;
274
275        Ok(fixture_paths
276            .into_iter()
277            .map(|path| GeneratedAsset {
278                description: format!("{} fixture", protocol.as_str()),
279                path,
280            })
281            .collect())
282    }
283
284    fn generate_asyncapi_app(
285        spec: &AsyncApiV3Spec,
286        protocol: Protocol,
287        language: TargetLanguage,
288        output: &Path,
289        validate: bool,
290    ) -> Result<GeneratedAsset> {
291        let code = match language {
292            TargetLanguage::Python => generate_python_test_app(spec, protocol)?,
293            TargetLanguage::TypeScript => generate_nodejs_test_app(spec, protocol)?,
294            TargetLanguage::Rust => generate_rust_test_app(spec, protocol)?,
295            TargetLanguage::Ruby => generate_ruby_test_app(spec, protocol)?,
296            TargetLanguage::Php => generate_php_test_app(spec, protocol)?,
297            TargetLanguage::Elixir => generate_elixir_test_app(spec, protocol)?,
298        };
299        if validate {
300            Self::validate_generated_code(language, &code)?;
301        }
302
303        Self::write_asset(output, format!("{} AsyncAPI test app", language_name(language)), code)
304    }
305
306    fn generate_asyncapi_handler(
307        spec: &AsyncApiV3Spec,
308        protocol: Protocol,
309        language: TargetLanguage,
310        output: &Path,
311        validate: bool,
312    ) -> Result<GeneratedAsset> {
313        let code = match language {
314            TargetLanguage::Python => generate_python_handler_app(spec, protocol)?,
315            TargetLanguage::TypeScript => generate_nodejs_handler_app(spec, protocol)?,
316            TargetLanguage::Ruby => generate_ruby_handler_app(spec, protocol)?,
317            TargetLanguage::Rust => generate_rust_handler_app(spec, protocol)?,
318            TargetLanguage::Php => generate_php_handler_app(spec, protocol)?,
319            TargetLanguage::Elixir => generate_elixir_handler_app(spec, protocol)?,
320        };
321        if validate {
322            Self::validate_generated_code(language, &code)?;
323        }
324
325        Self::write_asset(output, format!("{} AsyncAPI handler", language_name(language)), code)
326    }
327
328    fn generate_asyncapi_bundle(
329        spec: &AsyncApiV3Spec,
330        protocol: Protocol,
331        output: &Path,
332        validate: bool,
333    ) -> Result<Vec<GeneratedAsset>> {
334        let mut assets = Vec::new();
335
336        let fixtures_dir = output.join("testing_data");
337        assets.extend(Self::generate_asyncapi_fixtures(spec, protocol, &fixtures_dir)?);
338
339        let app_dir = output.join("apps");
340        fs::create_dir_all(&app_dir).with_context(|| format!("Failed to create {}", app_dir.display()))?;
341        let base_name = spec.info.title.to_kebab_case();
342
343        let python_asset = Self::generate_asyncapi_app(
344            spec,
345            protocol,
346            TargetLanguage::Python,
347            &app_dir.join(format!("{base_name}-asyncapi.py")),
348            validate,
349        )?;
350        assets.push(python_asset);
351
352        let node_asset = Self::generate_asyncapi_app(
353            spec,
354            protocol,
355            TargetLanguage::TypeScript,
356            &app_dir.join(format!("{base_name}-asyncapi.ts")),
357            validate,
358        )?;
359        assets.push(node_asset);
360
361        let rust_asset = Self::generate_asyncapi_app(
362            spec,
363            protocol,
364            TargetLanguage::Rust,
365            &app_dir.join(format!("{base_name}-asyncapi.rs")),
366            validate,
367        )?;
368        assets.push(rust_asset);
369
370        let ruby_asset = Self::generate_asyncapi_app(
371            spec,
372            protocol,
373            TargetLanguage::Ruby,
374            &app_dir.join(format!("{base_name}-asyncapi.rb")),
375            validate,
376        )?;
377        assets.push(ruby_asset);
378
379        let php_asset = Self::generate_asyncapi_app(
380            spec,
381            protocol,
382            TargetLanguage::Php,
383            &app_dir.join(format!("{base_name}-asyncapi.php")),
384            validate,
385        )?;
386        assets.push(php_asset);
387
388        let elixir_asset = Self::generate_asyncapi_app(
389            spec,
390            protocol,
391            TargetLanguage::Elixir,
392            &app_dir.join(format!("{base_name}-asyncapi.ex")),
393            validate,
394        )?;
395        assets.push(elixir_asset);
396
397        Ok(assets)
398    }
399
400    fn generate_openrpc_handler(
401        spec: &super::openrpc::spec_parser::OpenRpcSpec,
402        language: TargetLanguage,
403        output: &Path,
404        validate: bool,
405    ) -> Result<GeneratedAsset> {
406        let code = match language {
407            TargetLanguage::Python => generate_openrpc_python_handler(spec)?,
408            TargetLanguage::TypeScript => generate_openrpc_typescript_handler(spec)?,
409            TargetLanguage::Rust => generate_openrpc_rust_handler(spec)?,
410            TargetLanguage::Ruby => generate_openrpc_ruby_handler(spec)?,
411            TargetLanguage::Php => generate_openrpc_php_handler(spec)?,
412            TargetLanguage::Elixir => generate_openrpc_elixir_handler(spec)?,
413        };
414        if validate {
415            Self::validate_generated_code(language, &code)?;
416        }
417
418        Self::write_asset(output, format!("{} JSON-RPC handlers", language_name(language)), code)
419    }
420
421    fn generate_graphql_code(
422        schema_path: &Path,
423        language: TargetLanguage,
424        output: &Path,
425        target: &str,
426        validate: bool,
427    ) -> Result<Vec<GeneratedAsset>> {
428        let parsed_schema =
429            parse_graphql_schema(schema_path).with_context(|| format!("Failed to parse {}", schema_path.display()))?;
430
431        let code = match language {
432            TargetLanguage::Python => {
433                let generator = PythonGenerator;
434                match target {
435                    "types" => generator.generate_types(&parsed_schema)?,
436                    "resolvers" => generator.generate_resolvers(&parsed_schema)?,
437                    "schema" => generator.generate_schema_definition(&parsed_schema)?,
438                    "all" => generator.generate_complete(&parsed_schema)?,
439                    _ => generator.generate_complete(&parsed_schema)?,
440                }
441            }
442            TargetLanguage::TypeScript => {
443                let generator = TypeScriptGenerator;
444                match target {
445                    "types" => generator.generate_types(&parsed_schema)?,
446                    "resolvers" => generator.generate_resolvers(&parsed_schema)?,
447                    "schema" => generator.generate_schema_definition(&parsed_schema)?,
448                    "all" => generator.generate_complete(&parsed_schema)?,
449                    _ => generator.generate_complete(&parsed_schema)?,
450                }
451            }
452            TargetLanguage::Rust => {
453                let generator = RustGenerator::new();
454                match target {
455                    "types" => generator.generate_types(&parsed_schema)?,
456                    "resolvers" => generator.generate_resolvers(&parsed_schema)?,
457                    "schema" => generator.generate_schema_definition(&parsed_schema)?,
458                    "all" => generator.generate_complete(&parsed_schema)?,
459                    _ => generator.generate_complete(&parsed_schema)?,
460                }
461            }
462            TargetLanguage::Ruby => {
463                let generator = RubyGenerator;
464                match target {
465                    "types" => generator.generate_types(&parsed_schema)?,
466                    "resolvers" => generator.generate_resolvers(&parsed_schema)?,
467                    "schema" => generator.generate_schema_definition(&parsed_schema)?,
468                    "rbs" => generator.generate_type_signatures(&parsed_schema)?,
469                    "all" => generator.generate_complete(&parsed_schema)?,
470                    _ => generator.generate_complete(&parsed_schema)?,
471                }
472            }
473            TargetLanguage::Php => {
474                let generator = PhpGenerator;
475                match target {
476                    "types" => generator.generate_types(&parsed_schema)?,
477                    "resolvers" => generator.generate_resolvers(&parsed_schema)?,
478                    "schema" => generator.generate_schema_definition(&parsed_schema)?,
479                    "all" => generator.generate_complete(&parsed_schema)?,
480                    _ => generator.generate_complete(&parsed_schema)?,
481                }
482            }
483            TargetLanguage::Elixir => {
484                let generator = ElixirGenerator;
485                match target {
486                    "types" => generator.generate_types(&parsed_schema)?,
487                    "resolvers" => generator.generate_resolvers(&parsed_schema)?,
488                    "schema" => generator.generate_schema_definition(&parsed_schema)?,
489                    "all" => generator.generate_complete(&parsed_schema)?,
490                    _ => generator.generate_complete(&parsed_schema)?,
491                }
492            }
493        };
494        if validate {
495            Self::validate_generated_code(language, &code)?;
496        }
497
498        let mut assets = vec![Self::write_asset(
499            output,
500            format!("{} GraphQL code", language_name(language)),
501            &code,
502        )?];
503
504        if language == TargetLanguage::Ruby && (target == "all" || target == "types" || target == "schema") {
505            let generator = RubyGenerator;
506            let rbs_code = generator.generate_type_signatures(&parsed_schema)?;
507
508            let rbs_output = output.with_extension("rbs");
509
510            assets.push(Self::write_asset(
511                &rbs_output,
512                format!("{} GraphQL RBS types", language_name(language)),
513                &rbs_code,
514            )?);
515        }
516
517        Ok(assets)
518    }
519
520    fn validate_generated_code(language: TargetLanguage, code: &str) -> Result<()> {
521        let report = QualityValidator::new(language)
522            .validate_all(code)
523            .map_err(|err| anyhow::anyhow!("Failed to run quality validation: {err}"))?;
524
525        if report.is_valid() {
526            return Ok(());
527        }
528
529        bail!(
530            "{} generated code failed quality validation:\n{}",
531            language_name(language),
532            report
533        );
534    }
535
536    fn write_asset(path: &Path, description: impl Into<String>, content: impl AsRef<[u8]>) -> Result<GeneratedAsset> {
537        if let Some(parent) = path.parent()
538            && !parent.as_os_str().is_empty()
539        {
540            fs::create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
541        }
542
543        fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
544
545        Ok(GeneratedAsset {
546            path: path.to_path_buf(),
547            description: description.into(),
548        })
549    }
550}
551
552const fn language_name(language: TargetLanguage) -> &'static str {
553    match language {
554        TargetLanguage::Python => "Python",
555        TargetLanguage::TypeScript => "Node.js",
556        TargetLanguage::Rust => "Rust",
557        TargetLanguage::Ruby => "Ruby",
558        TargetLanguage::Php => "PHP",
559        TargetLanguage::Elixir => "Elixir",
560    }
561}
562
563impl std::fmt::Debug for CodegenTargetKind {
564    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
565        match self {
566            Self::Server { language, .. } => f
567                .debug_struct("Server")
568                .field("language", language)
569                .finish_non_exhaustive(),
570            Self::AsyncFixtures { output } => f.debug_struct("AsyncFixtures").field("output", output).finish(),
571            Self::AsyncTestApp { language, output } => f
572                .debug_struct("AsyncTestApp")
573                .field("language", language)
574                .field("output", output)
575                .finish(),
576            Self::AsyncHandlers { language, output } => f
577                .debug_struct("AsyncHandlers")
578                .field("language", language)
579                .field("output", output)
580                .finish(),
581            Self::AsyncAll { output } => f.debug_struct("AsyncAll").field("output", output).finish(),
582            Self::JsonRpcHandlers { language, output } => f
583                .debug_struct("JsonRpcHandlers")
584                .field("language", language)
585                .field("output", output)
586                .finish(),
587            Self::GraphQL {
588                language,
589                output,
590                target,
591            } => f
592                .debug_struct("GraphQL")
593                .field("language", language)
594                .field("output", output)
595                .field("target", target)
596                .finish(),
597            Self::Protobuf {
598                language,
599                output,
600                target,
601                include_paths,
602            } => f
603                .debug_struct("Protobuf")
604                .field("language", language)
605                .field("output", output)
606                .field("target", target)
607                .field("include_paths", include_paths)
608                .finish(),
609            Self::SqlHandlers {
610                schema_paths,
611                output,
612                dialect,
613                languages,
614                emit_openapi,
615                ..
616            } => f
617                .debug_struct("SqlHandlers")
618                .field("schema_paths", schema_paths)
619                .field("output", output)
620                .field("dialect", dialect)
621                .field("languages", languages)
622                .field("emit_openapi", emit_openapi)
623                .finish_non_exhaustive(),
624        }
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use tempfile::tempdir;
632
633    fn write_minimal_openapi_schema(path: &Path) {
634        let spec = r#"
635{
636  "openapi": "3.0.3",
637  "info": { "title": "Demo", "version": "1.0.0" },
638  "paths": {
639    "/ping": {
640      "get": {
641        "operationId": "ping",
642        "responses": {
643          "200": {
644            "description": "ok",
645            "content": {
646              "application/json": {
647                "schema": {
648                  "type": "object",
649                  "properties": { "message": { "type": "string" } },
650                  "required": ["message"]
651                }
652              }
653            }
654          }
655        }
656      }
657    }
658  }
659}
660"#;
661        fs::write(path, spec).unwrap();
662    }
663
664    #[test]
665    fn generates_openapi_code_in_memory_when_no_output_path() {
666        let dir = tempdir().unwrap();
667        let schema_path = dir.path().join("openapi.json");
668        write_minimal_openapi_schema(&schema_path);
669
670        let outcome = CodegenEngine::execute(CodegenRequest {
671            schema_path,
672            schema_kind: SchemaKind::OpenApi,
673            target: CodegenTargetKind::Server {
674                language: TargetLanguage::Python,
675                output: None,
676            },
677            dto: None,
678        })
679        .unwrap();
680
681        match outcome {
682            CodegenOutcome::InMemory(code) => {
683                assert!(code.contains("Generated by Spikard OpenAPI code generator"));
684                assert!(code.contains("ping"));
685            }
686            other => panic!("expected in-memory output, got {other:?}"),
687        }
688    }
689
690    #[test]
691    fn generates_openapi_code_to_file_when_output_path_provided() {
692        let dir = tempdir().unwrap();
693        let schema_path = dir.path().join("openapi.json");
694        write_minimal_openapi_schema(&schema_path);
695
696        let output_path = dir.path().join("generated.py");
697        let outcome = CodegenEngine::execute(CodegenRequest {
698            schema_path,
699            schema_kind: SchemaKind::OpenApi,
700            target: CodegenTargetKind::Server {
701                language: TargetLanguage::Python,
702                output: Some(output_path.clone()),
703            },
704            dto: None,
705        })
706        .unwrap();
707
708        match outcome {
709            CodegenOutcome::Files(assets) => {
710                assert_eq!(assets.len(), 1);
711                assert_eq!(assets[0].path, output_path);
712                assert!(assets[0].description.contains("Python"));
713                assert!(
714                    fs::read_to_string(&assets[0].path)
715                        .unwrap()
716                        .contains("Generated by Spikard OpenAPI code generator")
717                );
718            }
719            other => panic!("expected file output, got {other:?}"),
720        }
721    }
722
723    #[test]
724    fn rejects_unsupported_schema_target_combinations() {
725        let dir = tempdir().unwrap();
726        let schema_path = dir.path().join("openapi.json");
727        write_minimal_openapi_schema(&schema_path);
728
729        let err = CodegenEngine::execute(CodegenRequest {
730            schema_path,
731            schema_kind: SchemaKind::OpenApi,
732            target: CodegenTargetKind::AsyncFixtures {
733                output: dir.path().join("out"),
734            },
735            dto: None,
736        })
737        .unwrap_err();
738
739        assert!(err.to_string().contains("Unsupported schema/target combination"));
740    }
741
742    #[test]
743    fn generates_openrpc_handlers_to_file() {
744        let dir = tempdir().unwrap();
745        let schema_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
746            .join("../../testing_data/schemas/user-api.openrpc.json");
747
748        let output_path = dir.path().join("handlers.ts");
749        let outcome = CodegenEngine::execute(CodegenRequest {
750            schema_path,
751            schema_kind: SchemaKind::OpenRpc,
752            target: CodegenTargetKind::JsonRpcHandlers {
753                language: TargetLanguage::TypeScript,
754                output: output_path.clone(),
755            },
756            dto: None,
757        })
758        .unwrap();
759
760        match outcome {
761            CodegenOutcome::Files(assets) => {
762                assert_eq!(assets.len(), 1);
763                assert_eq!(assets[0].path, output_path);
764                let contents = fs::read_to_string(&assets[0].path).unwrap();
765                assert!(contents.contains("handleJsonRpcCall"));
766            }
767            other => panic!("expected file output, got {other:?}"),
768        }
769    }
770
771    #[test]
772    fn generates_protobuf_python_code_to_file() {
773        let dir = tempdir().unwrap();
774        let schema_path = dir.path().join("test.proto");
775
776        let proto_schema = r#"syntax = "proto3";
777
778package test;
779
780message TestMessage {
781  string id = 1;
782  string name = 2;
783}
784"#;
785        fs::write(&schema_path, proto_schema).unwrap();
786
787        let output_path = dir.path().join("test_pb.py");
788        let outcome = CodegenEngine::execute(CodegenRequest {
789            schema_path,
790            schema_kind: SchemaKind::Protobuf,
791            target: CodegenTargetKind::Protobuf {
792                language: TargetLanguage::Python,
793                output: output_path.clone(),
794                target: "all".to_string(),
795                include_paths: Vec::new(),
796            },
797            dto: None,
798        })
799        .unwrap();
800
801        match outcome {
802            CodegenOutcome::Files(assets) => {
803                assert_eq!(assets.len(), 1);
804                assert_eq!(assets[0].path, output_path);
805                let contents = fs::read_to_string(&assets[0].path).unwrap();
806                assert!(contents.contains("DO NOT EDIT - Auto-generated by Spikard CLI"));
807                assert!(contents.contains("from google.protobuf import message"));
808                assert!(contents.contains("PROTOBUF_PACKAGE = \"test\""));
809            }
810            other => panic!("expected file output, got {other:?}"),
811        }
812    }
813
814    #[test]
815    fn validates_generated_rust_protobuf_before_writing() {
816        let dir = tempdir().unwrap();
817        let schema_path = dir.path().join("service.proto");
818        fs::write(
819            &schema_path,
820            r#"syntax = "proto3";
821
822package example;
823
824message User {
825  string id = 1;
826  string name = 2;
827}
828
829service UserService {
830  rpc GetUser (User) returns (User);
831}
832"#,
833        )
834        .unwrap();
835
836        let output_path = dir.path().join("generated.rs");
837        let outcome = CodegenEngine::execute_validated(CodegenRequest {
838            schema_path,
839            schema_kind: SchemaKind::Protobuf,
840            target: CodegenTargetKind::Protobuf {
841                language: TargetLanguage::Rust,
842                output: output_path.clone(),
843                target: "all".to_string(),
844                include_paths: Vec::new(),
845            },
846            dto: None,
847        })
848        .unwrap();
849
850        match outcome {
851            CodegenOutcome::Files(assets) => {
852                assert_eq!(assets.len(), 1);
853                assert_eq!(assets[0].path, output_path);
854                assert!(
855                    fs::read_to_string(&assets[0].path)
856                        .unwrap()
857                        .contains("pub trait UserService")
858                );
859            }
860            other => panic!("expected file output, got {other:?}"),
861        }
862    }
863
864    #[test]
865    fn validates_generated_rust_openrpc_before_writing() {
866        let dir = tempdir().unwrap();
867        let schema_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
868            .join("../../testing_data/schemas/user-api.openrpc.json");
869        let output_path = dir.path().join("openrpc.rs");
870
871        let outcome = CodegenEngine::execute_validated(CodegenRequest {
872            schema_path,
873            schema_kind: SchemaKind::OpenRpc,
874            target: CodegenTargetKind::JsonRpcHandlers {
875                language: TargetLanguage::Rust,
876                output: output_path.clone(),
877            },
878            dto: None,
879        })
880        .unwrap();
881
882        match outcome {
883            CodegenOutcome::Files(assets) => {
884                assert_eq!(assets.len(), 1);
885                assert_eq!(assets[0].path, output_path);
886                let contents = fs::read_to_string(&assets[0].path).unwrap();
887                assert!(contents.contains("pub async fn handle_jsonrpc_call"));
888                assert!(contents.contains("pub fn register_jsonrpc_route"));
889            }
890            other => panic!("expected file output, got {other:?}"),
891        }
892    }
893
894    #[test]
895    fn validates_generated_rust_asyncapi_before_writing() {
896        let dir = tempdir().unwrap();
897        let schema_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
898            .join("../../testing_data/schemas/chat-service.asyncapi.yaml");
899        let output_path = dir.path().join("asyncapi.rs");
900
901        let outcome = CodegenEngine::execute_validated(CodegenRequest {
902            schema_path,
903            schema_kind: SchemaKind::AsyncApi,
904            target: CodegenTargetKind::AsyncHandlers {
905                language: TargetLanguage::Rust,
906                output: output_path.clone(),
907            },
908            dto: None,
909        })
910        .unwrap();
911
912        match outcome {
913            CodegenOutcome::Files(assets) => {
914                assert_eq!(assets.len(), 1);
915                assert_eq!(assets[0].path, output_path);
916                let contents = fs::read_to_string(&assets[0].path).unwrap();
917                assert!(contents.contains("pub fn register_asyncapi_routes"));
918                assert!(contents.contains("pub fn build_app() -> App"));
919            }
920            other => panic!("expected file output, got {other:?}"),
921        }
922    }
923
924    #[test]
925    fn validates_generated_rust_openapi_before_writing() {
926        let dir = tempdir().unwrap();
927        let schema_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
928            .join("../../testing_data/schemas/todo-api.openapi.yaml");
929        let output_path = dir.path().join("openapi.rs");
930
931        let outcome = CodegenEngine::execute_validated(CodegenRequest {
932            schema_path,
933            schema_kind: SchemaKind::OpenApi,
934            target: CodegenTargetKind::Server {
935                language: TargetLanguage::Rust,
936                output: Some(output_path.clone()),
937            },
938            dto: None,
939        })
940        .unwrap();
941
942        match outcome {
943            CodegenOutcome::Files(assets) => {
944                assert_eq!(assets.len(), 1);
945                assert_eq!(assets[0].path, output_path);
946                let contents = fs::read_to_string(&assets[0].path).unwrap();
947                assert!(contents.contains("pub fn build_app() -> Result<App, AppError>"));
948                assert!(contents.contains("pub struct AuthErrorResponse"));
949            }
950            other => panic!("expected file output, got {other:?}"),
951        }
952    }
953}