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