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 let commands = analyzer.analyze_project(&config.project_path)?;
194
195 if commands.is_empty() {
196 self.logger
197 .info("No Tauri commands found. Skipping generation.");
198 return Ok(vec![]);
199 }
200
201 let discovered_structs = analyzer.get_discovered_structs();
203 let discovered_events = analyzer.get_discovered_events();
204 if config.should_force() {
205 self.logger.verbose("Force flag set, regenerating bindings");
206 } else {
207 match GenerationCache::needs_regeneration(
208 &config.output_path,
209 &commands,
210 discovered_structs,
211 discovered_events,
212 config,
213 ) {
214 Ok(false) => {
215 self.logger
216 .verbose("Cache hit - no changes detected, skipping generation");
217 let output_manager = OutputManager::new(&config.output_path);
219 if let Ok(metadata) = output_manager.get_generation_metadata() {
220 return Ok(metadata.files.iter().map(|f| f.name.clone()).collect());
221 }
222 self.logger
224 .debug("Could not get existing file list, regenerating");
225 }
226 Ok(true) => {
227 self.logger
228 .verbose("Cache miss - changes detected, regenerating");
229 }
230 Err(e) => {
231 self.logger
232 .debug(&format!("Cache check failed: {}, regenerating", e));
233 }
234 }
235 }
236
237 let validation = match config.validation_library.as_str() {
238 "zod" | "none" => Some(config.validation_library.clone()),
239 _ => return Err("Invalid validation library. Use 'zod' or 'none'".into()),
240 };
241
242 let mut generator = create_generator(validation);
243 let generated_files = generator.generate_models(
244 &commands,
245 discovered_structs,
246 &config.output_path,
247 &analyzer,
248 config,
249 )?;
250
251 if config.should_visualize_deps() {
253 self.generate_dependency_visualization(&analyzer, &commands, &config.output_path)?;
254 }
255
256 let cache = GenerationCache::new(&commands, discovered_structs, discovered_events, config)?;
258 if let Err(e) = cache.save(&config.output_path) {
259 self.logger
260 .warning(&format!("Failed to save generation cache: {}", e));
261 }
262
263 Ok(generated_files)
264 }
265
266 fn generate_dependency_visualization(
267 &self,
268 analyzer: &CommandAnalyzer,
269 commands: &[crate::models::CommandInfo],
270 output_path: &str,
271 ) -> Result<(), Box<dyn std::error::Error>> {
272 use std::fs;
273
274 self.logger.debug("Generating dependency visualization");
275
276 let text_viz = analyzer.visualize_dependencies(commands);
277 let viz_file_path = Path::new(output_path).join("dependency-graph.txt");
278 fs::write(&viz_file_path, text_viz)?;
279
280 let dot_viz = analyzer.generate_dot_graph(commands);
281 let dot_file_path = Path::new(output_path).join("dependency-graph.dot");
282 fs::write(&dot_file_path, dot_viz)?;
283
284 self.logger.verbose(&format!(
285 "Generated dependency graphs: {} and {}",
286 viz_file_path.display(),
287 dot_file_path.display()
288 ));
289
290 Ok(())
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use crate::interface::config::GenerateConfig;
298 use std::path::Path;
299 use tempfile::TempDir;
300
301 fn create_build_config(project_path: &Path, output_path: &Path) -> GenerateConfig {
302 GenerateConfig {
303 project_path: project_path.to_string_lossy().to_string(),
304 output_path: output_path.to_string_lossy().to_string(),
305 validation_library: "none".to_string(),
306 verbose: Some(false),
307 visualize_deps: Some(false),
308 include_private: Some(false),
309 type_mappings: None,
310 exclude_patterns: None,
311 include_patterns: None,
312 default_parameter_case: "camelCase".to_string(),
313 default_field_case: "snake_case".to_string(),
314 force: Some(false),
315 }
316 }
317
318 fn run_generation(build_system: &BuildSystem, config: &GenerateConfig) -> Vec<String> {
319 let generated_files = build_system.generate_bindings(config).unwrap();
320 let mut output_manager = OutputManager::new(&config.output_path);
321 output_manager
322 .finalize_generation(&generated_files)
323 .unwrap();
324 generated_files
325 }
326
327 fn read_generated(output_path: &Path, file_name: &str) -> String {
328 std::fs::read_to_string(output_path.join(file_name)).unwrap()
329 }
330
331 #[test]
332 fn test_build_system_creation() {
333 let build_system = BuildSystem::new(true, false);
334 assert!(build_system
335 .logger
336 .should_log(crate::interface::output::LogLevel::Verbose));
337 }
338
339 #[test]
340 fn test_load_default_configuration() {
341 let temp_dir = TempDir::new().unwrap();
342 let project_info = ProjectInfo {
343 root_path: temp_dir.path().to_path_buf(),
344 src_tauri_path: temp_dir.path().join("src-tauri"),
345 tauri_config_path: None,
346 };
347
348 let build_system = BuildSystem::new(false, false);
349 let config = build_system.load_configuration(&project_info).unwrap();
350
351 assert_eq!(config.validation_library, "none");
352 assert_eq!(config.project_path, "./src-tauri");
353 }
354
355 #[test]
356 fn test_load_configuration_from_tauri_config() {
357 let temp_dir = TempDir::new().unwrap();
358 let tauri_config_path = temp_dir.path().join("tauri.conf.json");
359
360 let custom_src_path = temp_dir.path().join("custom-src");
362 std::fs::create_dir_all(&custom_src_path).unwrap();
363
364 let config_content = serde_json::json!({
366 "plugins": {
367 "typegen": {
368 "projectPath": custom_src_path.to_string_lossy().to_string(),
369 "outputPath": "./custom-output",
370 "validationLibrary": "zod"
371 }
372 }
373 })
374 .to_string();
375 std::fs::write(&tauri_config_path, &config_content).unwrap();
376
377 let project_info = ProjectInfo {
378 root_path: temp_dir.path().to_path_buf(),
379 src_tauri_path: temp_dir.path().join("src-tauri"),
380 tauri_config_path: Some(tauri_config_path),
381 };
382
383 let build_system = BuildSystem::new(false, false);
384 let config = build_system.load_configuration(&project_info).unwrap();
385
386 assert_eq!(config.validation_library, "zod");
387 assert_eq!(config.output_path, "./custom-output");
388 }
389
390 #[test]
391 fn test_load_configuration_from_standalone_file() {
392 let temp_dir = TempDir::new().unwrap();
393 let typegen_config_path = temp_dir.path().join("typegen.json");
394
395 let project_path = temp_dir.path().join("src-tauri");
397 std::fs::create_dir_all(&project_path).unwrap();
398
399 let config_content = serde_json::json!({
401 "project_path": project_path.to_string_lossy().to_string(),
402 "output_path": "./standalone-output",
403 "validation_library": "zod"
404 })
405 .to_string();
406 std::fs::write(&typegen_config_path, config_content).unwrap();
407
408 let project_info = ProjectInfo {
409 root_path: temp_dir.path().to_path_buf(),
410 src_tauri_path: project_path.clone(),
411 tauri_config_path: None,
412 };
413
414 let build_system = BuildSystem::new(false, false);
415 let config = build_system.load_configuration(&project_info).unwrap();
416
417 assert_eq!(config.validation_library, "zod");
418 assert_eq!(config.output_path, "./standalone-output");
419 }
420
421 #[test]
422 fn test_load_configuration_falls_back_on_invalid_tauri_config() {
423 let temp_dir = TempDir::new().unwrap();
424 let tauri_config_path = temp_dir.path().join("tauri.conf.json");
425
426 let config_content = r#"{"build": {}}"#;
428 std::fs::write(&tauri_config_path, config_content).unwrap();
429
430 let project_info = ProjectInfo {
431 root_path: temp_dir.path().to_path_buf(),
432 src_tauri_path: temp_dir.path().join("src-tauri"),
433 tauri_config_path: Some(tauri_config_path),
434 };
435
436 let build_system = BuildSystem::new(false, false);
437 let config = build_system.load_configuration(&project_info).unwrap();
438
439 assert_eq!(config.validation_library, "none");
441 assert_eq!(config.project_path, "./src-tauri");
442 }
443
444 #[test]
445 fn test_build_system_with_verbose_logging() {
446 let build_system = BuildSystem::new(true, true);
447 assert!(build_system
448 .logger
449 .should_log(crate::interface::output::LogLevel::Verbose));
450 assert!(build_system
451 .logger
452 .should_log(crate::interface::output::LogLevel::Debug));
453 }
454
455 #[test]
456 fn test_build_system_without_verbose_logging() {
457 let build_system = BuildSystem::new(false, false);
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_generate_bindings_skips_unrelated_rust_changes() {
468 let temp_dir = TempDir::new().unwrap();
469 let project_path = temp_dir.path().join("src-tauri");
470 let output_path = temp_dir.path().join("generated");
471 std::fs::create_dir_all(&project_path).unwrap();
472
473 let source_file = project_path.join("main.rs");
474 std::fs::write(
475 &source_file,
476 r#"
477 use serde::{Deserialize, Serialize};
478 use tauri::Manager;
479
480 #[derive(Debug, Clone, Serialize, Deserialize)]
481 pub struct Payload {
482 pub value: String,
483 }
484
485 fn helper_text() -> &'static str {
486 "one"
487 }
488
489 #[tauri::command]
490 pub fn fetch_payload() -> Result<Payload, String> {
491 Ok(Payload {
492 value: helper_text().to_string(),
493 })
494 }
495
496 #[tauri::command]
497 pub fn emit_event(app: tauri::AppHandle) -> Result<(), String> {
498 app.emit("stable-event", Payload {
499 value: helper_text().to_string(),
500 }).ok();
501 Ok(())
502 }
503 "#,
504 )
505 .unwrap();
506
507 let config = create_build_config(&project_path, &output_path);
508 let build_system = BuildSystem::new(false, false);
509
510 run_generation(&build_system, &config);
511
512 let commands_before = read_generated(&output_path, "commands.ts");
513 let types_before = read_generated(&output_path, "types.ts");
514 let events_before = read_generated(&output_path, "events.ts");
515 let index_before = read_generated(&output_path, "index.ts");
516
517 std::fs::write(
518 &source_file,
519 r#"
520 use serde::{Deserialize, Serialize};
521 use tauri::Manager;
522
523 #[derive(Debug, Clone, Serialize, Deserialize)]
524 pub struct Payload {
525 pub value: String,
526 }
527
528 fn helper_text() -> &'static str {
529 "two"
530 }
531
532 #[tauri::command]
533 pub fn fetch_payload() -> Result<Payload, String> {
534 Ok(Payload {
535 value: helper_text().to_string(),
536 })
537 }
538
539 #[tauri::command]
540 pub fn emit_event(app: tauri::AppHandle) -> Result<(), String> {
541 app.emit("stable-event", Payload {
542 value: helper_text().to_string(),
543 }).ok();
544 Ok(())
545 }
546 "#,
547 )
548 .unwrap();
549
550 run_generation(&build_system, &config);
551
552 assert_eq!(commands_before, read_generated(&output_path, "commands.ts"));
553 assert_eq!(types_before, read_generated(&output_path, "types.ts"));
554 assert_eq!(events_before, read_generated(&output_path, "events.ts"));
555 assert_eq!(index_before, read_generated(&output_path, "index.ts"));
556 }
557
558 #[test]
559 fn test_generate_bindings_skips_source_location_only_changes() {
560 let temp_dir = TempDir::new().unwrap();
561 let project_path = temp_dir.path().join("src-tauri");
562 let output_path = temp_dir.path().join("generated");
563 std::fs::create_dir_all(&project_path).unwrap();
564
565 let source_file = project_path.join("main.rs");
566 std::fs::write(
567 &source_file,
568 r#"
569 use serde::{Deserialize, Serialize};
570 use tauri::Manager;
571
572 #[derive(Debug, Clone, Serialize, Deserialize)]
573 pub struct Payload {
574 pub value: String,
575 }
576
577 #[tauri::command]
578 pub fn fetch_payload() -> Result<Payload, String> {
579 Ok(Payload {
580 value: "one".to_string(),
581 })
582 }
583
584 #[tauri::command]
585 pub fn emit_event(app: tauri::AppHandle) -> Result<(), String> {
586 app.emit("stable-event", Payload {
587 value: "one".to_string(),
588 }).ok();
589 Ok(())
590 }
591 "#,
592 )
593 .unwrap();
594
595 let config = create_build_config(&project_path, &output_path);
596 let build_system = BuildSystem::new(false, false);
597
598 run_generation(&build_system, &config);
599
600 let commands_before = read_generated(&output_path, "commands.ts");
601 let types_before = read_generated(&output_path, "types.ts");
602 let events_before = read_generated(&output_path, "events.ts");
603
604 std::fs::write(
605 &source_file,
606 r#"
607 use serde::{Deserialize, Serialize};
608 use tauri::Manager;
609
610 // Unrelated comment that shifts every discovered item downward.
611 // The generated bindings should stay byte-stable.
612
613 #[derive(Debug, Clone, Serialize, Deserialize)]
614 pub struct Payload {
615 pub value: String,
616 }
617
618 #[tauri::command]
619 pub fn fetch_payload() -> Result<Payload, String> {
620 Ok(Payload {
621 value: "one".to_string(),
622 })
623 }
624
625 #[tauri::command]
626 pub fn emit_event(app: tauri::AppHandle) -> Result<(), String> {
627 app.emit("stable-event", Payload {
628 value: "one".to_string(),
629 }).ok();
630 Ok(())
631 }
632 "#,
633 )
634 .unwrap();
635
636 run_generation(&build_system, &config);
637
638 assert_eq!(commands_before, read_generated(&output_path, "commands.ts"));
639 assert_eq!(types_before, read_generated(&output_path, "types.ts"));
640 assert_eq!(events_before, read_generated(&output_path, "events.ts"));
641 }
642
643 #[test]
644 fn test_generate_bindings_regenerates_when_commands_change() {
645 let temp_dir = TempDir::new().unwrap();
646 let project_path = temp_dir.path().join("src-tauri");
647 let output_path = temp_dir.path().join("generated");
648 std::fs::create_dir_all(&project_path).unwrap();
649
650 let source_file = project_path.join("main.rs");
651 std::fs::write(
652 &source_file,
653 r#"
654 #[tauri::command]
655 pub fn first_command() -> Result<String, String> {
656 Ok("one".to_string())
657 }
658 "#,
659 )
660 .unwrap();
661
662 let config = create_build_config(&project_path, &output_path);
663 let build_system = BuildSystem::new(false, false);
664
665 run_generation(&build_system, &config);
666 let commands_before = read_generated(&output_path, "commands.ts");
667
668 std::fs::write(
669 &source_file,
670 r#"
671 #[tauri::command]
672 pub fn second_command() -> Result<String, String> {
673 Ok("two".to_string())
674 }
675 "#,
676 )
677 .unwrap();
678
679 run_generation(&build_system, &config);
680 let commands_after = read_generated(&output_path, "commands.ts");
681
682 assert_ne!(commands_before, commands_after);
683 assert!(commands_after.contains("secondCommand"));
684 assert!(!commands_after.contains("firstCommand"));
685 }
686
687 #[test]
688 fn test_generate_bindings_regenerates_when_structs_change() {
689 let temp_dir = TempDir::new().unwrap();
690 let project_path = temp_dir.path().join("src-tauri");
691 let output_path = temp_dir.path().join("generated");
692 std::fs::create_dir_all(&project_path).unwrap();
693
694 let source_file = project_path.join("main.rs");
695 std::fs::write(
696 &source_file,
697 r#"
698 use serde::{Deserialize, Serialize};
699
700 #[derive(Debug, Clone, Serialize, Deserialize)]
701 pub struct Payload {
702 pub value: String,
703 }
704
705 #[tauri::command]
706 pub fn fetch_payload() -> Result<Payload, String> {
707 Ok(Payload {
708 value: "one".to_string(),
709 })
710 }
711 "#,
712 )
713 .unwrap();
714
715 let config = create_build_config(&project_path, &output_path);
716 let build_system = BuildSystem::new(false, false);
717
718 run_generation(&build_system, &config);
719 let types_before = read_generated(&output_path, "types.ts");
720
721 std::fs::write(
722 &source_file,
723 r#"
724 use serde::{Deserialize, Serialize};
725
726 #[derive(Debug, Clone, Serialize, Deserialize)]
727 pub struct Payload {
728 pub value: String,
729 pub count: i32,
730 }
731
732 #[tauri::command]
733 pub fn fetch_payload() -> Result<Payload, String> {
734 Ok(Payload {
735 value: "one".to_string(),
736 count: 2,
737 })
738 }
739 "#,
740 )
741 .unwrap();
742
743 run_generation(&build_system, &config);
744 let types_after = read_generated(&output_path, "types.ts");
745
746 assert_ne!(types_before, types_after);
747 assert!(types_after.contains("count: number"));
748 }
749
750 #[test]
751 fn test_generate_bindings_regenerates_when_events_change() {
752 let temp_dir = TempDir::new().unwrap();
753 let project_path = temp_dir.path().join("src-tauri");
754 let output_path = temp_dir.path().join("generated");
755 std::fs::create_dir_all(&project_path).unwrap();
756
757 let source_file = project_path.join("main.rs");
758 std::fs::write(
759 &source_file,
760 r#"
761 use serde::{Deserialize, Serialize};
762 use tauri::Manager;
763
764 #[derive(Debug, Clone, Serialize, Deserialize)]
765 pub struct Payload {
766 pub value: String,
767 }
768
769 #[tauri::command]
770 pub fn emit_event(app: tauri::AppHandle) -> Result<(), String> {
771 app.emit("first-event", Payload {
772 value: "one".to_string(),
773 }).ok();
774 Ok(())
775 }
776 "#,
777 )
778 .unwrap();
779
780 let config = create_build_config(&project_path, &output_path);
781
782 let build_system = BuildSystem::new(false, false);
783 run_generation(&build_system, &config);
784 let events_before = read_generated(&output_path, "events.ts");
785
786 std::fs::write(
787 &source_file,
788 r#"
789 use serde::{Deserialize, Serialize};
790 use tauri::Manager;
791
792 #[derive(Debug, Clone, Serialize, Deserialize)]
793 pub struct Payload {
794 pub value: String,
795 }
796
797 #[tauri::command]
798 pub fn emit_event(app: tauri::AppHandle) -> Result<(), String> {
799 app.emit("second-event", Payload {
800 value: "two".to_string(),
801 }).ok();
802 Ok(())
803 }
804 "#,
805 )
806 .unwrap();
807
808 run_generation(&build_system, &config);
809
810 let events_after = read_generated(&output_path, "events.ts");
811 assert_ne!(events_before, events_after);
812 assert!(events_after.contains("second-event"));
813 assert!(!events_after.contains("first-event"));
814 }
815}