Skip to main content

spikard_cli/codegen/quality/
validator.rs

1//! Quality validation implementation for generated code
2//!
3//! This module implements language-specific validation for syntax, types, and linting.
4
5use crate::codegen::TargetLanguage;
6use std::fmt;
7use std::fs;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use tempfile::{Builder, NamedTempFile, TempDir, tempdir};
12
13/// Error types for quality validation operations
14#[derive(Debug)]
15pub enum QualityError {
16    /// A required validation tool was not found in the system PATH
17    ToolNotFound(String),
18    /// Validation failed with a specific error message
19    ValidationFailed(String),
20    /// I/O error during file operations
21    IoError(String),
22}
23
24impl fmt::Display for QualityError {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        match self {
27            Self::ToolNotFound(tool) => {
28                write!(f, "Required validation tool not found: {tool}")
29            }
30            Self::ValidationFailed(msg) => {
31                write!(f, "Validation failed: {msg}")
32            }
33            Self::IoError(msg) => {
34                write!(f, "I/O error: {msg}")
35            }
36        }
37    }
38}
39
40impl std::error::Error for QualityError {}
41
42impl From<std::io::Error> for QualityError {
43    fn from(err: std::io::Error) -> Self {
44        Self::IoError(err.to_string())
45    }
46}
47
48/// Comprehensive validation report containing results from all quality gates
49#[derive(Debug, Clone)]
50pub struct ValidationReport {
51    /// Whether syntax validation passed
52    pub syntax_passed: bool,
53    /// Whether type validation passed
54    pub types_passed: bool,
55    /// Whether linting validation passed
56    pub lint_passed: bool,
57    /// List of all validation errors encountered
58    pub errors: Vec<String>,
59}
60
61impl ValidationReport {
62    /// Creates a new empty validation report
63    const fn new() -> Self {
64        Self {
65            syntax_passed: false,
66            types_passed: false,
67            lint_passed: false,
68            errors: Vec::new(),
69        }
70    }
71
72    /// Checks if all validation checks passed
73    ///
74    /// Returns `true` only if syntax, types, and lint all passed without errors.
75    #[must_use]
76    pub const fn is_valid(&self) -> bool {
77        self.syntax_passed && self.types_passed && self.lint_passed && self.errors.is_empty()
78    }
79
80    /// Returns the count of validation errors
81    #[must_use]
82    pub const fn error_count(&self) -> usize {
83        self.errors.len()
84    }
85
86    /// Adds an error message to the report
87    fn add_error(&mut self, error: String) {
88        self.errors.push(error);
89    }
90}
91
92impl fmt::Display for ValidationReport {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        writeln!(f, "Validation Report")?;
95        writeln!(f, "  Syntax: {}", if self.syntax_passed { "PASS" } else { "FAIL" })?;
96        writeln!(f, "  Types:  {}", if self.types_passed { "PASS" } else { "FAIL" })?;
97        writeln!(f, "  Lint:   {}", if self.lint_passed { "PASS" } else { "FAIL" })?;
98
99        if !self.errors.is_empty() {
100            writeln!(f, "  Errors: {}", self.error_count())?;
101            for error in &self.errors {
102                writeln!(f, "    - {error}")?;
103            }
104        }
105
106        Ok(())
107    }
108}
109
110/// Language-specific code quality validator
111///
112/// Orchestrates syntax, type, and lint validation for generated code across
113/// all supported target languages.
114///
115/// # Architecture
116///
117/// The validator follows a layered approach:
118///
119/// 1. **Code staging**: Writes code to a temporary file with appropriate extension
120/// 2. **Tool execution**: Runs language-specific validation tools
121/// 3. **Error parsing**: Extracts and structures error messages
122/// 4. **Report generation**: Compiles results into a [`ValidationReport`]
123///
124/// # Zero-Copy Design
125///
126/// Code is written to disk once and reused for all validation passes, minimizing
127/// I/O overhead. Tools operate directly on the filesystem.
128#[derive(Debug)]
129pub struct QualityValidator {
130    language: TargetLanguage,
131}
132
133impl QualityValidator {
134    /// Creates a new quality validator for the specified language
135    ///
136    /// # Arguments
137    ///
138    /// * `language` - The target language for validation
139    ///
140    /// # Example
141    ///
142    /// ```ignore
143    /// let validator = QualityValidator::new(TargetLanguage::Python);
144    /// ```
145    #[must_use]
146    pub const fn new(language: TargetLanguage) -> Self {
147        Self { language }
148    }
149
150    /// Validates syntax by attempting to parse/compile the code
151    ///
152    /// Each language uses its native compiler or parser:
153    /// - Python: `python3 -m py_compile`
154    /// - TypeScript: `tsc --noEmit`
155    /// - Ruby: `ruby -c`
156    /// - PHP: `php -l`
157    /// - Rust: `cargo check`
158    ///
159    /// # Arguments
160    ///
161    /// * `code` - The source code to validate
162    ///
163    /// # Returns
164    ///
165    /// - `Ok(())` if syntax is valid
166    /// - `Err(QualityError::ToolNotFound)` if the validation tool is unavailable
167    /// - `Err(QualityError::ValidationFailed)` if syntax errors are found
168    /// - `Err(QualityError::IoError)` if file operations fail
169    ///
170    /// # Example
171    ///
172    /// ```ignore
173    /// let validator = QualityValidator::new(TargetLanguage::Python);
174    /// validator.validate_syntax("x = 1")?;
175    /// ```
176    pub fn validate_syntax(&self, code: &str) -> Result<(), QualityError> {
177        match self.language {
178            TargetLanguage::Python => {
179                let project = self.write_temp_python_project(code)?;
180                self.run_tool_in_dir(
181                    "python3",
182                    &[
183                        "-m",
184                        "py_compile",
185                        project.entry_path.file_name().unwrap().to_str().unwrap(),
186                    ],
187                    project.workdir.path(),
188                    code,
189                )
190                .map(|_| ())
191            }
192            TargetLanguage::TypeScript => {
193                let project = self.write_temp_typescript_project(code)?;
194                self.run_tool_in_dir(
195                    "pnpm",
196                    &[
197                        "exec",
198                        "tsc",
199                        "--noEmit",
200                        "--project",
201                        project.config_path.to_str().unwrap(),
202                    ],
203                    Path::new("."),
204                    code,
205                )
206                .map(|_| ())
207            }
208            TargetLanguage::Rust => {
209                let project = self.write_temp_rust_project(code)?;
210                self.run_tool_in_dir(
211                    "cargo",
212                    &["check", "--manifest-path", project.manifest_path.to_str().unwrap()],
213                    project.workdir.path(),
214                    code,
215                )
216                .map(|_| ())
217            }
218            TargetLanguage::Ruby => {
219                let file = self.write_temp_file(code, "rb")?;
220                self.run_tool("ruby", &["-c", file.path().to_str().unwrap()], code)
221                    .map(|_| ())
222            }
223            TargetLanguage::Php => {
224                let file = self.write_temp_file(code, "php")?;
225                self.run_tool("php", &["-l", file.path().to_str().unwrap()], code)
226                    .map(|_| ())
227            }
228            TargetLanguage::Elixir => {
229                let project = self.write_temp_elixir_project(code)?;
230                self.run_tool_in_dir(
231                    "mix",
232                    &["compile", "--warnings-as-errors"],
233                    project.workdir.path(),
234                    code,
235                )
236                .map(|_| ())
237            }
238        }
239    }
240
241    /// Validates type correctness using language-specific type checkers
242    ///
243    /// Not all languages support this check; unsupported languages return `Ok(())`.
244    ///
245    /// Tools used:
246    /// - Python: `mypy --strict`
247    /// - TypeScript: `tsc --noEmit`
248    /// - Ruby: `steep check`
249    /// - PHP: Not supported (lint validation covers this)
250    /// - Rust: `cargo check`
251    ///
252    /// # Arguments
253    ///
254    /// * `code` - The source code to validate
255    ///
256    /// # Returns
257    ///
258    /// - `Ok(())` if types are valid or language doesn't support type checking
259    /// - `Err(QualityError::ToolNotFound)` if the type checker is unavailable
260    /// - `Err(QualityError::ValidationFailed)` if type errors are found
261    /// - `Err(QualityError::IoError)` if file operations fail
262    ///
263    /// # Example
264    ///
265    /// ```ignore
266    /// let validator = QualityValidator::new(TargetLanguage::TypeScript);
267    /// validator.validate_types("const x: number = 5;")?;
268    /// ```
269    pub fn validate_types(&self, code: &str) -> Result<(), QualityError> {
270        match self.language {
271            TargetLanguage::Python => {
272                let project = self.write_temp_python_project(code)?;
273                self.run_tool_in_dir_with_env(
274                    "uv",
275                    &["run", "mypy", "--strict", project.entry_path.to_str().unwrap()],
276                    workspace_root(),
277                    &[("MYPYPATH", project.stub_path.as_os_str())],
278                    code,
279                )
280                .map(|_| ())
281            }
282            TargetLanguage::TypeScript => {
283                let project = self.write_temp_typescript_project(code)?;
284                self.run_tool_in_dir(
285                    "pnpm",
286                    &[
287                        "exec",
288                        "tsc",
289                        "--strict",
290                        "--noEmit",
291                        "--project",
292                        project.config_path.to_str().unwrap(),
293                    ],
294                    Path::new("."),
295                    code,
296                )
297                .map(|_| ())
298            }
299            TargetLanguage::Ruby => {
300                let file = self.write_temp_file(code, "rb")?;
301                let package_dir = workspace_root().join("packages/ruby");
302                self.run_tool_in_dir(
303                    "bundle",
304                    &["exec", "steep", "check", file.path().to_str().unwrap()],
305                    &package_dir,
306                    code,
307                )
308                .map(|_| ())
309            }
310            TargetLanguage::Rust => {
311                let project = self.write_temp_rust_project(code)?;
312                self.run_tool_in_dir(
313                    "cargo",
314                    &["check", "--manifest-path", project.manifest_path.to_str().unwrap()],
315                    project.workdir.path(),
316                    code,
317                )
318                .map(|_| ())
319            }
320            TargetLanguage::Php => {
321                // PHP doesn't have a separate type checker; covered by lint
322                Ok(())
323            }
324            TargetLanguage::Elixir => Ok(()),
325        }
326    }
327
328    /// Validates code against linting and style standards
329    ///
330    /// Each language enforces its community standards:
331    /// - Python: `ruff check`
332    /// - TypeScript: `biome check`
333    /// - Ruby: `rubocop`
334    /// - PHP: `phpstan --level=max`
335    /// - Rust: `cargo clippy -- -D warnings`
336    ///
337    /// # Arguments
338    ///
339    /// * `code` - The source code to validate
340    ///
341    /// # Returns
342    ///
343    /// - `Ok(())` if code passes all linting checks
344    /// - `Err(QualityError::ToolNotFound)` if the linter is unavailable
345    /// - `Err(QualityError::ValidationFailed)` if linting violations are found
346    /// - `Err(QualityError::IoError)` if file operations fail
347    ///
348    /// # Example
349    ///
350    /// ```ignore
351    /// let validator = QualityValidator::new(TargetLanguage::Python);
352    /// validator.validate_lint("import os\nx = 1")?;
353    /// ```
354    pub fn validate_lint(&self, code: &str) -> Result<(), QualityError> {
355        match self.language {
356            TargetLanguage::Python => {
357                let project = self.write_temp_python_project(code)?;
358                self.run_tool_in_dir(
359                    "uv",
360                    &["run", "ruff", "check", project.entry_path.to_str().unwrap()],
361                    workspace_root(),
362                    code,
363                )
364                .map(|_| ())
365            }
366            TargetLanguage::TypeScript => Ok(()),
367            TargetLanguage::Ruby => {
368                let file = self.write_temp_file(code, "rb")?;
369                let package_dir = workspace_root().join("packages/ruby");
370                self.run_tool_in_dir(
371                    "bundle",
372                    &[
373                        "exec",
374                        "rubocop",
375                        "--disable-pending-cops",
376                        "--except",
377                        "Naming/FileName",
378                        file.path().to_str().unwrap(),
379                    ],
380                    &package_dir,
381                    code,
382                )
383                .map(|_| ())
384            }
385            TargetLanguage::Php => {
386                let file = self.write_temp_file(code, "php")?;
387                let bootstrap = self.write_php_validation_bootstrap()?;
388                let package_dir = workspace_root().join("packages/php");
389                self.run_tool_in_dir(
390                    "composer",
391                    &[
392                        "exec",
393                        "--",
394                        "phpstan",
395                        "analyse",
396                        "--no-progress",
397                        "--error-format=raw",
398                        "--level=max",
399                        "--autoload-file",
400                        bootstrap.path().to_str().unwrap(),
401                        file.path().to_str().unwrap(),
402                    ],
403                    &package_dir,
404                    code,
405                )
406                .map(|_| ())
407            }
408            TargetLanguage::Elixir => {
409                let project = self.write_temp_elixir_project(code)?;
410                let generated = project.generated_path.strip_prefix(project.workdir.path()).unwrap();
411                self.run_tool_in_dir(
412                    "mix",
413                    &[
414                        "format",
415                        "--check-formatted",
416                        generated.to_str().unwrap(),
417                        "mix.exs",
418                        ".formatter.exs",
419                        "lib/spikard/router.ex",
420                        "lib/spikard/request.ex",
421                        "lib/spikard/response.ex",
422                    ],
423                    project.workdir.path(),
424                    code,
425                )
426                .map(|_| ())
427            }
428            TargetLanguage::Rust => {
429                let project = self.write_temp_rust_project(code)?;
430                self.run_tool_in_dir(
431                    "cargo",
432                    &[
433                        "clippy",
434                        "--manifest-path",
435                        project.manifest_path.to_str().unwrap(),
436                        "--",
437                        "-D",
438                        "warnings",
439                    ],
440                    project.workdir.path(),
441                    code,
442                )
443                .map(|_| ())
444            }
445        }
446    }
447
448    /// Runs all validation checks (syntax, types, lint) and returns a comprehensive report
449    ///
450    /// This method executes all three quality gates sequentially and compiles results
451    /// into a single [`ValidationReport`]. All errors are captured, allowing callers
452    /// to see the complete picture of validation failures.
453    ///
454    /// # Arguments
455    ///
456    /// * `code` - The source code to validate
457    ///
458    /// # Returns
459    ///
460    /// - `Ok(report)` with validation results
461    /// - `Err(QualityError)` only if an I/O error occurs; validation failures are captured in the report
462    ///
463    /// # Example
464    ///
465    /// ```ignore
466    /// let validator = QualityValidator::new(TargetLanguage::Python);
467    /// let report = validator.validate_all("x = 1")?;
468    ///
469    /// if report.is_valid() {
470    ///     println!("Code is production-ready");
471    /// } else {
472    ///     eprintln!("Found {} validation errors", report.error_count());
473    /// }
474    /// ```
475    pub fn validate_all(&self, code: &str) -> Result<ValidationReport, QualityError> {
476        let mut report = ValidationReport::new();
477
478        // Syntax validation
479        match self.validate_syntax(code) {
480            Ok(()) => report.syntax_passed = true,
481            Err(e) => {
482                report.syntax_passed = false;
483                report.add_error(format!("Syntax: {e}"));
484            }
485        }
486
487        // Type validation
488        match self.validate_types(code) {
489            Ok(()) => report.types_passed = true,
490            Err(e) => {
491                report.types_passed = false;
492                report.add_error(format!("Types: {e}"));
493            }
494        }
495
496        // Lint validation
497        match self.validate_lint(code) {
498            Ok(()) => report.lint_passed = true,
499            Err(e) => {
500                report.lint_passed = false;
501                report.add_error(format!("Lint: {e}"));
502            }
503        }
504
505        Ok(report)
506    }
507
508    /// Writes code to a temporary file with the specified extension
509    ///
510    /// # Arguments
511    ///
512    /// * `code` - The source code to write
513    /// * `ext` - File extension (without leading dot)
514    ///
515    /// # Returns
516    ///
517    /// - `Ok(file)` - A named temporary file handle
518    /// - `Err(QualityError::IoError)` - If the file cannot be created or written
519    fn write_temp_file(&self, code: &str, ext: &str) -> Result<NamedTempFile, QualityError> {
520        let mut file = Builder::new()
521            .prefix("generated_")
522            .suffix(&format!(".{ext}"))
523            .tempfile()
524            .map_err(|e: std::io::Error| QualityError::IoError(e.to_string()))?;
525        file.write_all(code.as_bytes())
526            .map_err(|e: std::io::Error| QualityError::IoError(e.to_string()))?;
527        file.flush()
528            .map_err(|e: std::io::Error| QualityError::IoError(e.to_string()))?;
529        Ok(file)
530    }
531
532    fn write_temp_rust_project(&self, code: &str) -> Result<RustTempProject, QualityError> {
533        let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
534        let src_dir = workdir.path().join("src");
535        fs::create_dir_all(&src_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
536
537        let manifest_path = workdir.path().join("Cargo.toml");
538        let lib_path = src_dir.join("lib.rs");
539
540        fs::write(&manifest_path, rust_temp_manifest()).map_err(|e| QualityError::IoError(e.to_string()))?;
541        fs::write(&lib_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
542
543        Ok(RustTempProject { workdir, manifest_path })
544    }
545
546    fn write_temp_python_project(&self, code: &str) -> Result<PythonTempProject, QualityError> {
547        let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
548        let entry_path = workdir.path().join("generated.py");
549        let stub_path = workdir.path().join("stubs");
550
551        fs::write(&entry_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
552        fs::create_dir_all(&stub_path).map_err(|e| QualityError::IoError(e.to_string()))?;
553        write_python_validation_stubs(&stub_path)?;
554
555        Ok(PythonTempProject {
556            workdir,
557            entry_path,
558            stub_path,
559        })
560    }
561
562    fn write_temp_typescript_project(&self, code: &str) -> Result<TypeScriptTempProject, QualityError> {
563        let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
564        let entry_path = workdir.path().join("generated.ts");
565        let config_path = workdir.path().join("tsconfig.json");
566        let spikard_stub_path = workdir.path().join("spikard.d.ts");
567        let zod_stub_path = workdir.path().join("zod.d.ts");
568        let graphql_stub_path = workdir.path().join("graphql.d.ts");
569        let graphql_tools_stub_path = workdir.path().join("graphql-tools-schema.d.ts");
570        let protobufjs_stub_path = workdir.path().join("protobufjs.d.ts");
571
572        fs::write(&entry_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
573        fs::write(
574            &config_path,
575            r#"{
576  "compilerOptions": {
577    "target": "ES2022",
578    "module": "ESNext",
579    "moduleResolution": "Bundler",
580    "strict": true,
581    "skipLibCheck": true,
582    "noEmit": true
583  },
584  "files": [
585    "generated.ts",
586    "spikard.d.ts",
587    "zod.d.ts",
588    "graphql.d.ts",
589    "graphql-tools-schema.d.ts",
590    "protobufjs.d.ts"
591  ]
592}
593"#,
594        )
595        .map_err(|e| QualityError::IoError(e.to_string()))?;
596        fs::write(
597            &spikard_stub_path,
598            r#"declare module "spikard" {
599  export type RouteMetadata = {
600    method: string;
601    path: string;
602    handler_name: string;
603    is_async: boolean;
604  };
605
606  export type SpikardApp = {
607    routes: RouteMetadata[];
608    handlers: Record<string, unknown>;
609  };
610
611  export class StreamingResponse {
612    constructor(body?: unknown, init?: unknown);
613  }
614
615  export class Spikard {
616    start(config?: unknown): Promise<void>;
617  }
618
619  export type Body<T> = T;
620  export type Path<T> = T;
621  export type Query<T> = T;
622  export type Request = Record<string, unknown>;
623
624  export function route(...args: unknown[]): any;
625}
626"#,
627        )
628        .map_err(|e| QualityError::IoError(e.to_string()))?;
629        fs::write(
630            &zod_stub_path,
631            r#"declare module "zod" {
632  export namespace z {
633    export type infer<T> = any;
634  }
635
636  export const z: any;
637}
638"#,
639        )
640        .map_err(|e| QualityError::IoError(e.to_string()))?;
641        fs::write(
642            &graphql_stub_path,
643            r#"declare module "graphql" {
644  export interface GraphQLResolveInfo {}
645}
646"#,
647        )
648        .map_err(|e| QualityError::IoError(e.to_string()))?;
649        fs::write(
650            &graphql_tools_stub_path,
651            r#"declare module "@graphql-tools/schema" {
652  export function makeExecutableSchema(config: {
653    typeDefs: string;
654    resolvers: unknown;
655  }): unknown;
656}
657"#,
658        )
659        .map_err(|e| QualityError::IoError(e.to_string()))?;
660        fs::write(
661            &protobufjs_stub_path,
662            r#"declare module "protobufjs" {
663  const protobuf: Record<string, unknown>;
664  export = protobuf;
665}
666"#,
667        )
668        .map_err(|e| QualityError::IoError(e.to_string()))?;
669
670        Ok(TypeScriptTempProject {
671            _workdir: workdir,
672            config_path,
673        })
674    }
675
676    fn write_temp_elixir_project(&self, code: &str) -> Result<ElixirTempProject, QualityError> {
677        let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
678        let mix_exs = workdir.path().join("mix.exs");
679        let formatter = workdir.path().join(".formatter.exs");
680        let lib_dir = workdir.path().join("lib");
681        let spikard_dir = lib_dir.join("spikard");
682        let generated_path = lib_dir.join("generated.ex");
683
684        fs::create_dir_all(&spikard_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
685
686        fs::write(
687            &mix_exs,
688            r#"defmodule GeneratedValidation.MixProject do
689  use Mix.Project
690
691  def project do
692    [
693      app: :generated_validation,
694      version: "0.1.0",
695      elixir: "~> 1.18",
696      deps: []
697    ]
698  end
699end
700"#,
701        )
702        .map_err(|e| QualityError::IoError(e.to_string()))?;
703        fs::write(
704            &formatter,
705            r#"[
706  inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
707  line_length: 120
708]
709"#,
710        )
711        .map_err(|e| QualityError::IoError(e.to_string()))?;
712        fs::write(
713            spikard_dir.join("router.ex"),
714            r#"defmodule Spikard.Router do
715  defmacro __using__(_opts) do
716    quote do
717      import Spikard.Router
718      Module.register_attribute(__MODULE__, :spikard_routes, accumulate: true)
719    end
720  end
721
722  for method <- ~w(get post put patch delete)a do
723    defmacro unquote(method)(path, handler, opts \\ []) do
724      quote do
725        @spikard_routes {unquote(path), unquote(handler), unquote(opts)}
726      end
727    end
728  end
729end
730"#,
731        )
732        .map_err(|e| QualityError::IoError(e.to_string()))?;
733        fs::write(
734            spikard_dir.join("request.ex"),
735            r#"defmodule Spikard.Request do
736  @type t :: map()
737
738  @spec get_path_param(t(), String.t()) :: term()
739  def get_path_param(_request, _key), do: nil
740
741  @spec get_query_param(t(), String.t()) :: term()
742  def get_query_param(_request, _key), do: nil
743
744  @spec get_header(t(), String.t()) :: term()
745  def get_header(_request, _key), do: nil
746
747  @spec get_cookie(t(), String.t()) :: term()
748  def get_cookie(_request, _key), do: nil
749
750  @spec get_body(t()) :: term()
751  def get_body(_request), do: %{}
752end
753"#,
754        )
755        .map_err(|e| QualityError::IoError(e.to_string()))?;
756        fs::write(
757            spikard_dir.join("response.ex"),
758            r#"defmodule Spikard.Response do
759  @type t :: %{status: non_neg_integer(), headers: [{String.t(), String.t()}], body: term()}
760
761  @spec json(term(), keyword()) :: t()
762  def json(body, opts \\ []) do
763    %{status: Keyword.get(opts, :status, 200), headers: [{"content-type", "application/json"}], body: body}
764  end
765
766  @spec status(non_neg_integer()) :: t()
767  def status(code) do
768    %{status: code, headers: [], body: nil}
769  end
770end
771"#,
772        )
773        .map_err(|e| QualityError::IoError(e.to_string()))?;
774        fs::write(
775            spikard_dir.join("grpc.ex"),
776            r#"defmodule Spikard.Grpc do
777  defmodule Request do
778    @type t :: %{
779            service_name: String.t(),
780            method_name: String.t(),
781            payload: binary(),
782            metadata: %{optional(String.t()) => String.t()}
783          }
784  end
785
786  defmodule Error do
787    defstruct [:code, :message, metadata: %{}]
788    @type t :: %__MODULE__{code: term(), message: String.t(), metadata: map()}
789  end
790
791  defmodule Response do
792    defstruct payload: <<>>, metadata: %{}
793    @type t :: %__MODULE__{payload: binary(), metadata: %{optional(String.t()) => String.t()}}
794
795    @spec error(String.t(), term()) :: {:error, Spikard.Grpc.Error.t()}
796    def error(message, code \\ :internal) do
797      {:error, %Spikard.Grpc.Error{code: code, message: message, metadata: %{}}}
798    end
799  end
800
801  defmodule Service do
802    defstruct services: %{}
803    @type t :: %__MODULE__{services: map()}
804
805    @spec new() :: t()
806    def new, do: %__MODULE__{}
807
808    @spec register(t(), String.t(), String.t(), atom(), function()) :: t()
809    def register(%__MODULE__{services: services} = service, service_name, method_name, rpc_mode, handler) do
810      methods =
811        services
812        |> Map.get(service_name, %{})
813        |> Map.put(method_name, {rpc_mode, handler})
814
815      %{service | services: Map.put(services, service_name, methods)}
816    end
817  end
818end
819"#,
820        )
821        .map_err(|e| QualityError::IoError(e.to_string()))?;
822        fs::write(
823            spikard_dir.join("websocket.ex"),
824            r#"defmodule Spikard.WebSocket do
825  @callback handle_connect(term(), term()) :: {:ok, term()} | {:error, term()}
826  @callback handle_message(term(), term()) ::
827              {:reply, term(), term()} | {:noreply, term()} | {:error, term()}
828  @callback handle_disconnect(term(), term()) :: :ok | {:error, term()}
829
830  defmacro __using__(_opts) do
831    quote do
832      @behaviour Spikard.WebSocket
833
834      @impl true
835      def handle_connect(_ws, _opts), do: {:ok, nil}
836
837      @impl true
838      def handle_message(message, state), do: {:reply, message, state}
839
840      @impl true
841      def handle_disconnect(_ws, _state), do: :ok
842
843      defoverridable handle_connect: 2, handle_message: 2, handle_disconnect: 2
844    end
845  end
846end
847"#,
848        )
849        .map_err(|e| QualityError::IoError(e.to_string()))?;
850        fs::write(
851            spikard_dir.join("sse.ex"),
852            r#"defmodule Spikard.Sse.Event do
853  defstruct [:data, :event, :id]
854  @type t :: %__MODULE__{data: term(), event: String.t() | nil, id: String.t() | nil}
855end
856
857defmodule Spikard.Sse.Producer do
858  @callback init(term()) :: {:ok, term()} | {:error, term()}
859  @callback next_event(term()) ::
860              {:ok, Spikard.Sse.Event.t(), term()} | :done | :error
861
862  defmacro __using__(_opts) do
863    quote do
864      @behaviour Spikard.Sse.Producer
865
866      @impl true
867      def init(_opts), do: {:ok, nil}
868
869      defoverridable init: 1
870    end
871  end
872end
873"#,
874        )
875        .map_err(|e| QualityError::IoError(e.to_string()))?;
876        fs::write(&generated_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
877
878        Ok(ElixirTempProject {
879            workdir,
880            generated_path,
881        })
882    }
883
884    fn write_php_validation_bootstrap(&self) -> Result<NamedTempFile, QualityError> {
885        self.write_temp_file(
886            r#"<?php
887declare(strict_types=1);
888
889namespace SpikardGenerated;
890
891#[\Attribute(\Attribute::TARGET_METHOD)]
892final class Route
893{
894    public function __construct(
895        public string $path,
896        public array $methods = [],
897    ) {}
898}
899
900namespace Spikard\Handlers;
901
902interface WebSocketHandlerInterface
903{
904    public function onConnect(): void;
905
906    public function onMessage(string $message): void;
907
908    public function onClose(int $code, ?string $reason = null): void;
909}
910
911interface SseEventProducerInterface
912{
913    /** @return \Generator<int, string, mixed, void> */
914    public function __invoke(): \Generator;
915}
916
917namespace Spikard;
918
919final class App
920{
921    public function addWebSocket(
922        string $path,
923        \Spikard\Handlers\WebSocketHandlerInterface $handler
924    ): self {
925        return $this;
926    }
927
928    public function addSse(
929        string $path,
930        \Spikard\Handlers\SseEventProducerInterface $producer
931    ): self {
932        return $this;
933    }
934}
935
936namespace Google\Protobuf\Internal;
937
938class Message {}
939
940namespace GraphQL\Type\Definition;
941
942class Type
943{
944    public static function string(): self
945    {
946        return new self();
947    }
948
949    public static function int(): self
950    {
951        return new self();
952    }
953
954    public static function float(): self
955    {
956        return new self();
957    }
958
959    public static function boolean(): self
960    {
961        return new self();
962    }
963
964    public static function id(): self
965    {
966        return new self();
967    }
968
969    public static function nonNull(self $type): self
970    {
971        return $type;
972    }
973
974    public static function listOf(self $type): self
975    {
976        return $type;
977    }
978}
979
980class ObjectType extends Type
981{
982    /** @param array<string, mixed> $config */
983    public function __construct(array $config = [])
984    {
985    }
986}
987
988class InputObjectType extends Type
989{
990    /** @param array<string, mixed> $config */
991    public function __construct(array $config = [])
992    {
993    }
994}
995
996class InterfaceType extends Type
997{
998    /** @param array<string, mixed> $config */
999    public function __construct(array $config = [])
1000    {
1001    }
1002}
1003
1004class UnionType extends Type
1005{
1006    /** @param array<string, mixed> $config */
1007    public function __construct(array $config = [])
1008    {
1009    }
1010}
1011
1012class EnumType extends Type
1013{
1014    /** @param array<string, mixed> $config */
1015    public function __construct(array $config = [])
1016    {
1017    }
1018}
1019
1020namespace GraphQL\Type;
1021
1022class Schema
1023{
1024    /** @param array<string, mixed> $config */
1025    public function __construct(array $config = [])
1026    {
1027    }
1028}
1029"#,
1030            "php",
1031        )
1032    }
1033
1034    /// Executes a validation tool and captures its output
1035    ///
1036    /// This method runs an external command with the given arguments and interprets
1037    /// the exit code. A zero exit code indicates success; non-zero indicates failure.
1038    /// Both stdout and stderr are captured and included in error messages.
1039    ///
1040    /// # Arguments
1041    ///
1042    /// * `tool` - The executable name (resolved from PATH)
1043    /// * `args` - Command-line arguments
1044    /// * `code` - The original code (for error context)
1045    ///
1046    /// # Returns
1047    ///
1048    /// - `Ok(output)` - The tool's stdout if successful
1049    /// - `Err(QualityError::ToolNotFound)` - If the tool is not found in PATH
1050    /// - `Err(QualityError::ValidationFailed)` - If the tool exits with non-zero status
1051    /// - `Err(QualityError::IoError)` - If execution fails
1052    fn run_tool(&self, tool: &str, args: &[&str], _code: &str) -> Result<String, QualityError> {
1053        self.run_tool_in_dir(tool, args, Path::new("."), _code)
1054    }
1055
1056    fn run_tool_in_dir(&self, tool: &str, args: &[&str], cwd: &Path, _code: &str) -> Result<String, QualityError> {
1057        self.run_tool_in_dir_with_env(tool, args, cwd, &[], _code)
1058    }
1059
1060    fn run_tool_in_dir_with_env(
1061        &self,
1062        tool: &str,
1063        args: &[&str],
1064        cwd: &Path,
1065        envs: &[(&str, &std::ffi::OsStr)],
1066        _code: &str,
1067    ) -> Result<String, QualityError> {
1068        // mypy 2.x has a known nondeterministic INTERNAL ERROR crash. Retry up to
1069        // 3 times when we detect the signature; surface all other failures
1070        // immediately.
1071        let max_attempts = 3;
1072        let mut last_err: Option<QualityError> = None;
1073        for _ in 0..max_attempts {
1074            let mut command = Command::new(tool);
1075            command.args(args).current_dir(cwd);
1076            for (key, value) in envs {
1077                command.env(key, value);
1078            }
1079
1080            let output = command.output().map_err(|e| {
1081                if e.kind() == std::io::ErrorKind::NotFound {
1082                    QualityError::ToolNotFound(tool.to_string())
1083                } else {
1084                    QualityError::IoError(e.to_string())
1085                }
1086            })?;
1087
1088            if output.status.success() {
1089                return Ok(String::from_utf8_lossy(&output.stdout).to_string());
1090            }
1091
1092            let stderr = String::from_utf8_lossy(&output.stderr);
1093            let stdout = String::from_utf8_lossy(&output.stdout);
1094            let message = if stderr.is_empty() {
1095                stdout.to_string()
1096            } else {
1097                stderr.to_string()
1098            };
1099            let is_mypy_internal_error = message.contains("INTERNAL ERROR");
1100            last_err = Some(QualityError::ValidationFailed(message));
1101            if !is_mypy_internal_error {
1102                break;
1103            }
1104        }
1105        Err(last_err.expect("at least one attempt always runs"))
1106    }
1107}
1108
1109struct RustTempProject {
1110    workdir: TempDir,
1111    manifest_path: PathBuf,
1112}
1113
1114struct TypeScriptTempProject {
1115    // _workdir holds the TempDir alive for RAII cleanup; path is not read directly.
1116    _workdir: TempDir,
1117    config_path: PathBuf,
1118}
1119
1120struct PythonTempProject {
1121    workdir: TempDir,
1122    entry_path: PathBuf,
1123    stub_path: PathBuf,
1124}
1125
1126struct ElixirTempProject {
1127    workdir: TempDir,
1128    generated_path: PathBuf,
1129}
1130
1131fn write_python_validation_stubs(root: &Path) -> Result<(), QualityError> {
1132    write_stub_file(
1133        &root.join("msgspec.py"),
1134        r#"from typing import Any, TypeVar, cast
1135
1136T = TypeVar("T")
1137
1138class Struct:
1139    def __init__(self, **kwargs: object) -> None: ...
1140
1141    def __init_subclass__(cls, *, frozen: bool = False, kw_only: bool = False) -> None: ...
1142
1143def field(*, default: object = ..., name: str | None = None) -> Any: ...
1144
1145def convert(value: object, *, type: object) -> Any:
1146    return value
1147
1148def to_builtins(value: object) -> object: ...
1149"#,
1150    )?;
1151
1152    write_stub_file(&root.join("graphql.py"), "class GraphQLResolveInfo: ...\n")?;
1153
1154    write_stub_file(
1155        &root.join("ariadne.py"),
1156        r#"from __future__ import annotations
1157
1158from collections.abc import Callable
1159from typing import Any
1160
1161Resolver = Callable[..., Any]
1162
1163class QueryType:
1164    def set_field(self, _name: str, _resolver: Resolver) -> None: ...
1165
1166class MutationType:
1167    def set_field(self, _name: str, _resolver: Resolver) -> None: ...
1168
1169class SubscriptionType:
1170    def set_field(self, _name: str, _resolver: Resolver) -> None: ...
1171    def set_source(self, _name: str, _resolver: Resolver) -> None: ...
1172
1173def make_executable_schema(*_args: object, **_kwargs: object) -> object:
1174    return object()
1175"#,
1176    )?;
1177
1178    let spikard_dir = root.join("spikard");
1179    fs::create_dir_all(&spikard_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
1180    write_stub_file(
1181        &spikard_dir.join("__init__.py"),
1182        r#"from __future__ import annotations
1183
1184from collections.abc import Callable
1185from typing import Generic, TypeVar
1186
1187F = TypeVar("F", bound=Callable[..., object])
1188T = TypeVar("T")
1189
1190class Body(Generic[T]): ...
1191
1192class Path(Generic[T]): ...
1193
1194class Query(Generic[T]):
1195    def __init__(self, default: T | None = None) -> None:
1196        self.default = default
1197
1198class Request: ...
1199
1200class Spikard:
1201    def route(self, *_args: object, **_kwargs: object) -> Callable[[F], F]:
1202        def decorator(fn: F) -> F:
1203            return fn
1204        return decorator
1205
1206    def post(self, *_args: object, **_kwargs: object) -> Callable[[F], F]:
1207        def decorator(fn: F) -> F:
1208            return fn
1209        return decorator
1210
1211    def get(self, *_args: object, **_kwargs: object) -> Callable[[F], F]:
1212        def decorator(fn: F) -> F:
1213            return fn
1214        return decorator
1215
1216    def run(self, *_args: object, **_kwargs: object) -> None:
1217        return None
1218
1219def route(*_args: object, **_kwargs: object) -> Callable[[F], F]:
1220    def decorator(fn: F) -> F:
1221        return fn
1222    return decorator
1223
1224def websocket(*_args: object, **_kwargs: object) -> Callable[[F], F]:
1225    def decorator(fn: F) -> F:
1226        return fn
1227    return decorator
1228
1229def sse(*_args: object, **_kwargs: object) -> Callable[[F], F]:
1230    def decorator(fn: F) -> F:
1231        return fn
1232    return decorator
1233"#,
1234    )?;
1235    write_stub_file(
1236        &spikard_dir.join("config.py"),
1237        r#"class ServerConfig:
1238    def __init__(self, host: str = "0.0.0.0", port: int = 8000) -> None:
1239        self.host = host
1240        self.port = port
1241"#,
1242    )?;
1243
1244    let google_protobuf_dir = root.join("google").join("protobuf");
1245    fs::create_dir_all(&google_protobuf_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
1246    write_stub_file(&root.join("google").join("__init__.py"), "")?;
1247    write_stub_file(&google_protobuf_dir.join("__init__.py"), "")?;
1248    write_stub_file(&google_protobuf_dir.join("message.py"), "class Message: ...\n")?;
1249
1250    let websockets_dir = root.join("websockets");
1251    fs::create_dir_all(&websockets_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
1252    write_stub_file(&websockets_dir.join("__init__.py"), "")?;
1253    write_stub_file(
1254        &websockets_dir.join("client.py"),
1255        "class WebSocketClientProtocol: ...\n",
1256    )?;
1257
1258    Ok(())
1259}
1260
1261fn write_stub_file(path: &Path, contents: &str) -> Result<(), QualityError> {
1262    if let Some(parent) = path.parent() {
1263        fs::create_dir_all(parent).map_err(|e| QualityError::IoError(e.to_string()))?;
1264    }
1265    fs::write(path, contents).map_err(|e| QualityError::IoError(e.to_string()))
1266}
1267
1268fn rust_temp_manifest() -> String {
1269    let spikard_path = workspace_root().join("crates/spikard");
1270
1271    format!(
1272        r#"[package]
1273name = "spikard_codegen_validation"
1274version = "0.1.0"
1275edition = "2024"
1276
1277[lib]
1278path = "src/lib.rs"
1279
1280[dependencies]
1281async-graphql = "7"
1282async-trait = "0.1"
1283axum = "0.8"
1284bytes = "1"
1285chrono = {{ version = "0.4", features = ["serde"] }}
1286futures-core = "0.3"
1287futures-util = "0.3"
1288prost = "0.14"
1289schemars = {{ version = "1.2", features = ["derive", "chrono04", "uuid1"] }}
1290serde = {{ version = "1", features = ["derive"] }}
1291serde_json = "1"
1292spikard = {{ path = "{}" }}
1293tokio = {{ version = "1", features = ["full"] }}
1294tonic = "0.14"
1295uuid = {{ version = "1", features = ["serde", "v4"] }}
1296"#,
1297        spikard_path.display()
1298    )
1299}
1300
1301fn workspace_root() -> &'static Path {
1302    Path::new(env!("CARGO_MANIFEST_DIR"))
1303        .parent()
1304        .and_then(Path::parent)
1305        .expect("workspace root should be two levels above crates/spikard-cli")
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310    use super::*;
1311
1312    #[test]
1313    fn test_validation_report_is_valid() {
1314        let mut report = ValidationReport::new();
1315        assert!(!report.is_valid());
1316
1317        report.syntax_passed = true;
1318        report.types_passed = true;
1319        report.lint_passed = true;
1320        assert!(report.is_valid());
1321
1322        report.add_error("test error".to_string());
1323        assert!(!report.is_valid());
1324    }
1325
1326    #[test]
1327    fn test_validation_report_error_count() {
1328        let mut report = ValidationReport::new();
1329        assert_eq!(report.error_count(), 0);
1330
1331        report.add_error("error 1".to_string());
1332        report.add_error("error 2".to_string());
1333        assert_eq!(report.error_count(), 2);
1334    }
1335
1336    #[test]
1337    fn test_quality_validator_creation() {
1338        let validator = QualityValidator::new(TargetLanguage::Python);
1339        assert_eq!(validator.language, TargetLanguage::Python);
1340
1341        let validator = QualityValidator::new(TargetLanguage::TypeScript);
1342        assert_eq!(validator.language, TargetLanguage::TypeScript);
1343
1344        let validator = QualityValidator::new(TargetLanguage::Elixir);
1345        assert_eq!(validator.language, TargetLanguage::Elixir);
1346    }
1347
1348    #[test]
1349    fn test_quality_error_display() {
1350        let err = QualityError::ToolNotFound("mypy".to_string());
1351        assert_eq!(err.to_string(), "Required validation tool not found: mypy");
1352
1353        let err = QualityError::ValidationFailed("syntax error".to_string());
1354        assert!(err.to_string().contains("Validation failed"));
1355
1356        let err = QualityError::IoError("file not found".to_string());
1357        assert!(err.to_string().contains("I/O error"));
1358    }
1359
1360    #[test]
1361    fn test_validation_report_display() {
1362        let mut report = ValidationReport::new();
1363        report.syntax_passed = true;
1364        report.types_passed = false;
1365        report.add_error("type mismatch".to_string());
1366
1367        let display = report.to_string();
1368        assert!(display.contains("Syntax: PASS"));
1369        assert!(display.contains("Types:  FAIL"));
1370        assert!(display.contains("type mismatch"));
1371    }
1372
1373    #[test]
1374    fn test_rust_quality_validator_accepts_valid_code() {
1375        let validator = QualityValidator::new(TargetLanguage::Rust);
1376        validator
1377            .validate_syntax("pub fn add(a: i32, b: i32) -> i32 { a + b }")
1378            .expect("rust syntax validation should pass");
1379        validator
1380            .validate_types("pub fn add(a: i32, b: i32) -> i32 { a + b }")
1381            .expect("rust type validation should pass");
1382    }
1383}