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