Skip to main content

openapi_to_rust/
config.rs

1//! TOML configuration file support for OpenAPI code generation.
2//!
3//! This module provides TOML-based configuration as an alternative to the Rust API.
4//! It enables CLI-based code generation without requiring the generator as a build dependency.
5//!
6//! # Overview
7//!
8//! The TOML configuration system provides:
9//! - Declarative configuration in `openapi-to-rust.toml` files
10//! - Comprehensive validation with helpful error messages
11//! - Support for all generator features (HTTP client, retry, tracing, Specta)
12//! - Conversion to internal [`GeneratorConfig`] for code generation
13//!
14//! # Quick Start
15//!
16//! Create an `openapi-to-rust.toml` file:
17//!
18//! ```toml
19//! [generator]
20//! spec_path = "openapi.json"
21//! output_dir = "src/generated"
22//! module_name = "api"
23//!
24//! [features]
25//! enable_async_client = true
26//!
27//! [http_client]
28//! base_url = "https://api.example.com"
29//! timeout_seconds = 30
30//! max_response_body_bytes = 8388608
31//!
32//! [http_client.retry]
33//! max_retries = 3
34//! initial_delay_ms = 500
35//! max_delay_ms = 16000
36//! ```
37//!
38//! Load and use the configuration:
39//!
40//! ```no_run
41//! use openapi_to_rust::config::ConfigFile;
42//! use std::path::Path;
43//!
44//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
45//! // Load configuration from TOML file
46//! let config_file = ConfigFile::load(Path::new("openapi-to-rust.toml"))?;
47//!
48//! // Convert to internal GeneratorConfig
49//! let generator_config = config_file.into_generator_config();
50//!
51//! // Use with CodeGenerator...
52//! # Ok(())
53//! # }
54//! ```
55//!
56//! # Configuration Sections
57//!
58//! ## Generator Section (Required)
59//!
60//! ```toml
61//! [generator]
62//! spec_path = "openapi.json"       # Path to OpenAPI spec
63//! output_dir = "src/generated"     # Output directory
64//! module_name = "api"              # Module name
65//! ```
66//!
67//! ## Features Section (Optional)
68//!
69//! ```toml
70//! [features]
71//! enable_sse_client = true         # Generate SSE streaming client
72//! enable_async_client = true       # Generate HTTP REST client
73//! enable_specta = false            # Add specta::Type derives
74//! ```
75//!
76//! ## HTTP Client Section (Optional)
77//!
78//! ```toml
79//! [http_client]
80//! base_url = "https://api.example.com"
81//! timeout_seconds = 30
82//! max_response_body_bytes = 8388608 # 8 MiB default
83//!
84//! [http_client.retry]
85//! max_retries = 3                  # 0-10 retries
86//! initial_delay_ms = 500           # 100-10000ms
87//! max_delay_ms = 16000             # 1000-300000ms
88//!
89//! [http_client.tracing]
90//! enabled = true                   # Enable request tracing (default: true)
91//!
92//! [http_client.auth]
93//! type = "Bearer"                  # Bearer, ApiKey, or Custom
94//! header_name = "Authorization"
95//!
96//! [[http_client.headers]]
97//! name = "content-type"
98//! value = "application/json"
99//! ```
100//!
101//! ## Client Selection Section (Optional)
102//!
103//! ```toml
104//! [client]
105//! # Shared selector grammar: operationId | "METHOD /path" | "tag:<name>"
106//! operations = ["createResponse", "GET /models", "tag:Files"]
107//! prune_models = true
108//! ```
109//!
110//! Omitting `[client]`, or leaving `operations` empty, generates every HTTP
111//! client operation. When pruning is enabled, model reachability is the union
112//! of selected client and server operations.
113//!
114//! # Validation
115//!
116//! The configuration is validated on load:
117//! - The input specification path is checked for existence
118//! - Numeric ranges are enforced (timeout, retry counts, delays)
119//! - Enum values are validated (auth types, event flow types)
120//! - Required fields are checked
121//! - Relative generator paths are resolved from the configuration file's directory
122//!
123//! Invalid configurations produce helpful error messages:
124//!
125//! ```text
126//! Configuration validation failed:
127//!   - generator.spec_path: OpenAPI spec file not found: missing.json
128//!   - http_client.retry.max_retries: max_retries must be between 0 and 10
129//! ```
130//!
131//! # Examples
132//!
133//! See the [examples](https://github.com/gpu-cli/openapi-to-rust/tree/main/examples) directory for complete examples:
134//! - `toml_config_example.rs` - Various configuration patterns
135//! - `complete_workflow.rs` - Full generation workflow with TOML
136//!
137//! # Backward Compatibility
138//!
139//! The TOML configuration is fully optional. The existing Rust API continues to work:
140//!
141//! ```no_run
142//! use openapi_to_rust::{GeneratorConfig, CodeGenerator};
143//! use std::path::PathBuf;
144//!
145//! let config = GeneratorConfig {
146//!     spec_path: PathBuf::from("openapi.json"),
147//!     enable_async_client: true,
148//!     // ... other fields
149//!     ..Default::default()
150//! };
151//!
152//! let generator = CodeGenerator::new(config);
153//! // ... generate code
154//! ```
155
156use crate::{GeneratorError, generator::GeneratorConfig};
157use serde::{Deserialize, Serialize};
158use std::collections::BTreeMap;
159use std::path::{Path, PathBuf};
160
161/// Root configuration loaded from TOML file
162#[derive(Debug, Clone)]
163pub struct ConfigFile {
164    pub generator: GeneratorSection,
165    pub features: FeaturesSection,
166    pub http_client: Option<HttpClientSection>,
167    pub streaming: Option<StreamingSection>,
168    /// Server codegen opt-in. Absent or empty operations list ⇒ no
169    /// server code emitted. See `docs/planning/server-codegen.md`.
170    pub server: Option<ServerSection>,
171    /// Optional HTTP-client operation scope. Absent means all operations.
172    pub client: Option<ClientSection>,
173    pub nullable_overrides: BTreeMap<String, bool>,
174    /// Force a closed string-enum schema to be rendered as an extensible enum
175    /// (with a `Custom(String)` fallback variant). Use when the spec under-
176    /// declares the enum and the API returns values outside the declared set.
177    /// Format: `"SchemaName" = true`. Mirror of `nullable_overrides`.
178    pub extensible_enums: BTreeMap<String, bool>,
179    pub type_mappings: BTreeMap<String, String>,
180    /// Normalized type-mapping configuration.
181    ///
182    /// TOML deserialization accepts canonical `[generator.types]` and the
183    /// temporary top-level `[types]` compatibility alias. Serialization always
184    /// writes this value back in the canonical nested location.
185    pub types: crate::type_mapping::TypeMappingConfig,
186}
187
188#[derive(Debug, Clone, Deserialize, Serialize)]
189#[serde(deny_unknown_fields)]
190pub struct GeneratorSection {
191    /// OpenAPI input path or HTTPS URL. Relative filesystem paths are resolved
192    /// from the directory containing the configuration file.
193    pub spec_path: PathBuf,
194    /// Generated-code destination. Relative paths are resolved from the
195    /// directory containing the configuration file; it need not exist yet.
196    pub output_dir: PathBuf,
197    /// Informational label, not a directory or module path. The
198    /// generator writes the same files (mod.rs, types.rs, server/*)
199    /// regardless of this value. It shows up only in the generated
200    /// mod.rs header doc comment as a hint and is used by the
201    /// streaming codegen for naming the SSE client module. You
202    /// mount the tree at whatever Rust module path you prefer.
203    pub module_name: String,
204    /// Schema extension files to merge into the main spec before codegen.
205    /// Relative paths are resolved from the configuration file's directory.
206    #[serde(default)]
207    pub schema_extensions: Vec<PathBuf>,
208    /// Ordered OpenAPI Overlay 1.1 files, relative to the configuration file.
209    #[serde(default)]
210    pub overlays: Vec<PathBuf>,
211    /// Optional JSON effective document artifact, relative to output_dir.
212    #[serde(default)]
213    pub effective_spec: Option<PathBuf>,
214    /// Optional binding metadata artifact, relative to output_dir.
215    #[serde(default)]
216    pub bindings_metadata: Option<String>,
217    /// Additive operation-builder generation policy.
218    #[serde(default)]
219    pub builders: BuildersSection,
220}
221
222/// Configuration for additive `*_builder()` operation entry points.
223#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
224#[serde(default, deny_unknown_fields)]
225pub struct BuildersSection {
226    /// Generate builders for operations above [`Self::threshold`].
227    pub enabled: bool,
228    /// Minimum optional-value count. Builders are emitted only when an
229    /// operation has more optional values than this threshold.
230    pub threshold: usize,
231}
232
233impl Default for BuildersSection {
234    fn default() -> Self {
235        Self {
236            enabled: false,
237            threshold: 3,
238        }
239    }
240}
241
242#[derive(Debug, Clone, Deserialize, Serialize)]
243#[serde(deny_unknown_fields)]
244pub struct FeaturesSection {
245    #[serde(default)]
246    pub enable_sse_client: bool,
247    #[serde(default)]
248    pub enable_async_client: bool,
249    #[serde(default)]
250    pub enable_specta: bool,
251    /// Generate a static operation registry with metadata for CLI/proxy routing
252    #[serde(default)]
253    pub enable_registry: bool,
254    /// Generate only the operation registry (skip types, client, streaming)
255    #[serde(default)]
256    pub registry_only: bool,
257}
258
259/// Opt-in server codegen scope.
260///
261/// `operations` accepts three selector forms, parsed by
262/// [`crate::server::Selector::parse`]:
263///   - `operationId` (recommended)
264///   - `METHOD /path`
265///   - `tag:<name>`
266#[derive(Debug, Clone, Deserialize, Serialize)]
267#[serde(deny_unknown_fields)]
268pub struct ServerSection {
269    /// Target framework. Only `"axum"` is currently supported.
270    pub framework: String,
271    /// Selectors picking which operations get server scaffolding.
272    /// Empty ⇒ section is a no-op.
273    #[serde(default)]
274    pub operations: Vec<String>,
275    /// Emit only the model types reachable (transitively) from all selected
276    /// output scopes. When client and server generation coexist, pruning keeps
277    /// the union of both operation sets plus configured streaming event roots.
278    #[serde(default)]
279    pub prune_models: bool,
280    /// Runtime request validation policy for generated server scaffolding.
281    #[serde(default)]
282    pub validation: ServerValidationSection,
283}
284
285/// Limits and policy for generated request validation.
286#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
287#[serde(default, deny_unknown_fields)]
288pub struct ServerValidationSection {
289    /// Validate request inputs against their OpenAPI schemas.
290    pub enabled: bool,
291    /// Maximum request-body bytes accepted before buffering is rejected.
292    pub max_body_bytes: usize,
293    /// Maximum number of normalized violations returned to a client.
294    pub max_errors: usize,
295}
296
297impl Default for ServerValidationSection {
298    fn default() -> Self {
299        Self {
300            enabled: true,
301            max_body_bytes: 2_097_152,
302            max_errors: 16,
303        }
304    }
305}
306
307/// Optional scope for generated HTTP-client operations.
308///
309/// Selectors use the same grammar as [`ServerSection::operations`]. An absent
310/// section, or an empty `operations` list, preserves the historical behavior
311/// of generating every operation. The section is ignored when the async HTTP
312/// client is disabled and never filters the operation registry. Set
313/// `prune_models = true` to also remove models outside the combined selected
314/// client/server operation closure.
315#[derive(Debug, Clone, Deserialize, Serialize)]
316#[serde(deny_unknown_fields)]
317pub struct ClientSection {
318    /// Selectors picking client methods: `operationId`, `METHOD /path`, or
319    /// `tag:<name>`. Empty means all operations.
320    #[serde(default)]
321    pub operations: Vec<String>,
322    /// Restrict `types.rs` to models reachable from the selected client
323    /// operations plus any selected server operations.
324    #[serde(default)]
325    pub prune_models: bool,
326}
327
328impl ClientSection {
329    /// Parse configured selectors using the shared client/server grammar.
330    pub fn parsed_selectors(
331        &self,
332    ) -> Result<Vec<crate::server::Selector>, crate::server::SelectorParseError> {
333        self.operations
334            .iter()
335            .map(|s| crate::server::Selector::parse(s))
336            .collect()
337    }
338}
339
340impl ServerSection {
341    /// Parse each `operations` entry into a [`crate::server::Selector`].
342    /// Returns the first parse error encountered.
343    pub fn parsed_selectors(
344        &self,
345    ) -> Result<Vec<crate::server::Selector>, crate::server::SelectorParseError> {
346        self.operations
347            .iter()
348            .map(|s| crate::server::Selector::parse(s))
349            .collect()
350    }
351}
352
353#[derive(Debug, Clone, Deserialize, Serialize)]
354#[serde(deny_unknown_fields)]
355pub struct HttpClientSection {
356    pub base_url: Option<String>,
357    pub timeout_seconds: Option<u64>,
358    pub max_response_body_bytes: Option<usize>,
359    pub auth: Option<AuthConfigSection>,
360    #[serde(default)]
361    pub headers: Vec<HeaderEntry>,
362    pub retry: Option<RetryConfigSection>,
363    pub tracing: Option<TracingConfigSection>,
364}
365
366#[derive(Debug, Clone, Deserialize, Serialize)]
367#[serde(deny_unknown_fields)]
368pub struct TracingConfigSection {
369    #[serde(default = "default_tracing_enabled")]
370    pub enabled: bool,
371}
372
373fn default_tracing_enabled() -> bool {
374    true
375}
376
377#[derive(Debug, Clone, Deserialize, Serialize)]
378#[serde(deny_unknown_fields)]
379pub struct RetryConfigSection {
380    #[serde(default = "default_max_retries")]
381    pub max_retries: u32,
382    #[serde(default = "default_initial_delay_ms")]
383    pub initial_delay_ms: u64,
384    #[serde(default = "default_max_delay_ms")]
385    pub max_delay_ms: u64,
386}
387
388fn default_max_retries() -> u32 {
389    3
390}
391fn default_initial_delay_ms() -> u64 {
392    500
393}
394fn default_max_delay_ms() -> u64 {
395    16000
396}
397
398#[derive(Debug, Clone, Deserialize, Serialize)]
399#[serde(deny_unknown_fields)]
400pub struct AuthConfigSection {
401    #[serde(rename = "type")]
402    pub auth_type: String,
403    pub header_name: String,
404}
405
406#[derive(Debug, Clone, Deserialize, Serialize)]
407#[serde(deny_unknown_fields)]
408pub struct HeaderEntry {
409    pub name: String,
410    pub value: String,
411}
412
413#[derive(Debug, Clone, Deserialize, Serialize)]
414#[serde(deny_unknown_fields)]
415pub struct StreamingSection {
416    pub endpoints: Vec<StreamingEndpointSection>,
417}
418
419#[derive(Debug, Clone, Deserialize, Serialize)]
420#[serde(deny_unknown_fields)]
421pub struct StreamingEndpointSection {
422    pub operation_id: String,
423    pub path: String,
424    /// HTTP method: "GET" or "POST" (default: POST)
425    #[serde(default)]
426    pub http_method: Option<String>,
427    /// Parameter name that controls streaming (only for POST requests)
428    #[serde(default)]
429    pub stream_parameter: String,
430    /// Query parameters for GET requests
431    #[serde(default)]
432    pub query_parameters: Vec<QueryParameterSection>,
433    pub event_union_type: String,
434    pub content_type: Option<String>,
435    pub event_flow: Option<EventFlowSection>,
436}
437
438#[derive(Debug, Clone, Deserialize, Serialize)]
439#[serde(deny_unknown_fields)]
440pub struct QueryParameterSection {
441    pub name: String,
442    #[serde(default)]
443    pub required: bool,
444}
445
446#[derive(Debug, Clone, Deserialize, Serialize)]
447#[serde(deny_unknown_fields)]
448pub struct EventFlowSection {
449    #[serde(rename = "type")]
450    pub flow_type: String,
451    pub start_events: Option<Vec<String>>,
452    pub delta_events: Option<Vec<String>>,
453    pub stop_events: Option<Vec<String>>,
454}
455
456/// Serde-only representation of the TOML contract. Keeping this separate from
457/// the public Rust API lets TOML use canonical `[generator.types]` while
458/// preserving the long-standing normalized [`ConfigFile::types`] field.
459#[derive(Deserialize)]
460#[serde(deny_unknown_fields)]
461struct ConfigFileWire {
462    generator: GeneratorSectionWire,
463    features: FeaturesSection,
464    #[serde(default)]
465    http_client: Option<HttpClientSection>,
466    #[serde(default)]
467    streaming: Option<StreamingSection>,
468    #[serde(default)]
469    server: Option<ServerSection>,
470    #[serde(default)]
471    client: Option<ClientSection>,
472    #[serde(default)]
473    nullable_overrides: BTreeMap<String, bool>,
474    #[serde(default)]
475    extensible_enums: BTreeMap<String, bool>,
476    #[serde(default)]
477    type_mappings: BTreeMap<String, String>,
478    #[serde(default)]
479    types: Option<crate::type_mapping::TypeMappingConfig>,
480}
481
482#[derive(Deserialize)]
483#[serde(deny_unknown_fields)]
484struct GeneratorSectionWire {
485    spec_path: PathBuf,
486    output_dir: PathBuf,
487    module_name: String,
488    #[serde(default)]
489    schema_extensions: Vec<PathBuf>,
490    #[serde(default)]
491    overlays: Vec<PathBuf>,
492    #[serde(default)]
493    effective_spec: Option<PathBuf>,
494    #[serde(default)]
495    bindings_metadata: Option<String>,
496    #[serde(default)]
497    builders: BuildersSection,
498    #[serde(default)]
499    types: Option<crate::type_mapping::TypeMappingConfig>,
500}
501
502impl TryFrom<ConfigFileWire> for ConfigFile {
503    type Error = String;
504
505    fn try_from(wire: ConfigFileWire) -> Result<Self, Self::Error> {
506        let types = match (wire.generator.types, wire.types) {
507            (Some(_), Some(_)) => {
508                return Err(
509                    "Configuration contains both legacy [types] and canonical [generator.types]. Remove [types] and keep [generator.types]."
510                        .to_string(),
511                );
512            }
513            (Some(types), None) | (None, Some(types)) => types,
514            (None, None) => crate::type_mapping::TypeMappingConfig::default(),
515        };
516
517        Ok(Self {
518            generator: GeneratorSection {
519                spec_path: wire.generator.spec_path,
520                output_dir: wire.generator.output_dir,
521                module_name: wire.generator.module_name,
522                schema_extensions: wire.generator.schema_extensions,
523                overlays: wire.generator.overlays,
524                effective_spec: wire.generator.effective_spec,
525                bindings_metadata: wire.generator.bindings_metadata,
526                builders: wire.generator.builders,
527            },
528            features: wire.features,
529            http_client: wire.http_client,
530            streaming: wire.streaming,
531            server: wire.server,
532            client: wire.client,
533            nullable_overrides: wire.nullable_overrides,
534            extensible_enums: wire.extensible_enums,
535            type_mappings: wire.type_mappings,
536            types,
537        })
538    }
539}
540
541impl<'de> Deserialize<'de> for ConfigFile {
542    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
543    where
544        D: serde::Deserializer<'de>,
545    {
546        ConfigFileWire::deserialize(deserializer)?
547            .try_into()
548            .map_err(serde::de::Error::custom)
549    }
550}
551
552#[derive(Serialize)]
553struct ConfigFileRef<'a> {
554    generator: GeneratorSectionRef<'a>,
555    features: &'a FeaturesSection,
556    #[serde(skip_serializing_if = "Option::is_none")]
557    http_client: Option<&'a HttpClientSection>,
558    #[serde(skip_serializing_if = "Option::is_none")]
559    streaming: Option<&'a StreamingSection>,
560    #[serde(skip_serializing_if = "Option::is_none")]
561    server: Option<&'a ServerSection>,
562    #[serde(skip_serializing_if = "Option::is_none")]
563    client: Option<&'a ClientSection>,
564    nullable_overrides: &'a BTreeMap<String, bool>,
565    extensible_enums: &'a BTreeMap<String, bool>,
566    type_mappings: &'a BTreeMap<String, String>,
567}
568
569#[derive(Serialize)]
570struct GeneratorSectionRef<'a> {
571    spec_path: &'a Path,
572    output_dir: &'a Path,
573    module_name: &'a str,
574    schema_extensions: &'a [PathBuf],
575    overlays: &'a [PathBuf],
576    #[serde(skip_serializing_if = "Option::is_none")]
577    effective_spec: Option<&'a Path>,
578    #[serde(skip_serializing_if = "Option::is_none")]
579    bindings_metadata: Option<&'a str>,
580    builders: &'a BuildersSection,
581    types: &'a crate::type_mapping::TypeMappingConfig,
582}
583
584impl Serialize for ConfigFile {
585    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
586    where
587        S: serde::Serializer,
588    {
589        ConfigFileRef {
590            generator: GeneratorSectionRef {
591                spec_path: &self.generator.spec_path,
592                output_dir: &self.generator.output_dir,
593                module_name: &self.generator.module_name,
594                schema_extensions: &self.generator.schema_extensions,
595                overlays: &self.generator.overlays,
596                effective_spec: self.generator.effective_spec.as_deref(),
597                bindings_metadata: self.generator.bindings_metadata.as_deref(),
598                builders: &self.generator.builders,
599                types: &self.types,
600            },
601            features: &self.features,
602            http_client: self.http_client.as_ref(),
603            streaming: self.streaming.as_ref(),
604            server: self.server.as_ref(),
605            client: self.client.as_ref(),
606            nullable_overrides: &self.nullable_overrides,
607            extensible_enums: &self.extensible_enums,
608            type_mappings: &self.type_mappings,
609        }
610        .serialize(serializer)
611    }
612}
613
614fn resolve_relative_path(config_dir: &Path, path: &mut PathBuf) {
615    if path.is_relative() && !path.to_string_lossy().contains("://") {
616        *path = config_dir.join(&*path);
617    }
618}
619
620fn inspect_type_config_layout(value: &toml::Value) -> Result<(), GeneratorError> {
621    let legacy_types = value.get("types").is_some();
622    let canonical_types = value
623        .get("generator")
624        .and_then(|generator| generator.get("types"));
625
626    if legacy_types && canonical_types.is_some() {
627        return Err(GeneratorError::ValidationError(
628            "Configuration contains both legacy [types] and canonical [generator.types]. Remove [types] and keep [generator.types]."
629                .to_string(),
630        ));
631    }
632
633    if canonical_types
634        .and_then(|types| types.get("strategies"))
635        .is_some()
636    {
637        return Err(GeneratorError::ValidationError(
638            "[generator.types.strategies] is obsolete. Move its fields directly under [generator.types]. Use snake_case keys such as date_time (not date-time); valid byte values are string, base64, base64_url_unpadded, and vec_u8 (for example: byte = \"base64\")."
639                .to_string(),
640        ));
641    }
642
643    Ok(())
644}
645
646impl ConfigFile {
647    /// Load and validate configuration from a TOML file.
648    ///
649    /// Relative `spec_path`, `output_dir`, `schema_extensions`, and `overlays` values are
650    /// resolved against the directory containing `path`, independent of the
651    /// process's current working directory.
652    pub fn load(path: &Path) -> Result<Self, GeneratorError> {
653        let config_path = path.canonicalize().map_err(|e| GeneratorError::FileError {
654            message: format!("Failed to resolve config file '{}': {}", path.display(), e),
655        })?;
656        let config_dir = config_path
657            .parent()
658            .ok_or_else(|| GeneratorError::FileError {
659                message: format!(
660                    "Config file '{}' has no parent directory",
661                    config_path.display()
662                ),
663            })?;
664        let content =
665            std::fs::read_to_string(&config_path).map_err(|e| GeneratorError::FileError {
666                message: format!(
667                    "Failed to read config file '{}': {}",
668                    config_path.display(),
669                    e
670                ),
671            })?;
672
673        let value: toml::Value =
674            toml::from_str(&content).map_err(|e| GeneratorError::FileError {
675                message: format!(
676                    "Failed to parse TOML config: {}\n\nExample config:\n{}",
677                    e, EXAMPLE_CONFIG
678                ),
679            })?;
680        inspect_type_config_layout(&value)?;
681
682        let mut config: ConfigFile =
683            toml::from_str(&content).map_err(|e| GeneratorError::FileError {
684                message: format!(
685                    "Failed to parse TOML config: {}\n\nExample config:\n{}",
686                    e, EXAMPLE_CONFIG
687                ),
688            })?;
689
690        resolve_relative_path(config_dir, &mut config.generator.spec_path);
691        resolve_relative_path(config_dir, &mut config.generator.output_dir);
692        for extension in &mut config.generator.schema_extensions {
693            resolve_relative_path(config_dir, extension);
694        }
695
696        for overlay in &mut config.generator.overlays {
697            resolve_relative_path(config_dir, overlay);
698        }
699
700        config.validate()?;
701
702        Ok(config)
703    }
704
705    fn validate(&self) -> Result<(), GeneratorError> {
706        let mut errors = Vec::new();
707        for (field, path) in [
708            ("effective_spec", self.generator.effective_spec.as_deref()),
709            (
710                "bindings_metadata",
711                self.generator.bindings_metadata.as_deref().map(Path::new),
712            ),
713        ] {
714            if let Some(path) = path {
715                if let Err(error) = crate::overlay::validate_artifact_path(path) {
716                    errors.push(format!("generator.{field}: {error}"));
717                }
718            }
719        }
720        for path in &self.generator.overlays {
721            if !path.is_file() {
722                errors.push(format!(
723                    "generator.overlays: Overlay file not found: {}",
724                    path.display()
725                ));
726            }
727        }
728
729        let spec_source = self.generator.spec_path.to_string_lossy();
730        if crate::spec_source::is_remote_spec(&spec_source) {
731            if let Err(error) = crate::spec_source::validate_remote_spec_url(&spec_source) {
732                errors.push(format!("generator.spec_path: {error}"));
733            }
734        } else if spec_source.contains("://") {
735            let error = crate::spec_source::validate_remote_spec_url(&spec_source)
736                .err()
737                .unwrap_or_else(|| "unsupported remote OpenAPI URL".to_string());
738            errors.push(format!("generator.spec_path: {error}"));
739        } else if !self.generator.spec_path.exists() {
740            errors.push(format!(
741                "generator.spec_path: OpenAPI spec file not found: {}. Ensure spec_path points to a valid OpenAPI JSON or YAML file.",
742                self.generator.spec_path.display()
743            ));
744        }
745        if self.generator.module_name.is_empty() {
746            errors.push("generator.module_name: module_name cannot be empty".to_string());
747        }
748
749        if let Some(server) = &self.server
750            && server.framework != "axum"
751        {
752            errors.push(format!(
753                "server.framework: framework must be \"axum\" (got \"{}\"); other frameworks are not supported yet",
754                server.framework
755            ));
756        }
757
758        if let Some(client) = &self.client {
759            for (index, selector) in client.operations.iter().enumerate() {
760                if let Err(error) = crate::server::Selector::parse(selector) {
761                    errors.push(format!("client.operations[{index}]: {error}"));
762                }
763            }
764        }
765        if let Some(server) = &self.server {
766            if !(1..=67_108_864).contains(&server.validation.max_body_bytes) {
767                errors.push(
768                    "server.validation.max_body_bytes: max_body_bytes must be between 1 and 67108864"
769                        .to_string(),
770                );
771            }
772            if !(1..=100).contains(&server.validation.max_errors) {
773                errors.push(
774                    "server.validation.max_errors: max_errors must be between 1 and 100"
775                        .to_string(),
776                );
777            }
778            for (index, selector) in server.operations.iter().enumerate() {
779                if let Err(error) = crate::server::Selector::parse(selector) {
780                    errors.push(format!("server.operations[{index}]: {error}"));
781                }
782            }
783        }
784
785        if let Some(http) = &self.http_client {
786            if let Some(base_url) = &http.base_url
787                && url::Url::parse(base_url).is_err()
788            {
789                errors.push("http_client.base_url: base_url must be a valid URL".to_string());
790            }
791            if let Some(timeout) = http.timeout_seconds
792                && !(1..=3600).contains(&timeout)
793            {
794                errors.push(
795                    "http_client.timeout_seconds: timeout_seconds must be between 1 and 3600"
796                        .to_string(),
797                );
798            }
799            if let Some(auth) = &http.auth {
800                if !matches!(auth.auth_type.as_str(), "Bearer" | "ApiKey" | "Custom") {
801                    errors.push(format!(
802                        "http_client.auth.type: Invalid auth type '{}'. Must be one of: Bearer, ApiKey, Custom",
803                        auth.auth_type
804                    ));
805                }
806                if auth.header_name.is_empty() {
807                    errors.push(
808                        "http_client.auth.header_name: header_name cannot be empty".to_string(),
809                    );
810                }
811            }
812            for (index, header) in http.headers.iter().enumerate() {
813                if header.name.is_empty() {
814                    errors.push(format!(
815                        "http_client.headers[{index}].name: header name cannot be empty"
816                    ));
817                }
818            }
819            if let Some(retry) = &http.retry {
820                if retry.max_retries > 10 {
821                    errors.push(
822                        "http_client.retry.max_retries: max_retries must be between 0 and 10"
823                            .to_string(),
824                    );
825                }
826                if !(100..=10000).contains(&retry.initial_delay_ms) {
827                    errors.push(
828                        "http_client.retry.initial_delay_ms: initial_delay_ms must be between 100 and 10000"
829                            .to_string(),
830                    );
831                }
832                if !(1000..=300000).contains(&retry.max_delay_ms) {
833                    errors.push(
834                        "http_client.retry.max_delay_ms: max_delay_ms must be between 1000 and 300000"
835                            .to_string(),
836                    );
837                }
838            }
839        }
840
841        if let Some(streaming) = &self.streaming {
842            for (index, endpoint) in streaming.endpoints.iter().enumerate() {
843                let prefix = format!("streaming.endpoints[{index}]");
844                if endpoint.operation_id.is_empty() {
845                    errors.push(format!("{prefix}.operation_id: must not be empty"));
846                }
847                if endpoint.path.is_empty() {
848                    errors.push(format!("{prefix}.path: must not be empty"));
849                }
850                if endpoint.event_union_type.is_empty() {
851                    errors.push(format!("{prefix}.event_union_type: must not be empty"));
852                }
853                for (query_index, query) in endpoint.query_parameters.iter().enumerate() {
854                    if query.name.is_empty() {
855                        errors.push(format!(
856                            "{prefix}.query_parameters[{query_index}].name: must not be empty"
857                        ));
858                    }
859                }
860                if let Some(flow) = &endpoint.event_flow
861                    && !matches!(
862                        flow.flow_type.as_str(),
863                        "StartDeltaStop" | "start_delta_stop" | "Continuous"
864                    )
865                {
866                    errors.push(format!(
867                        "{prefix}.event_flow.type: Invalid event flow type '{}'. Must be one of: StartDeltaStop, Continuous",
868                        flow.flow_type
869                    ));
870                }
871            }
872        }
873
874        if errors.is_empty() {
875            Ok(())
876        } else {
877            Err(GeneratorError::ValidationError(format!(
878                "Configuration validation failed:\n  - {}",
879                errors.join("\n  - ")
880            )))
881        }
882    }
883
884    /// Convert to internal GeneratorConfig
885    pub fn into_generator_config(self) -> GeneratorConfig {
886        use crate::http_config::{AuthConfig, HttpClientConfig, RetryConfig};
887
888        let types = self.types;
889
890        // Convert HTTP client config
891        let http_client_config = self.http_client.as_ref().map(|http| HttpClientConfig {
892            base_url: http.base_url.clone(),
893            timeout_seconds: http.timeout_seconds,
894            max_response_body_bytes: http.max_response_body_bytes,
895            default_headers: http
896                .headers
897                .iter()
898                .map(|h| (h.name.clone(), h.value.clone()))
899                .collect(),
900        });
901
902        // Convert retry config
903        let retry_config = self
904            .http_client
905            .as_ref()
906            .and_then(|http| http.retry.as_ref())
907            .map(|retry| RetryConfig {
908                max_retries: retry.max_retries,
909                initial_delay_ms: retry.initial_delay_ms,
910                max_delay_ms: retry.max_delay_ms,
911            });
912
913        // Convert tracing config
914        let tracing_enabled = self
915            .http_client
916            .as_ref()
917            .and_then(|http| http.tracing.as_ref())
918            .map(|tracing| tracing.enabled)
919            .unwrap_or(true);
920
921        // Convert auth config
922        let auth_config = self
923            .http_client
924            .as_ref()
925            .and_then(|http| http.auth.as_ref())
926            .map(|auth| match auth.auth_type.as_str() {
927                "Bearer" => AuthConfig::Bearer {
928                    header_name: auth.header_name.clone(),
929                },
930                "ApiKey" => AuthConfig::ApiKey {
931                    header_name: auth.header_name.clone(),
932                },
933                "Custom" => AuthConfig::Custom {
934                    header_name: auth.header_name.clone(),
935                    header_value_prefix: None,
936                },
937                _ => AuthConfig::Bearer {
938                    header_name: "Authorization".to_string(),
939                },
940            });
941
942        // Convert streaming section to StreamingConfig
943        let streaming_config = self.streaming.map(|section| {
944            use crate::streaming::{
945                EventFlow, HttpMethod, QueryParameter, StreamingConfig, StreamingEndpoint,
946            };
947
948            let endpoints = section
949                .endpoints
950                .into_iter()
951                .map(|e| {
952                    let event_flow = e
953                        .event_flow
954                        .map(|ef| match ef.flow_type.as_str() {
955                            "StartDeltaStop" | "start_delta_stop" => EventFlow::StartDeltaStop {
956                                start_events: ef.start_events.unwrap_or_default(),
957                                delta_events: ef.delta_events.unwrap_or_default(),
958                                stop_events: ef.stop_events.unwrap_or_default(),
959                            },
960                            _ => EventFlow::Simple,
961                        })
962                        .unwrap_or(EventFlow::Simple);
963
964                    let http_method = e
965                        .http_method
966                        .map(|m| match m.to_uppercase().as_str() {
967                            "GET" => HttpMethod::Get,
968                            _ => HttpMethod::Post,
969                        })
970                        .unwrap_or(HttpMethod::Post);
971
972                    let query_parameters = e
973                        .query_parameters
974                        .into_iter()
975                        .map(|qp| QueryParameter {
976                            name: qp.name,
977                            required: qp.required,
978                        })
979                        .collect();
980
981                    StreamingEndpoint {
982                        operation_id: e.operation_id,
983                        path: e.path,
984                        http_method,
985                        stream_parameter: e.stream_parameter,
986                        query_parameters,
987                        event_union_type: e.event_union_type,
988                        content_type: e.content_type,
989                        event_flow,
990                        ..Default::default()
991                    }
992                })
993                .collect();
994
995            StreamingConfig {
996                endpoints,
997                ..Default::default()
998            }
999        });
1000
1001        GeneratorConfig {
1002            spec_path: self.generator.spec_path,
1003            output_dir: self.generator.output_dir,
1004            module_name: self.generator.module_name,
1005            enable_sse_client: self.features.enable_sse_client,
1006            enable_async_client: self.features.enable_async_client,
1007            enable_specta: self.features.enable_specta,
1008            type_mappings: if self.type_mappings.is_empty() {
1009                super::generator::default_type_mappings()
1010            } else {
1011                self.type_mappings
1012            },
1013            streaming_config,
1014            nullable_field_overrides: self.nullable_overrides,
1015            extensible_enum_overrides: self.extensible_enums,
1016            schema_extensions: self.generator.schema_extensions,
1017            overlays: self.generator.overlays,
1018            effective_spec: self.generator.effective_spec,
1019            bindings_metadata: self.generator.bindings_metadata,
1020            http_client_config,
1021            retry_config,
1022            tracing_enabled,
1023            auth_config,
1024            enable_registry: self.features.enable_registry,
1025            registry_only: self.features.registry_only,
1026            types,
1027            builders: self.generator.builders,
1028            server: self.server,
1029            client: self.client,
1030        }
1031    }
1032}
1033
1034const EXAMPLE_CONFIG: &str = r#"[generator]
1035spec_path = "openapi.json"
1036output_dir = "src/generated"
1037module_name = "types"
1038
1039[generator.builders]
1040enabled = true
1041threshold = 3
1042
1043[features]
1044enable_async_client = true
1045
1046[http_client]
1047base_url = "https://api.example.com"
1048timeout_seconds = 30
1049max_response_body_bytes = 8388608
1050
1051[http_client.retry]
1052max_retries = 3
1053
1054[http_client.auth]
1055type = "Bearer"
1056header_name = "Authorization""#;