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