1pub mod dependency_resolver;
2pub mod generation_cache;
3pub mod output_manager;
4pub mod project_scanner;
5
6use crate::analysis::CommandAnalyzer;
7use crate::generators::create_generator;
8use crate::interface::config::{ConfigError, GenerateConfig};
9use crate::interface::output::{Logger, ProgressReporter};
10use std::path::Path;
11
12pub use dependency_resolver::*;
13pub use generation_cache::*;
14pub use output_manager::*;
15pub use project_scanner::*;
16
17pub struct BuildSystem {
22 logger: Logger,
23}
24
25impl BuildSystem {
26 pub fn new(verbose: bool, debug: bool) -> Self {
33 Self {
34 logger: Logger::new(verbose, debug),
35 }
36 }
37
38 pub fn generate_at_build_time() -> Result<(), Box<dyn std::error::Error>> {
67 let build_system = Self::new(false, false);
68 build_system.run_generation()
69 }
70
71 pub fn run_generation(&self) -> Result<(), Box<dyn std::error::Error>> {
73 let mut reporter = ProgressReporter::new(self.logger.clone(), 5);
74
75 reporter.start_step("Detecting Tauri project");
76 let project_scanner = ProjectScanner::new();
77 let project_info = match project_scanner.detect_project()? {
78 Some(info) => {
79 reporter.complete_step(Some(&format!(
80 "Found project at {}",
81 info.root_path.display()
82 )));
83 info
84 }
85 None => {
86 reporter.complete_step(Some("No Tauri project detected, skipping generation"));
87 return Ok(());
88 }
89 };
90
91 reporter.start_step("Loading configuration");
92 let config = self.load_configuration(&project_info)?;
93 reporter.complete_step(Some(&format!(
94 "Using {} validation with output to {}",
95 config.validation_library, config.output_path
96 )));
97
98 reporter.start_step("Setting up build dependencies");
99 self.setup_build_dependencies(&config)?;
100 reporter.complete_step(None);
101
102 reporter.start_step("Analyzing and generating bindings");
103 let generated_files = self.generate_bindings(&config)?;
104 reporter.complete_step(Some(&format!("Generated {} files", generated_files.len())));
105
106 reporter.start_step("Managing output");
107 let mut output_manager = OutputManager::new(&config.output_path);
108 output_manager.finalize_generation(&generated_files)?;
109 reporter.complete_step(None);
110
111 reporter.finish(&format!(
112 "Successfully generated TypeScript bindings for {} commands",
113 generated_files.len()
114 ));
115
116 Ok(())
117 }
118
119 fn load_configuration(
120 &self,
121 project_info: &ProjectInfo,
122 ) -> Result<GenerateConfig, ConfigError> {
123 if let Some(tauri_config_path) = &project_info.tauri_config_path {
125 if tauri_config_path.exists() {
126 match GenerateConfig::from_tauri_config(tauri_config_path) {
127 Ok(Some(config)) => {
128 self.logger
129 .debug("Loaded configuration from tauri.conf.json");
130 return Ok(config);
131 }
132 Ok(None) => {}
133 Err(e) => {
134 self.logger.warning(&format!(
135 "Failed to load config from tauri.conf.json: {}. Using defaults.",
136 e
137 ));
138 }
139 }
140 }
141 }
142
143 let standalone_config = project_info.root_path.join("typegen.json");
145 if standalone_config.exists() {
146 match GenerateConfig::from_file(&standalone_config) {
147 Ok(config) => {
148 self.logger.debug("Loaded configuration from typegen.json");
149 return Ok(config);
150 }
151 Err(e) => {
152 self.logger.warning(&format!(
153 "Failed to load config from typegen.json: {}. Using defaults.",
154 e
155 ));
156 }
157 }
158 }
159
160 self.logger.debug("Using default configuration");
162 Ok(GenerateConfig::default())
163 }
164
165 fn setup_build_dependencies(
166 &self,
167 config: &GenerateConfig,
168 ) -> Result<(), Box<dyn std::error::Error>> {
169 println!("cargo:rerun-if-changed={}", config.project_path);
171
172 if Path::new("tauri.conf.json").exists() {
174 println!("cargo:rerun-if-changed=tauri.conf.json");
175 }
176 if Path::new("typegen.json").exists() {
177 println!("cargo:rerun-if-changed=typegen.json");
178 }
179
180 if Path::new(&config.output_path).exists() {
182 println!("cargo:rerun-if-changed={}", config.output_path);
183 }
184
185 Ok(())
186 }
187
188 fn generate_bindings(
189 &self,
190 config: &GenerateConfig,
191 ) -> Result<Vec<String>, Box<dyn std::error::Error>> {
192 let mut analyzer = CommandAnalyzer::new();
193 if let Ok(prev_cache) = GenerationCache::load(&config.output_path) {
196 analyzer.seed_external_type_cache(prev_cache.external_type_index().clone());
197 }
198 let commands = analyzer.analyze_project(&config.project_path)?;
199
200 if commands.is_empty() {
201 self.logger
202 .info("No Tauri commands found. Skipping generation.");
203 return Ok(vec![]);
204 }
205
206 let discovered_structs = analyzer.get_discovered_structs();
208 let discovered_events = analyzer.get_discovered_events();
209 if config.should_force() {
210 self.logger.verbose("Force flag set, regenerating bindings");
211 } else {
212 match GenerationCache::needs_regeneration(
213 &config.output_path,
214 &commands,
215 discovered_structs,
216 discovered_events,
217 config,
218 ) {
219 Ok(false) => {
220 self.logger
221 .verbose("Cache hit - no changes detected, skipping generation");
222 let output_manager = OutputManager::new(&config.output_path);
224 if let Ok(metadata) = output_manager.get_generation_metadata() {
225 return Ok(metadata.files.iter().map(|f| f.name.clone()).collect());
226 }
227 self.logger
229 .debug("Could not get existing file list, regenerating");
230 }
231 Ok(true) => {
232 self.logger
233 .verbose("Cache miss - changes detected, regenerating");
234 }
235 Err(e) => {
236 self.logger
237 .debug(&format!("Cache check failed: {}, regenerating", e));
238 }
239 }
240 }
241
242 let validation = match config.validation_library.as_str() {
243 "zod" | "none" => Some(config.validation_library.clone()),
244 _ => return Err("Invalid validation library. Use 'zod' or 'none'".into()),
245 };
246
247 let mut generator = create_generator(validation);
248 let generated_files = generator.generate_models(
249 &commands,
250 discovered_structs,
251 &config.output_path,
252 &analyzer,
253 config,
254 )?;
255
256 if config.should_visualize_deps() {
258 self.generate_dependency_visualization(&analyzer, &commands, &config.output_path)?;
259 }
260
261 let cache = GenerationCache::new_with_external_index(
263 &commands,
264 discovered_structs,
265 discovered_events,
266 config,
267 analyzer.external_type_lookup_cache().clone(),
268 )?;
269 if let Err(e) = cache.save(&config.output_path) {
270 self.logger
271 .warning(&format!("Failed to save generation cache: {}", e));
272 }
273
274 Ok(generated_files)
275 }
276
277 fn generate_dependency_visualization(
278 &self,
279 analyzer: &CommandAnalyzer,
280 commands: &[crate::models::CommandInfo],
281 output_path: &str,
282 ) -> Result<(), Box<dyn std::error::Error>> {
283 use std::fs;
284
285 self.logger.debug("Generating dependency visualization");
286
287 let text_viz = analyzer.visualize_dependencies(commands);
288 let viz_file_path = Path::new(output_path).join("dependency-graph.txt");
289 fs::write(&viz_file_path, text_viz)?;
290
291 let dot_viz = analyzer.generate_dot_graph(commands);
292 let dot_file_path = Path::new(output_path).join("dependency-graph.dot");
293 fs::write(&dot_file_path, dot_viz)?;
294
295 self.logger.verbose(&format!(
296 "Generated dependency graphs: {} and {}",
297 viz_file_path.display(),
298 dot_file_path.display()
299 ));
300
301 Ok(())
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use crate::interface::config::GenerateConfig;
309 use std::path::Path;
310 use tempfile::TempDir;
311
312 fn create_build_config(project_path: &Path, output_path: &Path) -> GenerateConfig {
313 GenerateConfig {
314 project_path: project_path.to_string_lossy().to_string(),
315 output_path: output_path.to_string_lossy().to_string(),
316 validation_library: "none".to_string(),
317 verbose: Some(false),
318 visualize_deps: Some(false),
319 include_private: Some(false),
320 type_mappings: None,
321 exclude_patterns: None,
322 include_patterns: None,
323 default_parameter_case: "camelCase".to_string(),
324 default_field_case: "snake_case".to_string(),
325 force: Some(false),
326 }
327 }
328
329 fn run_generation(build_system: &BuildSystem, config: &GenerateConfig) -> Vec<String> {
330 let generated_files = build_system.generate_bindings(config).unwrap();
331 let mut output_manager = OutputManager::new(&config.output_path);
332 output_manager
333 .finalize_generation(&generated_files)
334 .unwrap();
335 generated_files
336 }
337
338 fn read_generated(output_path: &Path, file_name: &str) -> String {
339 std::fs::read_to_string(output_path.join(file_name)).unwrap()
340 }
341
342 #[test]
343 fn test_build_system_creation() {
344 let build_system = BuildSystem::new(true, false);
345 assert!(build_system
346 .logger
347 .should_log(crate::interface::output::LogLevel::Verbose));
348 }
349
350 #[test]
351 fn test_load_default_configuration() {
352 let temp_dir = TempDir::new().unwrap();
353 let project_info = ProjectInfo {
354 root_path: temp_dir.path().to_path_buf(),
355 src_tauri_path: temp_dir.path().join("src-tauri"),
356 tauri_config_path: None,
357 };
358
359 let build_system = BuildSystem::new(false, false);
360 let config = build_system.load_configuration(&project_info).unwrap();
361
362 assert_eq!(config.validation_library, "none");
363 assert_eq!(config.project_path, "./src-tauri");
364 }
365
366 #[test]
367 fn test_load_configuration_from_tauri_config() {
368 let temp_dir = TempDir::new().unwrap();
369 let tauri_config_path = temp_dir.path().join("tauri.conf.json");
370
371 let custom_src_path = temp_dir.path().join("custom-src");
373 std::fs::create_dir_all(&custom_src_path).unwrap();
374
375 let config_content = serde_json::json!({
377 "plugins": {
378 "typegen": {
379 "projectPath": custom_src_path.to_string_lossy().to_string(),
380 "outputPath": "./custom-output",
381 "validationLibrary": "zod"
382 }
383 }
384 })
385 .to_string();
386 std::fs::write(&tauri_config_path, &config_content).unwrap();
387
388 let project_info = ProjectInfo {
389 root_path: temp_dir.path().to_path_buf(),
390 src_tauri_path: temp_dir.path().join("src-tauri"),
391 tauri_config_path: Some(tauri_config_path),
392 };
393
394 let build_system = BuildSystem::new(false, false);
395 let config = build_system.load_configuration(&project_info).unwrap();
396
397 assert_eq!(config.validation_library, "zod");
398 assert_eq!(config.output_path, "./custom-output");
399 }
400
401 #[test]
402 fn test_load_configuration_from_standalone_file() {
403 let temp_dir = TempDir::new().unwrap();
404 let typegen_config_path = temp_dir.path().join("typegen.json");
405
406 let project_path = temp_dir.path().join("src-tauri");
408 std::fs::create_dir_all(&project_path).unwrap();
409
410 let config_content = serde_json::json!({
412 "project_path": project_path.to_string_lossy().to_string(),
413 "output_path": "./standalone-output",
414 "validation_library": "zod"
415 })
416 .to_string();
417 std::fs::write(&typegen_config_path, config_content).unwrap();
418
419 let project_info = ProjectInfo {
420 root_path: temp_dir.path().to_path_buf(),
421 src_tauri_path: project_path.clone(),
422 tauri_config_path: None,
423 };
424
425 let build_system = BuildSystem::new(false, false);
426 let config = build_system.load_configuration(&project_info).unwrap();
427
428 assert_eq!(config.validation_library, "zod");
429 assert_eq!(config.output_path, "./standalone-output");
430 }
431
432 #[test]
433 fn test_load_configuration_falls_back_on_invalid_tauri_config() {
434 let temp_dir = TempDir::new().unwrap();
435 let tauri_config_path = temp_dir.path().join("tauri.conf.json");
436
437 let config_content = r#"{"build": {}}"#;
439 std::fs::write(&tauri_config_path, config_content).unwrap();
440
441 let project_info = ProjectInfo {
442 root_path: temp_dir.path().to_path_buf(),
443 src_tauri_path: temp_dir.path().join("src-tauri"),
444 tauri_config_path: Some(tauri_config_path),
445 };
446
447 let build_system = BuildSystem::new(false, false);
448 let config = build_system.load_configuration(&project_info).unwrap();
449
450 assert_eq!(config.validation_library, "none");
452 assert_eq!(config.project_path, "./src-tauri");
453 }
454
455 #[test]
456 fn test_build_system_with_verbose_logging() {
457 let build_system = BuildSystem::new(true, true);
458 assert!(build_system
459 .logger
460 .should_log(crate::interface::output::LogLevel::Verbose));
461 assert!(build_system
462 .logger
463 .should_log(crate::interface::output::LogLevel::Debug));
464 }
465
466 #[test]
467 fn test_build_system_without_verbose_logging() {
468 let build_system = BuildSystem::new(false, false);
469 assert!(!build_system
470 .logger
471 .should_log(crate::interface::output::LogLevel::Verbose));
472 assert!(!build_system
473 .logger
474 .should_log(crate::interface::output::LogLevel::Debug));
475 }
476
477 #[test]
478 fn test_generate_bindings_skips_unrelated_rust_changes() {
479 let temp_dir = TempDir::new().unwrap();
480 let project_path = temp_dir.path().join("src-tauri");
481 let output_path = temp_dir.path().join("generated");
482 std::fs::create_dir_all(&project_path).unwrap();
483
484 let source_file = project_path.join("main.rs");
485 std::fs::write(
486 &source_file,
487 r#"
488 use serde::{Deserialize, Serialize};
489 use tauri::Manager;
490
491 #[derive(Debug, Clone, Serialize, Deserialize)]
492 pub struct Payload {
493 pub value: String,
494 }
495
496 fn helper_text() -> &'static str {
497 "one"
498 }
499
500 #[tauri::command]
501 pub fn fetch_payload() -> Result<Payload, String> {
502 Ok(Payload {
503 value: helper_text().to_string(),
504 })
505 }
506
507 #[tauri::command]
508 pub fn emit_event(app: tauri::AppHandle) -> Result<(), String> {
509 app.emit("stable-event", Payload {
510 value: helper_text().to_string(),
511 }).ok();
512 Ok(())
513 }
514 "#,
515 )
516 .unwrap();
517
518 let config = create_build_config(&project_path, &output_path);
519 let build_system = BuildSystem::new(false, false);
520
521 run_generation(&build_system, &config);
522
523 let commands_before = read_generated(&output_path, "commands.ts");
524 let types_before = read_generated(&output_path, "types.ts");
525 let events_before = read_generated(&output_path, "events.ts");
526 let index_before = read_generated(&output_path, "index.ts");
527
528 std::fs::write(
529 &source_file,
530 r#"
531 use serde::{Deserialize, Serialize};
532 use tauri::Manager;
533
534 #[derive(Debug, Clone, Serialize, Deserialize)]
535 pub struct Payload {
536 pub value: String,
537 }
538
539 fn helper_text() -> &'static str {
540 "two"
541 }
542
543 #[tauri::command]
544 pub fn fetch_payload() -> Result<Payload, String> {
545 Ok(Payload {
546 value: helper_text().to_string(),
547 })
548 }
549
550 #[tauri::command]
551 pub fn emit_event(app: tauri::AppHandle) -> Result<(), String> {
552 app.emit("stable-event", Payload {
553 value: helper_text().to_string(),
554 }).ok();
555 Ok(())
556 }
557 "#,
558 )
559 .unwrap();
560
561 run_generation(&build_system, &config);
562
563 assert_eq!(commands_before, read_generated(&output_path, "commands.ts"));
564 assert_eq!(types_before, read_generated(&output_path, "types.ts"));
565 assert_eq!(events_before, read_generated(&output_path, "events.ts"));
566 assert_eq!(index_before, read_generated(&output_path, "index.ts"));
567 }
568
569 #[test]
570 fn test_generate_bindings_skips_source_location_only_changes() {
571 let temp_dir = TempDir::new().unwrap();
572 let project_path = temp_dir.path().join("src-tauri");
573 let output_path = temp_dir.path().join("generated");
574 std::fs::create_dir_all(&project_path).unwrap();
575
576 let source_file = project_path.join("main.rs");
577 std::fs::write(
578 &source_file,
579 r#"
580 use serde::{Deserialize, Serialize};
581 use tauri::Manager;
582
583 #[derive(Debug, Clone, Serialize, Deserialize)]
584 pub struct Payload {
585 pub value: String,
586 }
587
588 #[tauri::command]
589 pub fn fetch_payload() -> Result<Payload, String> {
590 Ok(Payload {
591 value: "one".to_string(),
592 })
593 }
594
595 #[tauri::command]
596 pub fn emit_event(app: tauri::AppHandle) -> Result<(), String> {
597 app.emit("stable-event", Payload {
598 value: "one".to_string(),
599 }).ok();
600 Ok(())
601 }
602 "#,
603 )
604 .unwrap();
605
606 let config = create_build_config(&project_path, &output_path);
607 let build_system = BuildSystem::new(false, false);
608
609 run_generation(&build_system, &config);
610
611 let commands_before = read_generated(&output_path, "commands.ts");
612 let types_before = read_generated(&output_path, "types.ts");
613 let events_before = read_generated(&output_path, "events.ts");
614
615 std::fs::write(
616 &source_file,
617 r#"
618 use serde::{Deserialize, Serialize};
619 use tauri::Manager;
620
621 // Unrelated comment that shifts every discovered item downward.
622 // The generated bindings should stay byte-stable.
623
624 #[derive(Debug, Clone, Serialize, Deserialize)]
625 pub struct Payload {
626 pub value: String,
627 }
628
629 #[tauri::command]
630 pub fn fetch_payload() -> Result<Payload, String> {
631 Ok(Payload {
632 value: "one".to_string(),
633 })
634 }
635
636 #[tauri::command]
637 pub fn emit_event(app: tauri::AppHandle) -> Result<(), String> {
638 app.emit("stable-event", Payload {
639 value: "one".to_string(),
640 }).ok();
641 Ok(())
642 }
643 "#,
644 )
645 .unwrap();
646
647 run_generation(&build_system, &config);
648
649 assert_eq!(commands_before, read_generated(&output_path, "commands.ts"));
650 assert_eq!(types_before, read_generated(&output_path, "types.ts"));
651 assert_eq!(events_before, read_generated(&output_path, "events.ts"));
652 }
653
654 #[test]
655 fn test_generate_bindings_regenerates_when_commands_change() {
656 let temp_dir = TempDir::new().unwrap();
657 let project_path = temp_dir.path().join("src-tauri");
658 let output_path = temp_dir.path().join("generated");
659 std::fs::create_dir_all(&project_path).unwrap();
660
661 let source_file = project_path.join("main.rs");
662 std::fs::write(
663 &source_file,
664 r#"
665 #[tauri::command]
666 pub fn first_command() -> Result<String, String> {
667 Ok("one".to_string())
668 }
669 "#,
670 )
671 .unwrap();
672
673 let config = create_build_config(&project_path, &output_path);
674 let build_system = BuildSystem::new(false, false);
675
676 run_generation(&build_system, &config);
677 let commands_before = read_generated(&output_path, "commands.ts");
678
679 std::fs::write(
680 &source_file,
681 r#"
682 #[tauri::command]
683 pub fn second_command() -> Result<String, String> {
684 Ok("two".to_string())
685 }
686 "#,
687 )
688 .unwrap();
689
690 run_generation(&build_system, &config);
691 let commands_after = read_generated(&output_path, "commands.ts");
692
693 assert_ne!(commands_before, commands_after);
694 assert!(commands_after.contains("secondCommand"));
695 assert!(!commands_after.contains("firstCommand"));
696 }
697
698 #[test]
699 fn test_generate_bindings_regenerates_when_structs_change() {
700 let temp_dir = TempDir::new().unwrap();
701 let project_path = temp_dir.path().join("src-tauri");
702 let output_path = temp_dir.path().join("generated");
703 std::fs::create_dir_all(&project_path).unwrap();
704
705 let source_file = project_path.join("main.rs");
706 std::fs::write(
707 &source_file,
708 r#"
709 use serde::{Deserialize, Serialize};
710
711 #[derive(Debug, Clone, Serialize, Deserialize)]
712 pub struct Payload {
713 pub value: String,
714 }
715
716 #[tauri::command]
717 pub fn fetch_payload() -> Result<Payload, String> {
718 Ok(Payload {
719 value: "one".to_string(),
720 })
721 }
722 "#,
723 )
724 .unwrap();
725
726 let config = create_build_config(&project_path, &output_path);
727 let build_system = BuildSystem::new(false, false);
728
729 run_generation(&build_system, &config);
730 let types_before = read_generated(&output_path, "types.ts");
731
732 std::fs::write(
733 &source_file,
734 r#"
735 use serde::{Deserialize, Serialize};
736
737 #[derive(Debug, Clone, Serialize, Deserialize)]
738 pub struct Payload {
739 pub value: String,
740 pub count: i32,
741 }
742
743 #[tauri::command]
744 pub fn fetch_payload() -> Result<Payload, String> {
745 Ok(Payload {
746 value: "one".to_string(),
747 count: 2,
748 })
749 }
750 "#,
751 )
752 .unwrap();
753
754 run_generation(&build_system, &config);
755 let types_after = read_generated(&output_path, "types.ts");
756
757 assert_ne!(types_before, types_after);
758 assert!(types_after.contains("count: number"));
759 }
760
761 #[test]
762 fn test_generate_bindings_regenerates_when_events_change() {
763 let temp_dir = TempDir::new().unwrap();
764 let project_path = temp_dir.path().join("src-tauri");
765 let output_path = temp_dir.path().join("generated");
766 std::fs::create_dir_all(&project_path).unwrap();
767
768 let source_file = project_path.join("main.rs");
769 std::fs::write(
770 &source_file,
771 r#"
772 use serde::{Deserialize, Serialize};
773 use tauri::Manager;
774
775 #[derive(Debug, Clone, Serialize, Deserialize)]
776 pub struct Payload {
777 pub value: String,
778 }
779
780 #[tauri::command]
781 pub fn emit_event(app: tauri::AppHandle) -> Result<(), String> {
782 app.emit("first-event", Payload {
783 value: "one".to_string(),
784 }).ok();
785 Ok(())
786 }
787 "#,
788 )
789 .unwrap();
790
791 let config = create_build_config(&project_path, &output_path);
792
793 let build_system = BuildSystem::new(false, false);
794 run_generation(&build_system, &config);
795 let events_before = read_generated(&output_path, "events.ts");
796
797 std::fs::write(
798 &source_file,
799 r#"
800 use serde::{Deserialize, Serialize};
801 use tauri::Manager;
802
803 #[derive(Debug, Clone, Serialize, Deserialize)]
804 pub struct Payload {
805 pub value: String,
806 }
807
808 #[tauri::command]
809 pub fn emit_event(app: tauri::AppHandle) -> Result<(), String> {
810 app.emit("second-event", Payload {
811 value: "two".to_string(),
812 }).ok();
813 Ok(())
814 }
815 "#,
816 )
817 .unwrap();
818
819 run_generation(&build_system, &config);
820
821 let events_after = read_generated(&output_path, "events.ts");
822 assert_ne!(events_before, events_after);
823 assert!(events_after.contains("second-event"));
824 assert!(!events_after.contains("first-event"));
825 }
826}