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