tauri_typegen/interface/
config.rs

1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::Path;
4use thiserror::Error;
5
6#[derive(Error, Debug)]
7pub enum ConfigError {
8    #[error("IO error: {0}")]
9    Io(#[from] std::io::Error),
10    #[error("JSON parsing error: {0}")]
11    Json(#[from] serde_json::Error),
12    #[error("Invalid validation library: {0}. Use 'zod' or 'none'")]
13    InvalidValidationLibrary(String),
14    #[error("Invalid configuration: {0}")]
15    InvalidConfig(String),
16}
17
18#[derive(Debug, Serialize, Deserialize, Clone)]
19pub struct GenerateConfig {
20    /// Path to the Tauri project source directory
21    #[serde(default = "default_project_path")]
22    pub project_path: String,
23
24    /// Output path for generated TypeScript files
25    #[serde(default = "default_output_path")]
26    pub output_path: String,
27
28    /// Validation library to use ('zod' or 'none')
29    #[serde(default = "default_validation_library")]
30    pub validation_library: String,
31
32    /// Enable verbose output
33    #[serde(default)]
34    pub verbose: Option<bool>,
35
36    /// Generate dependency graph visualization
37    #[serde(default)]
38    pub visualize_deps: Option<bool>,
39
40    /// Include private struct fields in generation
41    #[serde(default)]
42    pub include_private: Option<bool>,
43
44    /// Custom type mappings
45    #[serde(default)]
46    pub type_mappings: Option<std::collections::HashMap<String, String>>,
47
48    /// File patterns to exclude from analysis
49    #[serde(default)]
50    pub exclude_patterns: Option<Vec<String>>,
51
52    /// File patterns to include in analysis (overrides excludes)
53    #[serde(default)]
54    pub include_patterns: Option<Vec<String>>,
55
56    /// Default naming convention for command parameters when no serde attribute is present
57    /// Options: "camelCase", "snake_case", "PascalCase", "SCREAMING_SNAKE_CASE", "kebab-case", "SCREAMING-KEBAB-CASE"
58    /// Default: "camelCase" (matches Tauri's default behavior - Tauri converts camelCase from JS to snake_case in Rust)
59    #[serde(default = "default_parameter_case")]
60    pub default_parameter_case: String,
61
62    /// Default naming convention for struct fields when no serde attribute is present
63    /// Options: same as default_parameter_case
64    /// Default: "snake_case" (matches serde's default serialization behavior)
65    /// Note: Use #[serde(rename_all = "camelCase")] on your structs if you want camelCase in TypeScript
66    #[serde(default = "default_field_case")]
67    pub default_field_case: String,
68}
69
70fn default_project_path() -> String {
71    "./src-tauri".to_string()
72}
73
74fn default_output_path() -> String {
75    "./src/generated".to_string()
76}
77
78fn default_validation_library() -> String {
79    "none".to_string()
80}
81
82fn default_parameter_case() -> String {
83    "camelCase".to_string()
84}
85
86fn default_field_case() -> String {
87    // Default to snake_case to match serde's default serialization behavior
88    // Users should add #[serde(rename_all = "camelCase")] if they want camelCase
89    "snake_case".to_string()
90}
91
92impl Default for GenerateConfig {
93    fn default() -> Self {
94        Self {
95            project_path: default_project_path(),
96            output_path: default_output_path(),
97            validation_library: default_validation_library(),
98            verbose: Some(false),
99            visualize_deps: Some(false),
100            include_private: Some(false),
101            type_mappings: None,
102            exclude_patterns: None,
103            include_patterns: None,
104            default_parameter_case: default_parameter_case(),
105            default_field_case: default_field_case(),
106        }
107    }
108}
109
110impl GenerateConfig {
111    /// Create a new configuration with defaults
112    pub fn new() -> Self {
113        Self::default()
114    }
115
116    /// Load configuration from a file
117    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
118        let content = fs::read_to_string(path)?;
119        let config: Self = serde_json::from_str(&content)?;
120        config.validate()?;
121        Ok(config)
122    }
123
124    /// Load configuration from Tauri configuration file
125    pub fn from_tauri_config<P: AsRef<Path>>(path: P) -> Result<Option<Self>, ConfigError> {
126        let content = fs::read_to_string(path)?;
127        let tauri_config: serde_json::Value = serde_json::from_str(&content)?;
128
129        // Look for typegen plugin configuration
130        if let Some(plugins) = tauri_config.get("plugins") {
131            if let Some(typegen) = plugins.get("typegen") {
132                let mut config = Self::default();
133
134                if let Some(project_path) = typegen.get("projectPath").and_then(|v| v.as_str()) {
135                    config.project_path = project_path.to_string();
136                }
137                if let Some(output_path) = typegen.get("outputPath").and_then(|v| v.as_str()) {
138                    config.output_path = output_path.to_string();
139                }
140                if let Some(validation) = typegen.get("validationLibrary").and_then(|v| v.as_str())
141                {
142                    config.validation_library = validation.to_string();
143                }
144                if let Some(verbose) = typegen.get("verbose").and_then(|v| v.as_bool()) {
145                    config.verbose = Some(verbose);
146                }
147                if let Some(visualize_deps) = typegen.get("visualizeDeps").and_then(|v| v.as_bool())
148                {
149                    config.visualize_deps = Some(visualize_deps);
150                }
151                if let Some(include_private) =
152                    typegen.get("includePrivate").and_then(|v| v.as_bool())
153                {
154                    config.include_private = Some(include_private);
155                }
156                if let Some(type_mappings) = typegen.get("typeMappings") {
157                    if let Ok(mappings) = serde_json::from_value::<
158                        std::collections::HashMap<String, String>,
159                    >(type_mappings.clone())
160                    {
161                        config.type_mappings = Some(mappings);
162                    }
163                }
164                if let Some(exclude_patterns) = typegen.get("excludePatterns") {
165                    if let Ok(patterns) =
166                        serde_json::from_value::<Vec<String>>(exclude_patterns.clone())
167                    {
168                        config.exclude_patterns = Some(patterns);
169                    }
170                }
171                if let Some(include_patterns) = typegen.get("includePatterns") {
172                    if let Ok(patterns) =
173                        serde_json::from_value::<Vec<String>>(include_patterns.clone())
174                    {
175                        config.include_patterns = Some(patterns);
176                    }
177                }
178
179                config.validate()?;
180                return Ok(Some(config));
181            }
182        }
183
184        Ok(None)
185    }
186
187    /// Save configuration to a file
188    pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), ConfigError> {
189        let content = serde_json::to_string_pretty(self)?;
190        fs::write(path, content)?;
191        Ok(())
192    }
193
194    /// Save configuration to Tauri configuration file
195    pub fn save_to_tauri_config<P: AsRef<Path>>(&self, path: P) -> Result<(), ConfigError> {
196        // Read existing tauri.conf.json - we require it to exist
197        if !path.as_ref().exists() {
198            return Err(ConfigError::InvalidConfig(format!(
199                "tauri.conf.json not found at {}. Please ensure you have a Tauri project initialized.",
200                path.as_ref().display()
201            )));
202        }
203
204        let content = fs::read_to_string(&path)?;
205        let mut tauri_config = serde_json::from_str::<serde_json::Value>(&content)?;
206
207        // Create typegen plugin configuration
208        let typegen_config = serde_json::json!({
209            "projectPath": self.project_path,
210            "outputPath": self.output_path,
211            "validationLibrary": self.validation_library,
212            "verbose": self.verbose.unwrap_or(false),
213            "visualizeDeps": self.visualize_deps.unwrap_or(false),
214            "includePrivate": self.include_private.unwrap_or(false),
215            "typeMappings": self.type_mappings,
216            "excludePatterns": self.exclude_patterns,
217            "includePatterns": self.include_patterns,
218        });
219
220        // Ensure plugins section exists and insert typegen configuration
221        if !tauri_config.is_object() {
222            tauri_config = serde_json::json!({});
223        }
224
225        let tauri_obj = tauri_config.as_object_mut().unwrap();
226
227        // Create plugins section if it doesn't exist
228        if !tauri_obj.contains_key("plugins") {
229            tauri_obj.insert("plugins".to_string(), serde_json::json!({}));
230        }
231
232        // Insert typegen configuration into plugins
233        if let Some(plugins) = tauri_obj.get_mut("plugins") {
234            if let Some(plugins_obj) = plugins.as_object_mut() {
235                plugins_obj.insert("typegen".to_string(), typegen_config);
236            }
237        }
238
239        let content = serde_json::to_string_pretty(&tauri_config)?;
240        fs::write(path, content)?;
241        Ok(())
242    }
243
244    /// Validate the configuration
245    pub fn validate(&self) -> Result<(), ConfigError> {
246        // Validate validation library
247        match self.validation_library.as_str() {
248            "zod" | "none" => {}
249            _ => {
250                return Err(ConfigError::InvalidValidationLibrary(
251                    self.validation_library.clone(),
252                ));
253            }
254        }
255
256        // Validate paths exist
257        let project_path = Path::new(&self.project_path);
258        if !project_path.exists() {
259            return Err(ConfigError::InvalidConfig(format!(
260                "Project path does not exist: {}",
261                self.project_path
262            )));
263        }
264
265        Ok(())
266    }
267
268    /// Merge with another configuration, with other taking precedence
269    pub fn merge(&mut self, other: &GenerateConfig) {
270        if other.project_path != default_project_path() {
271            self.project_path = other.project_path.clone();
272        }
273        if other.output_path != default_output_path() {
274            self.output_path = other.output_path.clone();
275        }
276        if other.validation_library != default_validation_library() {
277            self.validation_library = other.validation_library.clone();
278        }
279        if other.verbose.is_some() {
280            self.verbose = other.verbose;
281        }
282        if other.visualize_deps.is_some() {
283            self.visualize_deps = other.visualize_deps;
284        }
285        if other.include_private.is_some() {
286            self.include_private = other.include_private;
287        }
288        if other.type_mappings.is_some() {
289            self.type_mappings = other.type_mappings.clone();
290        }
291        if other.exclude_patterns.is_some() {
292            self.exclude_patterns = other.exclude_patterns.clone();
293        }
294        if other.include_patterns.is_some() {
295            self.include_patterns = other.include_patterns.clone();
296        }
297    }
298
299    /// Get effective verbose setting
300    pub fn is_verbose(&self) -> bool {
301        self.verbose.unwrap_or(false)
302    }
303
304    /// Get effective visualize_deps setting
305    pub fn should_visualize_deps(&self) -> bool {
306        self.visualize_deps.unwrap_or(false)
307    }
308
309    /// Get effective include_private setting
310    pub fn should_include_private(&self) -> bool {
311        self.include_private.unwrap_or(false)
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use tempfile::NamedTempFile;
319
320    #[test]
321    fn test_default_config() {
322        let config = GenerateConfig::default();
323        assert_eq!(config.project_path, "./src-tauri");
324        assert_eq!(config.output_path, "./src/generated");
325        assert_eq!(config.validation_library, "none");
326        assert!(!config.is_verbose());
327        assert!(!config.should_visualize_deps());
328        assert!(!config.should_include_private());
329    }
330
331    #[test]
332    fn test_config_validation() {
333        let config = GenerateConfig {
334            validation_library: "invalid".to_string(),
335            ..Default::default()
336        };
337
338        let result = config.validate();
339        assert!(result.is_err());
340        if let Err(ConfigError::InvalidValidationLibrary(lib)) = result {
341            assert_eq!(lib, "invalid");
342        } else {
343            panic!("Expected InvalidValidationLibrary error");
344        }
345    }
346
347    #[test]
348    fn test_config_merge() {
349        let mut base = GenerateConfig::default();
350        let override_config = GenerateConfig {
351            output_path: "./custom".to_string(),
352            verbose: Some(true),
353            ..Default::default()
354        };
355
356        base.merge(&override_config);
357        assert_eq!(base.output_path, "./custom");
358        assert!(base.is_verbose());
359        assert_eq!(base.validation_library, "none"); // Should remain default
360    }
361
362    #[test]
363    fn test_save_and_load_config() {
364        let temp_dir = tempfile::TempDir::new().unwrap();
365        let project_path = temp_dir.path().join("src-tauri");
366        std::fs::create_dir_all(&project_path).unwrap();
367
368        let config = GenerateConfig {
369            project_path: project_path.to_string_lossy().to_string(),
370            output_path: "./test".to_string(),
371            verbose: Some(true),
372            ..Default::default()
373        };
374
375        let temp_file = NamedTempFile::new().unwrap();
376        config.save_to_file(temp_file.path()).unwrap();
377
378        let loaded_config = GenerateConfig::from_file(temp_file.path()).unwrap();
379        assert_eq!(loaded_config.output_path, "./test");
380        assert!(loaded_config.is_verbose());
381    }
382
383    #[test]
384    fn test_save_to_tauri_config_preserves_existing_content() {
385        let temp_dir = tempfile::TempDir::new().unwrap();
386        let project_path = temp_dir.path().join("src-tauri");
387        std::fs::create_dir_all(&project_path).unwrap();
388
389        let tauri_conf_path = temp_dir.path().join("tauri.conf.json");
390
391        // Create existing tauri.conf.json with some content
392        let existing_content = serde_json::json!({
393            "package": {
394                "productName": "My App",
395                "version": "1.0.0"
396            },
397            "tauri": {
398                "allowlist": {
399                    "all": false
400                }
401            },
402            "plugins": {
403                "shell": {
404                    "all": false
405                }
406            }
407        });
408
409        fs::write(
410            &tauri_conf_path,
411            serde_json::to_string_pretty(&existing_content).unwrap(),
412        )
413        .unwrap();
414
415        let config = GenerateConfig {
416            project_path: project_path.to_string_lossy().to_string(),
417            output_path: "./test".to_string(),
418            validation_library: "zod".to_string(),
419            verbose: Some(true),
420            ..Default::default()
421        };
422
423        // Save to tauri config - should preserve existing content
424        config.save_to_tauri_config(&tauri_conf_path).unwrap();
425
426        // Read back and verify
427        let updated_content = fs::read_to_string(&tauri_conf_path).unwrap();
428        let updated_json: serde_json::Value = serde_json::from_str(&updated_content).unwrap();
429
430        // Check that existing content is preserved
431        assert_eq!(updated_json["package"]["productName"], "My App");
432        assert_eq!(updated_json["package"]["version"], "1.0.0");
433        assert_eq!(updated_json["tauri"]["allowlist"]["all"], false);
434        assert_eq!(updated_json["plugins"]["shell"]["all"], false);
435
436        // Check that typegen config was added
437        assert_eq!(updated_json["plugins"]["typegen"]["outputPath"], "./test");
438        assert_eq!(
439            updated_json["plugins"]["typegen"]["validationLibrary"],
440            "zod"
441        );
442        assert_eq!(updated_json["plugins"]["typegen"]["verbose"], true);
443    }
444
445    #[test]
446    fn test_save_to_tauri_config_creates_plugins_section() {
447        let temp_dir = tempfile::TempDir::new().unwrap();
448        let project_path = temp_dir.path().join("src-tauri");
449        std::fs::create_dir_all(&project_path).unwrap();
450
451        let tauri_conf_path = temp_dir.path().join("tauri.conf.json");
452
453        // Create existing tauri.conf.json without plugins section
454        let existing_content = serde_json::json!({
455            "package": {
456                "productName": "My App",
457                "version": "1.0.0"
458            },
459            "tauri": {
460                "allowlist": {
461                    "all": false
462                }
463            }
464        });
465
466        fs::write(
467            &tauri_conf_path,
468            serde_json::to_string_pretty(&existing_content).unwrap(),
469        )
470        .unwrap();
471
472        let config = GenerateConfig {
473            project_path: project_path.to_string_lossy().to_string(),
474            output_path: "./test".to_string(),
475            validation_library: "none".to_string(),
476            ..Default::default()
477        };
478
479        // Save to tauri config - should create plugins section
480        config.save_to_tauri_config(&tauri_conf_path).unwrap();
481
482        // Read back and verify
483        let updated_content = fs::read_to_string(&tauri_conf_path).unwrap();
484        let updated_json: serde_json::Value = serde_json::from_str(&updated_content).unwrap();
485
486        // Check that existing content is preserved
487        assert_eq!(updated_json["package"]["productName"], "My App");
488        assert_eq!(updated_json["tauri"]["allowlist"]["all"], false);
489
490        // Check that plugins section was created with typegen config
491        assert!(updated_json["plugins"].is_object());
492        assert_eq!(updated_json["plugins"]["typegen"]["outputPath"], "./test");
493        assert_eq!(
494            updated_json["plugins"]["typegen"]["validationLibrary"],
495            "none"
496        );
497    }
498}