Skip to main content

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