Skip to main content

mockforge_core/
generate_config.rs

1//! Configuration for mock generation from OpenAPI specifications
2//!
3//! This module provides configuration management for the `mockforge generate` command,
4//! allowing users to customize the mock generation process through configuration files.
5//!
6//! ## Supported Formats
7//!
8//! - `mockforge.toml` (recommended, type-safe)
9//! - `mockforge.json` (JSON format)
10//! - `mockforge.yaml` or `mockforge.yml` (YAML format)
11//!
12//! ## Priority Order
13//!
14//! 1. CLI arguments (highest precedence)
15//! 2. Configuration file
16//! 3. Environment variables
17//! 4. Default values (lowest precedence)
18//!
19//! ## Example Configuration
20//!
21//! ```toml
22//! [input]
23//! spec = "openapi.json"
24//!
25//! [output]
26//! path = "./generated"
27//! filename = "mock-server.rs"
28//!
29//! [plugins]
30//! oas-types = { package = "oas-types" }
31//!
32//! [options]
33//! client = "reqwest"
34//! mode = "tags"
35//! ```
36
37use serde::{Deserialize, Serialize};
38use std::collections::HashMap;
39use std::path::{Path, PathBuf};
40use tracing::warn;
41
42/// Configuration for mock generation
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(rename_all = "kebab-case")]
45pub struct GenerateConfig {
46    /// Input specification configuration
47    pub input: InputConfig,
48    /// Output configuration
49    pub output: OutputConfig,
50    /// Plugins to use during generation
51    #[serde(default)]
52    pub plugins: HashMap<String, PluginConfig>,
53    /// Generation options
54    pub options: Option<GenerateOptions>,
55}
56
57impl Default for GenerateConfig {
58    fn default() -> Self {
59        Self {
60            input: InputConfig::default(),
61            output: OutputConfig::default(),
62            plugins: HashMap::new(),
63            options: Some(GenerateOptions::default()),
64        }
65    }
66}
67
68/// Input specification configuration
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(rename_all = "kebab-case")]
71#[derive(Default)]
72pub struct InputConfig {
73    /// Path to OpenAPI specification file (JSON or YAML)
74    pub spec: Option<PathBuf>,
75    /// Additional input files
76    #[serde(default)]
77    pub additional: Vec<PathBuf>,
78}
79
80/// Barrel file type for organizing exports
81#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
82#[serde(rename_all = "kebab-case")]
83#[derive(Default)]
84pub enum BarrelType {
85    /// No barrel files generated
86    #[default]
87    None,
88    /// Generate index.ts (TypeScript/JavaScript)
89    #[serde(rename = "index")]
90    Index,
91    /// Generate index.ts and similar barrel files (full barrel pattern)
92    #[serde(rename = "barrel")]
93    Barrel,
94}
95
96/// Output configuration
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(rename_all = "kebab-case")]
99pub struct OutputConfig {
100    /// Output directory path
101    pub path: PathBuf,
102    /// Output file name (without extension)
103    pub filename: Option<String>,
104    /// Clean output directory before generation
105    #[serde(default)]
106    pub clean: bool,
107    /// Type of barrel/index files to generate
108    #[serde(default)]
109    pub barrel_type: BarrelType,
110    /// File extension override (e.g., "ts", "tsx", "js", "mjs")
111    pub extension: Option<String>,
112    /// Banner comment template to prepend to generated files
113    /// Supports placeholders: {{timestamp}}, {{source}}, {{generator}}
114    pub banner: Option<String>,
115    /// File naming template for generated files
116    /// Supports placeholders: {{name}}, {{tag}}, {{operation}}, {{path}}
117    pub file_naming_template: Option<String>,
118}
119
120impl Default for OutputConfig {
121    fn default() -> Self {
122        Self {
123            path: PathBuf::from("./generated"),
124            filename: None,
125            clean: false,
126            barrel_type: BarrelType::None,
127            extension: None,
128            banner: None,
129            file_naming_template: None,
130        }
131    }
132}
133
134/// Plugin configuration
135#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(untagged)]
137pub enum PluginConfig {
138    /// Simple plugin (string package name)
139    Simple(String),
140    /// Advanced plugin configuration
141    Advanced {
142        /// Package name
143        package: String,
144        /// Plugin options
145        #[serde(default)]
146        options: HashMap<String, serde_json::Value>,
147    },
148}
149
150/// Generation options
151#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(rename_all = "kebab-case")]
153pub struct GenerateOptions {
154    /// Client library to generate for (reqwest, ureq, etc.)
155    pub client: Option<String>,
156    /// Generation mode (operations, tags, paths)
157    pub mode: Option<String>,
158    /// Include validation in generated code
159    pub include_validation: bool,
160    /// Include examples in responses
161    pub include_examples: bool,
162    /// Target runtime (tokio, async-std, sync)
163    pub runtime: Option<String>,
164}
165
166impl Default for GenerateOptions {
167    fn default() -> Self {
168        Self {
169            client: Some("reqwest".to_string()),
170            mode: Some("tags".to_string()),
171            include_validation: true,
172            include_examples: true,
173            runtime: Some("tokio".to_string()),
174        }
175    }
176}
177
178/// Discovery configuration file paths in the current directory
179pub fn discover_config_file() -> Result<PathBuf, String> {
180    let config_names = vec![
181        "mockforge.toml",
182        "mockforge.json",
183        "mockforge.yaml",
184        "mockforge.yml",
185        ".mockforge.toml",
186        ".mockforge.json",
187        ".mockforge.yaml",
188        ".mockforge.yml",
189    ];
190
191    for name in config_names {
192        let path = Path::new(&name);
193        if path.exists() {
194            return Ok(path.to_path_buf());
195        }
196    }
197
198    Err("No configuration file found".to_string())
199}
200
201/// Load configuration from a file
202pub async fn load_generate_config<P: AsRef<Path>>(path: P) -> crate::Result<GenerateConfig> {
203    let path = path.as_ref();
204
205    if !path.exists() {
206        return Ok(GenerateConfig::default());
207    }
208
209    let content = tokio::fs::read_to_string(path)
210        .await
211        .map_err(|e| crate::Error::generic(format!("Failed to read config file: {}", e)))?;
212
213    let config = if path.extension().and_then(|s| s.to_str()) == Some("toml") {
214        toml::from_str(&content)
215            .map_err(|e| crate::Error::generic(format!("Failed to parse TOML config: {}", e)))?
216    } else if path.extension().and_then(|s| s.to_str()).map(|s| s == "json").unwrap_or(false) {
217        serde_json::from_str(&content)
218            .map_err(|e| crate::Error::generic(format!("Failed to parse JSON config: {}", e)))?
219    } else {
220        // Try YAML
221        serde_yaml::from_str(&content)
222            .map_err(|e| crate::Error::generic(format!("Failed to parse YAML config: {}", e)))?
223    };
224
225    Ok(config)
226}
227
228/// Load configuration with fallback to defaults
229pub async fn load_generate_config_with_fallback<P: AsRef<Path>>(path: P) -> GenerateConfig {
230    match load_generate_config(path).await {
231        Ok(config) => config,
232        Err(e) => {
233            warn!("Failed to load config file: {}. Using defaults.", e);
234            GenerateConfig::default()
235        }
236    }
237}
238
239/// Save configuration to a file
240pub async fn save_generate_config<P: AsRef<Path>>(
241    path: P,
242    config: &GenerateConfig,
243) -> crate::Result<()> {
244    let path = path.as_ref();
245
246    let content = if path.extension().and_then(|s| s.to_str()) == Some("toml") {
247        toml::to_string_pretty(config)
248            .map_err(|e| crate::Error::generic(format!("Failed to serialize to TOML: {}", e)))?
249    } else if path.extension().and_then(|s| s.to_str()).map(|s| s == "json").unwrap_or(false) {
250        serde_json::to_string_pretty(config)
251            .map_err(|e| crate::Error::generic(format!("Failed to serialize to JSON: {}", e)))?
252    } else {
253        serde_yaml::to_string(config)
254            .map_err(|e| crate::Error::generic(format!("Failed to serialize to YAML: {}", e)))?
255    };
256
257    tokio::fs::write(path, content)
258        .await
259        .map_err(|e| crate::Error::generic(format!("Failed to write config file: {}", e)))?;
260
261    Ok(())
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn test_default_config() {
270        let config = GenerateConfig::default();
271        assert!(config.input.spec.is_none());
272        assert_eq!(config.output.path, PathBuf::from("./generated"));
273        assert!(config.plugins.is_empty());
274        assert!(config.options.is_some());
275    }
276
277    #[test]
278    fn test_config_serialization_toml() {
279        let config = GenerateConfig::default();
280        let toml = toml::to_string(&config).unwrap();
281        assert!(toml.contains("input"));
282        assert!(toml.contains("output"));
283    }
284
285    #[test]
286    fn test_config_serialization_json() {
287        let config = GenerateConfig::default();
288        let json = serde_json::to_string(&config).unwrap();
289        assert!(json.contains("input"));
290        assert!(json.contains("output"));
291    }
292
293    #[test]
294    fn test_config_deserialization_toml() {
295        let toml_str = r#"
296[input]
297spec = "openapi.json"
298
299[output]
300path = "./generated"
301clean = true
302"#;
303        let config: GenerateConfig = toml::from_str(toml_str).unwrap();
304        assert_eq!(config.input.spec.unwrap(), PathBuf::from("openapi.json"));
305        assert_eq!(config.output.path, PathBuf::from("./generated"));
306        assert!(config.output.clean);
307    }
308
309    #[test]
310    fn test_output_config_with_barrel_type() {
311        let toml_str = r#"
312[input]
313
314[output]
315path = "./generated"
316barrel-type = "index"
317extension = "ts"
318banner = "Generated by {{generator}}"
319"#;
320        let config: GenerateConfig = toml::from_str(toml_str).unwrap();
321        assert_eq!(config.output.barrel_type, BarrelType::Index);
322        assert_eq!(config.output.extension, Some("ts".to_string()));
323        assert!(config.output.banner.is_some());
324    }
325
326    #[test]
327    fn test_config_deserialization_json() {
328        let json_str = r#"{
329            "input": {
330                "spec": "openapi.json"
331            },
332            "output": {
333                "path": "./generated",
334                "clean": true
335            },
336            "options": {
337                "client": "reqwest",
338                "mode": "tags",
339                "include-validation": true,
340                "include-examples": true
341            }
342        }"#;
343        let config: GenerateConfig = serde_json::from_str(json_str).unwrap();
344        assert_eq!(config.input.spec.unwrap(), PathBuf::from("openapi.json"));
345        assert_eq!(config.output.path, PathBuf::from("./generated"));
346        assert!(config.output.clean);
347        assert!(config.options.is_some());
348    }
349
350    #[test]
351    fn test_plugin_config_simple() {
352        let json_str = r#"{
353            "plugin-name": "package-name"
354        }"#;
355        let plugins: HashMap<String, PluginConfig> = serde_json::from_str(json_str).unwrap();
356        match plugins.get("plugin-name").unwrap() {
357            PluginConfig::Simple(pkg) => assert_eq!(pkg, "package-name"),
358            _ => panic!("Expected simple plugin"),
359        }
360    }
361
362    #[test]
363    fn test_plugin_config_advanced() {
364        let json_str = r#"{
365            "plugin-name": {
366                "package": "package-name",
367                "options": {
368                    "key": "value"
369                }
370            }
371        }"#;
372        let plugins: HashMap<String, PluginConfig> = serde_json::from_str(json_str).unwrap();
373        match plugins.get("plugin-name").unwrap() {
374            PluginConfig::Advanced { package, options } => {
375                assert_eq!(package, "package-name");
376                assert!(options.contains_key("key"));
377            }
378            _ => panic!("Expected advanced plugin"),
379        }
380    }
381}