tauri_typegen/build/
output_manager.rs

1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4use thiserror::Error;
5
6#[derive(Error, Debug)]
7pub enum OutputError {
8    #[error("IO error: {0}")]
9    Io(#[from] std::io::Error),
10    #[error("Invalid output path: {0}")]
11    InvalidPath(String),
12    #[error("Permission denied: {0}")]
13    PermissionDenied(String),
14}
15
16pub struct OutputManager {
17    output_dir: PathBuf,
18    managed_files: HashSet<String>,
19    backup_dir: Option<PathBuf>,
20}
21
22impl OutputManager {
23    pub fn new<P: AsRef<Path>>(output_dir: P) -> Self {
24        Self {
25            output_dir: output_dir.as_ref().to_path_buf(),
26            managed_files: HashSet::new(),
27            backup_dir: None,
28        }
29    }
30
31    pub fn with_backup<P: AsRef<Path>>(output_dir: P, backup_dir: Option<P>) -> Self {
32        Self {
33            output_dir: output_dir.as_ref().to_path_buf(),
34            managed_files: HashSet::new(),
35            backup_dir: backup_dir.map(|p| p.as_ref().to_path_buf()),
36        }
37    }
38
39    /// Ensure the output directory exists and is writable
40    pub fn prepare_output_directory(&self) -> Result<(), OutputError> {
41        if !self.output_dir.exists() {
42            fs::create_dir_all(&self.output_dir).map_err(|e| {
43                OutputError::PermissionDenied(format!(
44                    "Cannot create output directory {}: {}",
45                    self.output_dir.display(),
46                    e
47                ))
48            })?;
49        }
50
51        // Test write permissions by creating a temporary file
52        let test_file = self.output_dir.join(".write_test");
53        fs::write(&test_file, "test").map_err(|e| {
54            OutputError::PermissionDenied(format!(
55                "Cannot write to output directory {}: {}",
56                self.output_dir.display(),
57                e
58            ))
59        })?;
60        fs::remove_file(&test_file).ok(); // Ignore errors on cleanup
61
62        Ok(())
63    }
64
65    /// Register a file as managed by this generator
66    pub fn register_managed_file(&mut self, filename: &str) {
67        self.managed_files.insert(filename.to_string());
68    }
69
70    /// Clean up old generated files that are no longer needed
71    pub fn cleanup_old_files(&self, current_files: &[String]) -> Result<Vec<String>, OutputError> {
72        let mut cleaned_files = Vec::new();
73
74        if !self.output_dir.exists() {
75            return Ok(cleaned_files);
76        }
77
78        let current_set: HashSet<String> = current_files.iter().cloned().collect();
79
80        // Read the directory and find files that look like they were generated
81        let entries = fs::read_dir(&self.output_dir)?;
82
83        for entry in entries {
84            let entry = entry?;
85            let path = entry.path();
86
87            if path.is_file() {
88                if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
89                    // Only clean up files that look like generated TypeScript files
90                    if self.is_generated_file(filename) && !current_set.contains(filename) {
91                        self.backup_and_remove_file(&path)?;
92                        cleaned_files.push(filename.to_string());
93                    }
94                }
95            }
96        }
97
98        Ok(cleaned_files)
99    }
100
101    /// Check if a file appears to be a generated file based on naming patterns
102    fn is_generated_file(&self, filename: &str) -> bool {
103        // Check for common generated file patterns
104        let generated_patterns = [
105            "types.ts",
106            "types.d.ts",
107            "commands.ts",
108            "commands.d.ts",
109            "schemas.ts",
110            "schemas.d.ts",
111            "index.ts",
112            "index.d.ts",
113            "models.ts",
114            "models.d.ts",
115            "bindings.ts",
116            "bindings.d.ts",
117        ];
118
119        generated_patterns
120            .iter()
121            .any(|pattern| filename == *pattern)
122            || filename.starts_with("generated_")
123            || filename.contains("_generated")
124            || self.managed_files.contains(filename)
125    }
126
127    /// Backup a file before removing it
128    fn backup_and_remove_file(&self, file_path: &Path) -> Result<(), OutputError> {
129        if let Some(backup_dir) = &self.backup_dir {
130            if !backup_dir.exists() {
131                fs::create_dir_all(backup_dir)?;
132            }
133
134            if let Some(filename) = file_path.file_name() {
135                let backup_path = backup_dir.join(format!(
136                    "{}.backup.{}",
137                    filename.to_string_lossy(),
138                    chrono::Utc::now().timestamp()
139                ));
140                fs::copy(file_path, backup_path)?;
141            }
142        }
143
144        fs::remove_file(file_path)?;
145        Ok(())
146    }
147
148    /// Write a file to the output directory with proper error handling
149    pub fn write_file(&self, filename: &str, content: &str) -> Result<PathBuf, OutputError> {
150        let file_path = self.output_dir.join(filename);
151
152        // Ensure parent directory exists
153        if let Some(parent) = file_path.parent() {
154            if !parent.exists() {
155                fs::create_dir_all(parent)?;
156            }
157        }
158
159        // Write with atomic operation (write to temp file first, then rename)
160        let temp_path = file_path.with_extension("tmp");
161        fs::write(&temp_path, content)?;
162        fs::rename(&temp_path, &file_path)?;
163
164        Ok(file_path)
165    }
166
167    /// Verify that all expected files were generated
168    pub fn verify_output(&self, expected_files: &[String]) -> Result<Vec<String>, OutputError> {
169        let mut missing_files = Vec::new();
170
171        for expected in expected_files {
172            let file_path = self.output_dir.join(expected);
173            if !file_path.exists() {
174                missing_files.push(expected.clone());
175            }
176        }
177
178        Ok(missing_files)
179    }
180
181    /// Get metadata about generated files
182    pub fn get_generation_metadata(&self) -> Result<GenerationMetadata, OutputError> {
183        let mut metadata = GenerationMetadata {
184            output_directory: self.output_dir.clone(),
185            generated_at: chrono::Utc::now(),
186            files: Vec::new(),
187            total_size: 0,
188        };
189
190        if !self.output_dir.exists() {
191            return Ok(metadata);
192        }
193
194        let entries = fs::read_dir(&self.output_dir)?;
195
196        for entry in entries {
197            let entry = entry?;
198            let path = entry.path();
199
200            if path.is_file() {
201                if let Ok(metadata_entry) = entry.metadata() {
202                    let size = metadata_entry.len();
203                    metadata.total_size += size;
204
205                    if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
206                        metadata.files.push(FileMetadata {
207                            name: filename.to_string(),
208                            path: path.clone(),
209                            size,
210                            modified: metadata_entry.modified().ok(),
211                        });
212                    }
213                }
214            }
215        }
216
217        Ok(metadata)
218    }
219
220    /// Finalize the generation process
221    pub fn finalize_generation(&mut self, generated_files: &[String]) -> Result<(), OutputError> {
222        self.prepare_output_directory()?;
223
224        // Register all generated files as managed
225        for file in generated_files {
226            self.register_managed_file(file);
227        }
228
229        // Clean up old files
230        let cleaned = self.cleanup_old_files(generated_files)?;
231        if !cleaned.is_empty() {
232            eprintln!("Cleaned up {} old generated files", cleaned.len());
233        }
234
235        // Verify all expected files exist
236        let missing = self.verify_output(generated_files)?;
237        if !missing.is_empty() {
238            return Err(OutputError::InvalidPath(format!(
239                "Missing generated files: {}",
240                missing.join(", ")
241            )));
242        }
243
244        Ok(())
245    }
246
247    /// Create a summary report of the generation process
248    pub fn create_summary_report(&self) -> Result<String, OutputError> {
249        let metadata = self.get_generation_metadata()?;
250
251        let mut report = String::new();
252        report.push_str("# TypeScript Generation Summary\n\n");
253        report.push_str(&format!(
254            "Generated at: {}\n",
255            metadata.generated_at.format("%Y-%m-%d %H:%M:%S UTC")
256        ));
257        report.push_str(&format!(
258            "Output directory: {}\n",
259            metadata.output_directory.display()
260        ));
261        report.push_str(&format!("Total files: {}\n", metadata.files.len()));
262        report.push_str(&format!("Total size: {} bytes\n\n", metadata.total_size));
263
264        report.push_str("## Generated Files\n\n");
265        for file in &metadata.files {
266            report.push_str(&format!("- **{}** ({} bytes)\n", file.name, file.size));
267        }
268
269        Ok(report)
270    }
271}
272
273#[derive(Debug)]
274pub struct GenerationMetadata {
275    pub output_directory: PathBuf,
276    pub generated_at: chrono::DateTime<chrono::Utc>,
277    pub files: Vec<FileMetadata>,
278    pub total_size: u64,
279}
280
281#[derive(Debug)]
282pub struct FileMetadata {
283    pub name: String,
284    pub path: PathBuf,
285    pub size: u64,
286    pub modified: Option<std::time::SystemTime>,
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use tempfile::TempDir;
293
294    #[test]
295    fn test_prepare_output_directory() {
296        let temp_dir = TempDir::new().unwrap();
297        let output_path = temp_dir.path().join("output");
298
299        let manager = OutputManager::new(&output_path);
300        manager.prepare_output_directory().unwrap();
301
302        assert!(output_path.exists());
303        assert!(output_path.is_dir());
304    }
305
306    #[test]
307    fn test_write_file() {
308        let temp_dir = TempDir::new().unwrap();
309        let manager = OutputManager::new(temp_dir.path());
310
311        manager.prepare_output_directory().unwrap();
312
313        let content = "export interface Test { name: string; }";
314        let file_path = manager.write_file("test.ts", content).unwrap();
315
316        assert!(file_path.exists());
317        let written_content = fs::read_to_string(&file_path).unwrap();
318        assert_eq!(written_content, content);
319    }
320
321    #[test]
322    fn test_is_generated_file() {
323        let temp_dir = TempDir::new().unwrap();
324        let manager = OutputManager::new(temp_dir.path());
325
326        // Test standard patterns
327        assert!(manager.is_generated_file("types.ts"));
328        assert!(manager.is_generated_file("commands.ts"));
329        assert!(manager.is_generated_file("schemas.ts"));
330        assert!(manager.is_generated_file("index.ts"));
331
332        // Test prefix/suffix patterns
333        assert!(manager.is_generated_file("generated_models.ts"));
334        assert!(manager.is_generated_file("api_generated.ts"));
335
336        // Test non-generated files
337        assert!(!manager.is_generated_file("user_code.ts"));
338        assert!(!manager.is_generated_file("main.ts"));
339        assert!(!manager.is_generated_file("app.vue"));
340    }
341
342    #[test]
343    fn test_cleanup_old_files() {
344        let temp_dir = TempDir::new().unwrap();
345        let manager = OutputManager::new(temp_dir.path());
346
347        manager.prepare_output_directory().unwrap();
348
349        // Create some files
350        fs::write(temp_dir.path().join("types.ts"), "old content").unwrap();
351        fs::write(temp_dir.path().join("commands.ts"), "old content").unwrap();
352        fs::write(temp_dir.path().join("user_file.ts"), "user content").unwrap();
353
354        // Current generation only includes types.ts
355        let current_files = vec!["types.ts".to_string()];
356
357        let cleaned = manager.cleanup_old_files(&current_files).unwrap();
358
359        // Should clean up commands.ts but not user_file.ts or types.ts
360        assert_eq!(cleaned.len(), 1);
361        assert!(cleaned.contains(&"commands.ts".to_string()));
362
363        assert!(temp_dir.path().join("types.ts").exists());
364        assert!(!temp_dir.path().join("commands.ts").exists());
365        assert!(temp_dir.path().join("user_file.ts").exists());
366    }
367
368    #[test]
369    fn test_verify_output() {
370        let temp_dir = TempDir::new().unwrap();
371        let manager = OutputManager::new(temp_dir.path());
372
373        manager.prepare_output_directory().unwrap();
374
375        // Create one expected file
376        fs::write(temp_dir.path().join("types.ts"), "content").unwrap();
377
378        let expected = vec!["types.ts".to_string(), "commands.ts".to_string()];
379        let missing = manager.verify_output(&expected).unwrap();
380
381        assert_eq!(missing.len(), 1);
382        assert!(missing.contains(&"commands.ts".to_string()));
383    }
384
385    #[test]
386    fn test_generation_metadata() {
387        let temp_dir = TempDir::new().unwrap();
388        let manager = OutputManager::new(temp_dir.path());
389
390        manager.prepare_output_directory().unwrap();
391
392        // Create some test files
393        fs::write(temp_dir.path().join("types.ts"), "interface Test {}").unwrap();
394        fs::write(
395            temp_dir.path().join("commands.ts"),
396            "export function test() {}",
397        )
398        .unwrap();
399
400        let metadata = manager.get_generation_metadata().unwrap();
401
402        assert_eq!(metadata.files.len(), 2);
403        assert!(metadata.total_size > 0);
404        assert_eq!(metadata.output_directory, temp_dir.path());
405
406        // Check individual files
407        let types_file = metadata
408            .files
409            .iter()
410            .find(|f| f.name == "types.ts")
411            .unwrap();
412        assert_eq!(types_file.size, 17); // Length of "interface Test {}"
413    }
414}