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