tauri_typegen/build/
project_scanner.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use thiserror::Error;
4
5#[derive(Error, Debug)]
6pub enum ScanError {
7    #[error("IO error: {0}")]
8    Io(#[from] std::io::Error),
9    #[error("Invalid project structure: {0}")]
10    InvalidProject(String),
11}
12
13#[derive(Debug, Clone)]
14pub struct ProjectInfo {
15    pub root_path: PathBuf,
16    pub src_tauri_path: PathBuf,
17    pub tauri_config_path: Option<PathBuf>,
18}
19
20pub struct ProjectScanner {
21    current_dir: PathBuf,
22}
23
24impl ProjectScanner {
25    pub fn new() -> Self {
26        Self {
27            current_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
28        }
29    }
30
31    pub fn with_current_dir<P: AsRef<Path>>(path: P) -> Self {
32        Self {
33            current_dir: path.as_ref().to_path_buf(),
34        }
35    }
36
37    /// Detect if we're in a Tauri project and gather project information
38    pub fn detect_project(&self) -> Result<Option<ProjectInfo>, ScanError> {
39        // Start from current directory and walk up the tree
40        let mut current = self.current_dir.clone();
41
42        loop {
43            if let Some(project_info) = self.check_directory(&current)? {
44                return Ok(Some(project_info));
45            }
46
47            // Move up one directory
48            if let Some(parent) = current.parent() {
49                current = parent.to_path_buf();
50            } else {
51                // Reached filesystem root, no Tauri project found
52                break;
53            }
54        }
55
56        Ok(None)
57    }
58
59    /// Check if a specific directory contains a Tauri project
60    fn check_directory(&self, dir: &Path) -> Result<Option<ProjectInfo>, ScanError> {
61        // Check for tauri.conf.json (v2) or tauri.conf.js
62        let tauri_config_json = dir.join("tauri.conf.json");
63        let tauri_config_js = dir.join("tauri.conf.js");
64        let src_tauri = dir.join("src-tauri");
65
66        let tauri_config_path = if tauri_config_json.exists() {
67            Some(tauri_config_json)
68        } else if tauri_config_js.exists() {
69            Some(tauri_config_js)
70        } else {
71            None
72        };
73
74        // A Tauri project should have either a config file or a src-tauri directory
75        if tauri_config_path.is_some() || src_tauri.exists() {
76            // Determine the actual source path
77            let src_tauri_path = if src_tauri.exists() && src_tauri.is_dir() {
78                src_tauri
79            } else if let Some(ref config_path) = tauri_config_path {
80                // Try to read the config to find the source path
81                if let Ok(source_dir) = self.read_source_dir_from_config(config_path) {
82                    dir.join(source_dir)
83                } else {
84                    // Default fallback
85                    src_tauri
86                }
87            } else {
88                src_tauri
89            };
90
91            return Ok(Some(ProjectInfo {
92                root_path: dir.to_path_buf(),
93                src_tauri_path,
94                tauri_config_path,
95            }));
96        }
97
98        Ok(None)
99    }
100
101    /// Try to read the source directory from the Tauri configuration
102    fn read_source_dir_from_config(&self, config_path: &Path) -> Result<String, ScanError> {
103        let content = fs::read_to_string(config_path)?;
104
105        // Handle JSON config
106        if config_path.extension().and_then(|s| s.to_str()) == Some("json") {
107            if let Ok(config) = serde_json::from_str::<serde_json::Value>(&content) {
108                if let Some(build) = config.get("build") {
109                    if let Some(dev_path) = build.get("devPath").and_then(|v| v.as_str()) {
110                        return Ok(dev_path.to_string());
111                    }
112                }
113            }
114        }
115
116        // Default fallback
117        Ok("src-tauri".to_string())
118    }
119
120    /// Discover all Rust source files in the project
121    pub fn discover_rust_files(
122        &self,
123        project_info: &ProjectInfo,
124    ) -> Result<Vec<PathBuf>, ScanError> {
125        let mut rust_files = Vec::new();
126        Self::walk_directory(&project_info.src_tauri_path, &mut rust_files)?;
127        Ok(rust_files)
128    }
129
130    /// Recursively walk a directory to find Rust files
131    fn walk_directory(dir: &Path, rust_files: &mut Vec<PathBuf>) -> Result<(), ScanError> {
132        if !dir.exists() || !dir.is_dir() {
133            return Ok(());
134        }
135
136        let entries = fs::read_dir(dir)?;
137
138        for entry in entries {
139            let entry = entry?;
140            let path = entry.path();
141
142            if path.is_dir() {
143                // Skip common directories that shouldn't contain source
144                let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
145
146                if !["target", "node_modules", ".git", "dist"].contains(&dir_name) {
147                    Self::walk_directory(&path, rust_files)?;
148                }
149            } else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
150                rust_files.push(path);
151            }
152        }
153
154        Ok(())
155    }
156
157    /// Check if the project has package.json (indicates frontend project)
158    pub fn has_frontend(&self, project_info: &ProjectInfo) -> bool {
159        let package_json = project_info.root_path.join("package.json");
160        package_json.exists()
161    }
162
163    /// Get the recommended output path based on project structure
164    pub fn get_recommended_output_path(&self, project_info: &ProjectInfo) -> String {
165        if self.has_frontend(project_info) {
166            // Frontend project, use src/generated
167            "./src/generated".to_string()
168        } else {
169            // Backend-only project, use generated in root
170            "./generated".to_string()
171        }
172    }
173}
174
175impl Default for ProjectScanner {
176    fn default() -> Self {
177        Self::new()
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use std::fs;
185    use tempfile::TempDir;
186
187    #[test]
188    fn test_detect_tauri_project_with_config() {
189        let temp_dir = TempDir::new().unwrap();
190        let config_path = temp_dir.path().join("tauri.conf.json");
191        fs::write(&config_path, r#"{"build": {"devPath": "./src"}}"#).unwrap();
192
193        let scanner = ProjectScanner::with_current_dir(temp_dir.path());
194        let project_info = scanner.detect_project().unwrap().unwrap();
195
196        assert_eq!(project_info.root_path, temp_dir.path());
197        assert!(project_info.tauri_config_path.is_some());
198    }
199
200    #[test]
201    fn test_detect_tauri_project_with_src_tauri() {
202        let temp_dir = TempDir::new().unwrap();
203        let src_tauri = temp_dir.path().join("src-tauri");
204        fs::create_dir(&src_tauri).unwrap();
205
206        let scanner = ProjectScanner::with_current_dir(temp_dir.path());
207        let project_info = scanner.detect_project().unwrap().unwrap();
208
209        assert_eq!(project_info.root_path, temp_dir.path());
210        assert_eq!(project_info.src_tauri_path, src_tauri);
211    }
212
213    #[test]
214    fn test_no_tauri_project() {
215        let temp_dir = TempDir::new().unwrap();
216
217        let scanner = ProjectScanner::with_current_dir(temp_dir.path());
218        let project_info = scanner.detect_project().unwrap();
219
220        assert!(project_info.is_none());
221    }
222
223    #[test]
224    fn test_discover_rust_files() {
225        let temp_dir = TempDir::new().unwrap();
226        let src_tauri = temp_dir.path().join("src-tauri");
227        fs::create_dir(&src_tauri).unwrap();
228
229        let main_rs = src_tauri.join("main.rs");
230        let lib_rs = src_tauri.join("lib.rs");
231        fs::write(&main_rs, "// main").unwrap();
232        fs::write(&lib_rs, "// lib").unwrap();
233
234        let project_info = ProjectInfo {
235            root_path: temp_dir.path().to_path_buf(),
236            src_tauri_path: src_tauri,
237            tauri_config_path: None,
238        };
239
240        let scanner = ProjectScanner::new();
241        let rust_files = scanner.discover_rust_files(&project_info).unwrap();
242
243        assert_eq!(rust_files.len(), 2);
244        assert!(rust_files.contains(&main_rs));
245        assert!(rust_files.contains(&lib_rs));
246    }
247
248    #[test]
249    fn test_has_frontend_detection() {
250        let temp_dir = TempDir::new().unwrap();
251        let package_json = temp_dir.path().join("package.json");
252        fs::write(&package_json, r#"{"name": "test"}"#).unwrap();
253
254        let project_info = ProjectInfo {
255            root_path: temp_dir.path().to_path_buf(),
256            src_tauri_path: temp_dir.path().join("src-tauri"),
257            tauri_config_path: None,
258        };
259
260        let scanner = ProjectScanner::new();
261        assert!(scanner.has_frontend(&project_info));
262    }
263
264    #[test]
265    fn test_recommended_output_path() {
266        let temp_dir = TempDir::new().unwrap();
267
268        // Test without frontend
269        let project_info = ProjectInfo {
270            root_path: temp_dir.path().to_path_buf(),
271            src_tauri_path: temp_dir.path().join("src-tauri"),
272            tauri_config_path: None,
273        };
274
275        let scanner = ProjectScanner::new();
276        assert_eq!(
277            scanner.get_recommended_output_path(&project_info),
278            "./generated"
279        );
280
281        // Test with frontend
282        let package_json = temp_dir.path().join("package.json");
283        fs::write(&package_json, r#"{"name": "test"}"#).unwrap();
284
285        assert_eq!(
286            scanner.get_recommended_output_path(&project_info),
287            "./src/generated"
288        );
289    }
290}