syncable_cli/analyzer/runtime/
javascript.rs

1use log::{debug, info};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
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!(
100            "Detecting JavaScript runtime and package manager for project: {}",
101            self.project_path.display()
102        );
103
104        let mut detected_lockfiles = Vec::new();
105        let has_package_json = self.project_path.join("package.json").exists();
106
107        debug!("Has package.json: {}", has_package_json);
108
109        // Priority 1: Check for lock files (highest confidence)
110        let lockfile_detection = self.detect_by_lockfiles(&mut detected_lockfiles);
111        if let Some((runtime, manager)) = lockfile_detection {
112            info!(
113                "Detected {} runtime with {} package manager via lockfile",
114                runtime.as_str(),
115                manager.as_str()
116            );
117            return RuntimeDetectionResult {
118                runtime,
119                package_manager: manager,
120                detected_lockfiles,
121                has_package_json,
122                has_engines_field: false,
123                confidence: DetectionConfidence::High,
124            };
125        }
126
127        // Priority 2: Check package.json engines field (high confidence)
128        let engines_result = self.detect_by_engines_field();
129        if let Some((runtime, manager)) = engines_result {
130            info!(
131                "Detected {} runtime with {} package manager via engines field",
132                runtime.as_str(),
133                manager.as_str()
134            );
135            return RuntimeDetectionResult {
136                runtime,
137                package_manager: manager,
138                detected_lockfiles,
139                has_package_json,
140                has_engines_field: true,
141                confidence: DetectionConfidence::High,
142            };
143        }
144
145        // Priority 3: Check for common Bun-specific files (medium confidence)
146        if self.has_bun_specific_files() {
147            info!("Detected Bun-specific files, assuming Bun runtime");
148            return RuntimeDetectionResult {
149                runtime: JavaScriptRuntime::Bun,
150                package_manager: PackageManager::Bun,
151                detected_lockfiles,
152                has_package_json,
153                has_engines_field: false,
154                confidence: DetectionConfidence::Medium,
155            };
156        }
157
158        // Priority 4: Default behavior based on project type
159        if has_package_json {
160            debug!(
161                "Package.json exists but no specific runtime detected, defaulting to Node.js with npm"
162            );
163            RuntimeDetectionResult {
164                runtime: JavaScriptRuntime::Node,
165                package_manager: PackageManager::Npm,
166                detected_lockfiles,
167                has_package_json,
168                has_engines_field: false,
169                confidence: DetectionConfidence::Low,
170            }
171        } else {
172            debug!("No package.json found, not a JavaScript project");
173            RuntimeDetectionResult {
174                runtime: JavaScriptRuntime::Unknown,
175                package_manager: PackageManager::Unknown,
176                detected_lockfiles,
177                has_package_json,
178                has_engines_field: false,
179                confidence: DetectionConfidence::Low,
180            }
181        }
182    }
183
184    /// Detect all available package managers in the project
185    pub fn detect_all_package_managers(&self) -> Vec<PackageManager> {
186        let mut managers = Vec::new();
187
188        if self.project_path.join("bun.lockb").exists() {
189            managers.push(PackageManager::Bun);
190        }
191        if self.project_path.join("pnpm-lock.yaml").exists() {
192            managers.push(PackageManager::Pnpm);
193        }
194        if self.project_path.join("yarn.lock").exists() {
195            managers.push(PackageManager::Yarn);
196        }
197        if self.project_path.join("package-lock.json").exists() {
198            managers.push(PackageManager::Npm);
199        }
200
201        managers
202    }
203
204    /// Check if this is likely a Bun project
205    pub fn is_bun_project(&self) -> bool {
206        let result = self.detect_js_runtime_and_package_manager();
207        matches!(result.runtime, JavaScriptRuntime::Bun)
208            || matches!(result.package_manager, PackageManager::Bun)
209    }
210
211    /// Check if this is a JavaScript/TypeScript project
212    pub fn is_js_project(&self) -> bool {
213        self.project_path.join("package.json").exists()
214            || self.project_path.join("bun.lockb").exists()
215            || self.project_path.join("package-lock.json").exists()
216            || self.project_path.join("yarn.lock").exists()
217            || self.project_path.join("pnpm-lock.yaml").exists()
218    }
219
220    /// Detect runtime by lock files
221    fn detect_by_lockfiles(
222        &self,
223        detected_lockfiles: &mut Vec<String>,
224    ) -> Option<(JavaScriptRuntime, PackageManager)> {
225        // Check Bun first (as it's the most specific)
226        if self.project_path.join("bun.lockb").exists() {
227            detected_lockfiles.push("bun.lockb".to_string());
228            debug!("Found bun.lockb, using Bun runtime and package manager");
229            return Some((JavaScriptRuntime::Bun, PackageManager::Bun));
230        }
231
232        // Check pnpm-lock.yaml
233        if self.project_path.join("pnpm-lock.yaml").exists() {
234            detected_lockfiles.push("pnpm-lock.yaml".to_string());
235            debug!("Found pnpm-lock.yaml, using Node.js runtime with pnpm");
236            return Some((JavaScriptRuntime::Node, PackageManager::Pnpm));
237        }
238
239        // Check yarn.lock
240        if self.project_path.join("yarn.lock").exists() {
241            detected_lockfiles.push("yarn.lock".to_string());
242            debug!("Found yarn.lock, using Node.js runtime with Yarn");
243            return Some((JavaScriptRuntime::Node, PackageManager::Yarn));
244        }
245
246        // Check package-lock.json
247        if self.project_path.join("package-lock.json").exists() {
248            detected_lockfiles.push("package-lock.json".to_string());
249            debug!("Found package-lock.json, using Node.js runtime with npm");
250            return Some((JavaScriptRuntime::Node, PackageManager::Npm));
251        }
252
253        None
254    }
255
256    /// Detect runtime by engines field in package.json
257    fn detect_by_engines_field(&self) -> Option<(JavaScriptRuntime, PackageManager)> {
258        let package_json_path = self.project_path.join("package.json");
259        if !package_json_path.exists() {
260            return None;
261        }
262
263        match self.read_package_json() {
264            Ok(package_json) => {
265                if let Some(engines) = package_json.get("engines") {
266                    debug!("Found engines field in package.json: {:?}", engines);
267
268                    // Check for Bun engine
269                    if engines.get("bun").is_some() {
270                        debug!("Found bun engine specification");
271                        return Some((JavaScriptRuntime::Bun, PackageManager::Bun));
272                    }
273
274                    // Check for Deno engine (less common but possible)
275                    if engines.get("deno").is_some() {
276                        debug!("Found deno engine specification");
277                        return Some((JavaScriptRuntime::Deno, PackageManager::Unknown));
278                    }
279
280                    // If only node is specified, default to npm
281                    if engines.get("node").is_some() {
282                        debug!("Found node engine specification, using npm as default");
283                        return Some((JavaScriptRuntime::Node, PackageManager::Npm));
284                    }
285                }
286
287                // Check packageManager field (newer npm/yarn feature)
288                if let Some(package_manager) = package_json
289                    .get("packageManager")
290                    .and_then(|pm| pm.as_str())
291                {
292                    debug!("Found packageManager field: {}", package_manager);
293
294                    if package_manager.starts_with("bun") {
295                        return Some((JavaScriptRuntime::Bun, PackageManager::Bun));
296                    } else if package_manager.starts_with("pnpm") {
297                        return Some((JavaScriptRuntime::Node, PackageManager::Pnpm));
298                    } else if package_manager.starts_with("yarn") {
299                        return Some((JavaScriptRuntime::Node, PackageManager::Yarn));
300                    } else if package_manager.starts_with("npm") {
301                        return Some((JavaScriptRuntime::Node, PackageManager::Npm));
302                    }
303                }
304            }
305            Err(e) => {
306                debug!("Failed to read package.json: {}", e);
307            }
308        }
309
310        None
311    }
312
313    /// Check for Bun-specific files
314    fn has_bun_specific_files(&self) -> bool {
315        // Check for bunfig.toml (Bun configuration file)
316        if self.project_path.join("bunfig.toml").exists() {
317            debug!("Found bunfig.toml");
318            return true;
319        }
320
321        // Check for .bunfig.toml (alternative config name)
322        if self.project_path.join(".bunfig.toml").exists() {
323            debug!("Found .bunfig.toml");
324            return true;
325        }
326
327        // Check for bun-specific scripts in package.json
328        if let Ok(package_json) = self.read_package_json()
329            && let Some(scripts) = package_json.get("scripts").and_then(|s| s.as_object())
330        {
331            for script in scripts.values() {
332                if let Some(script_str) = script.as_str()
333                    && (script_str.contains("bun ") || script_str.starts_with("bun"))
334                {
335                    debug!("Found Bun command in scripts: {}", script_str);
336                    return true;
337                }
338            }
339        }
340
341        false
342    }
343
344    /// Read and parse package.json
345    fn read_package_json(&self) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
346        let package_json_path = self.project_path.join("package.json");
347        let content = fs::read_to_string(package_json_path)?;
348        let json: serde_json::Value = serde_json::from_str(&content)?;
349        Ok(json)
350    }
351
352    /// Get recommended audit commands for the project
353    pub fn get_audit_commands(&self) -> Vec<String> {
354        let result = self.detect_js_runtime_and_package_manager();
355        let mut commands = Vec::new();
356
357        // Primary command based on detection
358        commands.push(result.package_manager.audit_command().to_string());
359
360        // Add fallback commands for multiple package managers
361        let all_managers = self.detect_all_package_managers();
362        for manager in all_managers {
363            let cmd = manager.audit_command().to_string();
364            if !commands.contains(&cmd) {
365                commands.push(cmd);
366            }
367        }
368
369        commands
370    }
371
372    /// Get a human-readable summary of the detection
373    pub fn get_detection_summary(&self) -> String {
374        let result = self.detect_js_runtime_and_package_manager();
375
376        let confidence_str = match result.confidence {
377            DetectionConfidence::High => "high confidence",
378            DetectionConfidence::Medium => "medium confidence",
379            DetectionConfidence::Low => "low confidence (default)",
380        };
381
382        let mut summary = format!(
383            "Detected {} runtime with {} package manager ({})",
384            result.runtime.as_str(),
385            result.package_manager.as_str(),
386            confidence_str
387        );
388
389        if !result.detected_lockfiles.is_empty() {
390            summary.push_str(&format!(
391                " - Lock files: {}",
392                result.detected_lockfiles.join(", ")
393            ));
394        }
395
396        if result.has_engines_field {
397            summary.push_str(" - Engines field present");
398        }
399
400        summary
401    }
402}