syncable_cli/analyzer/runtime/
javascript.rs

1use std::path::PathBuf;
2use std::fs;
3use serde::{Deserialize, Serialize};
4use log::{debug, info};
5
6/// JavaScript runtime types
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub enum JavaScriptRuntime {
9    Bun,
10    Node,
11    Deno,
12    Unknown,
13}
14
15impl JavaScriptRuntime {
16    pub fn as_str(&self) -> &str {
17        match self {
18            JavaScriptRuntime::Bun => "bun",
19            JavaScriptRuntime::Node => "node",
20            JavaScriptRuntime::Deno => "deno",
21            JavaScriptRuntime::Unknown => "unknown",
22        }
23    }
24}
25
26/// Package manager types
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub enum PackageManager {
29    Bun,
30    Npm,
31    Yarn,
32    Pnpm,
33    Unknown,
34}
35
36impl PackageManager {
37    pub fn as_str(&self) -> &str {
38        match self {
39            PackageManager::Bun => "bun",
40            PackageManager::Npm => "npm",
41            PackageManager::Yarn => "yarn",
42            PackageManager::Pnpm => "pnpm",
43            PackageManager::Unknown => "unknown",
44        }
45    }
46    
47    pub fn lockfile_name(&self) -> &str {
48        match self {
49            PackageManager::Bun => "bun.lockb",
50            PackageManager::Npm => "package-lock.json",
51            PackageManager::Yarn => "yarn.lock",
52            PackageManager::Pnpm => "pnpm-lock.yaml",
53            PackageManager::Unknown => "",
54        }
55    }
56    
57    pub fn audit_command(&self) -> &str {
58        match self {
59            PackageManager::Bun => "bun audit",
60            PackageManager::Npm => "npm audit",
61            PackageManager::Yarn => "yarn audit",
62            PackageManager::Pnpm => "pnpm audit",
63            PackageManager::Unknown => "",
64        }
65    }
66}
67
68/// Runtime detection result
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct RuntimeDetectionResult {
71    pub runtime: JavaScriptRuntime,
72    pub package_manager: PackageManager,
73    pub detected_lockfiles: Vec<String>,
74    pub has_package_json: bool,
75    pub has_engines_field: bool,
76    pub confidence: DetectionConfidence,
77}
78
79/// Confidence level for runtime detection
80#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
81pub enum DetectionConfidence {
82    High,    // Lock file present or explicit engine specification
83    Medium,  // Inferred from package.json or common patterns
84    Low,     // Default assumptions
85}
86
87/// Runtime detector for JavaScript/TypeScript projects
88pub struct RuntimeDetector {
89    project_path: PathBuf,
90}
91
92impl RuntimeDetector {
93    pub fn new(project_path: PathBuf) -> Self {
94        Self { project_path }
95    }
96    
97    /// Detect JavaScript runtime and package manager for the project
98    pub fn detect_js_runtime_and_package_manager(&self) -> RuntimeDetectionResult {
99        debug!("Detecting JavaScript runtime and package manager for project: {}", self.project_path.display());
100        
101        let mut detected_lockfiles = Vec::new();
102        let has_package_json = self.project_path.join("package.json").exists();
103        
104        debug!("Has package.json: {}", has_package_json);
105        
106        // Priority 1: Check for lock files (highest confidence)
107        let lockfile_detection = self.detect_by_lockfiles(&mut detected_lockfiles);
108        if let Some((runtime, manager)) = lockfile_detection {
109            info!("Detected {} runtime with {} package manager via lockfile", runtime.as_str(), manager.as_str());
110            return RuntimeDetectionResult {
111                runtime,
112                package_manager: manager,
113                detected_lockfiles,
114                has_package_json,
115                has_engines_field: false,
116                confidence: DetectionConfidence::High,
117            };
118        }
119        
120        // Priority 2: Check package.json engines field (high confidence)
121        let engines_result = self.detect_by_engines_field();
122        if let Some((runtime, manager)) = engines_result {
123            info!("Detected {} runtime with {} package manager via engines field", runtime.as_str(), manager.as_str());
124            return RuntimeDetectionResult {
125                runtime,
126                package_manager: manager,
127                detected_lockfiles,
128                has_package_json,
129                has_engines_field: true,
130                confidence: DetectionConfidence::High,
131            };
132        }
133        
134        // Priority 3: Check for common Bun-specific files (medium confidence)
135        if self.has_bun_specific_files() {
136            info!("Detected Bun-specific files, assuming Bun runtime");
137            return RuntimeDetectionResult {
138                runtime: JavaScriptRuntime::Bun,
139                package_manager: PackageManager::Bun,
140                detected_lockfiles,
141                has_package_json,
142                has_engines_field: false,
143                confidence: DetectionConfidence::Medium,
144            };
145        }
146        
147        // Priority 4: Default behavior based on project type
148        if has_package_json {
149            debug!("Package.json exists but no specific runtime detected, defaulting to Node.js with npm");
150            RuntimeDetectionResult {
151                runtime: JavaScriptRuntime::Node,
152                package_manager: PackageManager::Npm,
153                detected_lockfiles,
154                has_package_json,
155                has_engines_field: false,
156                confidence: DetectionConfidence::Low,
157            }
158        } else {
159            debug!("No package.json found, not a JavaScript project");
160            RuntimeDetectionResult {
161                runtime: JavaScriptRuntime::Unknown,
162                package_manager: PackageManager::Unknown,
163                detected_lockfiles,
164                has_package_json,
165                has_engines_field: false,
166                confidence: DetectionConfidence::Low,
167            }
168        }
169    }
170    
171    /// Detect all available package managers in the project
172    pub fn detect_all_package_managers(&self) -> Vec<PackageManager> {
173        let mut managers = Vec::new();
174        
175        if self.project_path.join("bun.lockb").exists() {
176            managers.push(PackageManager::Bun);
177        }
178        if self.project_path.join("pnpm-lock.yaml").exists() {
179            managers.push(PackageManager::Pnpm);
180        }
181        if self.project_path.join("yarn.lock").exists() {
182            managers.push(PackageManager::Yarn);
183        }
184        if self.project_path.join("package-lock.json").exists() {
185            managers.push(PackageManager::Npm);
186        }
187        
188        managers
189    }
190    
191    /// Check if this is likely a Bun project
192    pub fn is_bun_project(&self) -> bool {
193        let result = self.detect_js_runtime_and_package_manager();
194        matches!(result.runtime, JavaScriptRuntime::Bun) || 
195        matches!(result.package_manager, PackageManager::Bun)
196    }
197    
198    /// Check if this is a JavaScript/TypeScript project
199    pub fn is_js_project(&self) -> bool {
200        self.project_path.join("package.json").exists() ||
201        self.project_path.join("bun.lockb").exists() ||
202        self.project_path.join("package-lock.json").exists() ||
203        self.project_path.join("yarn.lock").exists() ||
204        self.project_path.join("pnpm-lock.yaml").exists()
205    }
206    
207    /// Detect runtime by lock files
208    fn detect_by_lockfiles(&self, detected_lockfiles: &mut Vec<String>) -> Option<(JavaScriptRuntime, PackageManager)> {
209        // Check Bun first (as it's the most specific)
210        if self.project_path.join("bun.lockb").exists() {
211            detected_lockfiles.push("bun.lockb".to_string());
212            debug!("Found bun.lockb, using Bun runtime and package manager");
213            return Some((JavaScriptRuntime::Bun, PackageManager::Bun));
214        }
215        
216        // Check pnpm-lock.yaml
217        if self.project_path.join("pnpm-lock.yaml").exists() {
218            detected_lockfiles.push("pnpm-lock.yaml".to_string());
219            debug!("Found pnpm-lock.yaml, using Node.js runtime with pnpm");
220            return Some((JavaScriptRuntime::Node, PackageManager::Pnpm));
221        }
222        
223        // Check yarn.lock
224        if self.project_path.join("yarn.lock").exists() {
225            detected_lockfiles.push("yarn.lock".to_string());
226            debug!("Found yarn.lock, using Node.js runtime with Yarn");
227            return Some((JavaScriptRuntime::Node, PackageManager::Yarn));
228        }
229        
230        // Check package-lock.json
231        if self.project_path.join("package-lock.json").exists() {
232            detected_lockfiles.push("package-lock.json".to_string());
233            debug!("Found package-lock.json, using Node.js runtime with npm");
234            return Some((JavaScriptRuntime::Node, PackageManager::Npm));
235        }
236        
237        None
238    }
239    
240    /// Detect runtime by engines field in package.json
241    fn detect_by_engines_field(&self) -> Option<(JavaScriptRuntime, PackageManager)> {
242        let package_json_path = self.project_path.join("package.json");
243        if !package_json_path.exists() {
244            return None;
245        }
246        
247        match self.read_package_json() {
248            Ok(package_json) => {
249                if let Some(engines) = package_json.get("engines") {
250                    debug!("Found engines field in package.json: {:?}", engines);
251                    
252                    // Check for Bun engine
253                    if engines.get("bun").is_some() {
254                        debug!("Found bun engine specification");
255                        return Some((JavaScriptRuntime::Bun, PackageManager::Bun));
256                    }
257                    
258                    // Check for Deno engine (less common but possible)
259                    if engines.get("deno").is_some() {
260                        debug!("Found deno engine specification");
261                        return Some((JavaScriptRuntime::Deno, PackageManager::Unknown));
262                    }
263                    
264                    // If only node is specified, default to npm
265                    if engines.get("node").is_some() {
266                        debug!("Found node engine specification, using npm as default");
267                        return Some((JavaScriptRuntime::Node, PackageManager::Npm));
268                    }
269                }
270                
271                // Check packageManager field (newer npm/yarn feature)
272                if let Some(package_manager) = package_json.get("packageManager").and_then(|pm| pm.as_str()) {
273                    debug!("Found packageManager field: {}", package_manager);
274                    
275                    if package_manager.starts_with("bun") {
276                        return Some((JavaScriptRuntime::Bun, PackageManager::Bun));
277                    } else if package_manager.starts_with("pnpm") {
278                        return Some((JavaScriptRuntime::Node, PackageManager::Pnpm));
279                    } else if package_manager.starts_with("yarn") {
280                        return Some((JavaScriptRuntime::Node, PackageManager::Yarn));
281                    } else if package_manager.starts_with("npm") {
282                        return Some((JavaScriptRuntime::Node, PackageManager::Npm));
283                    }
284                }
285            }
286            Err(e) => {
287                debug!("Failed to read package.json: {}", e);
288            }
289        }
290        
291        None
292    }
293    
294    /// Check for Bun-specific files
295    fn has_bun_specific_files(&self) -> bool {
296        // Check for bunfig.toml (Bun configuration file)
297        if self.project_path.join("bunfig.toml").exists() {
298            debug!("Found bunfig.toml");
299            return true;
300        }
301        
302        // Check for .bunfig.toml (alternative config name)
303        if self.project_path.join(".bunfig.toml").exists() {
304            debug!("Found .bunfig.toml");
305            return true;
306        }
307        
308        // Check for bun-specific scripts in package.json
309        if let Ok(package_json) = self.read_package_json() {
310            if let Some(scripts) = package_json.get("scripts").and_then(|s| s.as_object()) {
311                for script in scripts.values() {
312                    if let Some(script_str) = script.as_str() {
313                        if script_str.contains("bun ") || script_str.starts_with("bun") {
314                            debug!("Found Bun command in scripts: {}", script_str);
315                            return true;
316                        }
317                    }
318                }
319            }
320        }
321        
322        false
323    }
324    
325    /// Read and parse package.json
326    fn read_package_json(&self) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
327        let package_json_path = self.project_path.join("package.json");
328        let content = fs::read_to_string(package_json_path)?;
329        let json: serde_json::Value = serde_json::from_str(&content)?;
330        Ok(json)
331    }
332    
333    /// Get recommended audit commands for the project
334    pub fn get_audit_commands(&self) -> Vec<String> {
335        let result = self.detect_js_runtime_and_package_manager();
336        let mut commands = Vec::new();
337        
338        // Primary command based on detection
339        commands.push(result.package_manager.audit_command().to_string());
340        
341        // Add fallback commands for multiple package managers
342        let all_managers = self.detect_all_package_managers();
343        for manager in all_managers {
344            let cmd = manager.audit_command().to_string();
345            if !commands.contains(&cmd) {
346                commands.push(cmd);
347            }
348        }
349        
350        commands
351    }
352    
353    /// Get a human-readable summary of the detection
354    pub fn get_detection_summary(&self) -> String {
355        let result = self.detect_js_runtime_and_package_manager();
356        
357        let confidence_str = match result.confidence {
358            DetectionConfidence::High => "high confidence",
359            DetectionConfidence::Medium => "medium confidence", 
360            DetectionConfidence::Low => "low confidence (default)",
361        };
362        
363        let mut summary = format!(
364            "Detected {} runtime with {} package manager ({})",
365            result.runtime.as_str(),
366            result.package_manager.as_str(),
367            confidence_str
368        );
369        
370        if !result.detected_lockfiles.is_empty() {
371            summary.push_str(&format!(" - Lock files: {}", result.detected_lockfiles.join(", ")));
372        }
373        
374        if result.has_engines_field {
375            summary.push_str(" - Engines field present");
376        }
377        
378        summary
379    }
380}