Skip to main content

null_e/plugins/
node.rs

1//! Node.js/npm/yarn/pnpm/bun plugin
2
3use crate::core::{Artifact, ArtifactKind, ArtifactMetadata, MarkerKind, ProjectKind, ProjectMarker};
4use crate::error::Result;
5use crate::plugins::Plugin;
6use std::path::{Path, PathBuf};
7
8/// Plugin for Node.js ecosystem (npm, yarn, pnpm, bun)
9pub struct NodePlugin;
10
11impl Plugin for NodePlugin {
12    fn id(&self) -> &'static str {
13        "node"
14    }
15
16    fn name(&self) -> &'static str {
17        "Node.js (npm/yarn/pnpm/bun)"
18    }
19
20    fn supported_kinds(&self) -> &[ProjectKind] {
21        &[
22            ProjectKind::NodeNpm,
23            ProjectKind::NodeYarn,
24            ProjectKind::NodePnpm,
25            ProjectKind::NodeBun,
26        ]
27    }
28
29    fn markers(&self) -> Vec<ProjectMarker> {
30        vec![
31            ProjectMarker {
32                indicator: MarkerKind::File("package.json"),
33                kind: ProjectKind::NodeNpm,
34                priority: 50,
35            },
36            ProjectMarker {
37                indicator: MarkerKind::File("yarn.lock"),
38                kind: ProjectKind::NodeYarn,
39                priority: 60,
40            },
41            ProjectMarker {
42                indicator: MarkerKind::File("pnpm-lock.yaml"),
43                kind: ProjectKind::NodePnpm,
44                priority: 60,
45            },
46            ProjectMarker {
47                indicator: MarkerKind::File("bun.lockb"),
48                kind: ProjectKind::NodeBun,
49                priority: 60,
50            },
51        ]
52    }
53
54    fn detect(&self, path: &Path) -> Option<ProjectKind> {
55        // Must have package.json
56        if !path.join("package.json").is_file() {
57            return None;
58        }
59
60        // Determine specific variant by lockfile
61        if path.join("bun.lockb").exists() {
62            Some(ProjectKind::NodeBun)
63        } else if path.join("pnpm-lock.yaml").exists() {
64            Some(ProjectKind::NodePnpm)
65        } else if path.join("yarn.lock").exists() {
66            Some(ProjectKind::NodeYarn)
67        } else {
68            Some(ProjectKind::NodeNpm)
69        }
70    }
71
72    fn find_artifacts(&self, project_root: &Path) -> Result<Vec<Artifact>> {
73        let mut artifacts = Vec::new();
74
75        // node_modules - the big one!
76        let node_modules = project_root.join("node_modules");
77        if node_modules.exists() {
78            artifacts.push(Artifact {
79                path: node_modules,
80                kind: ArtifactKind::Dependencies,
81                size: 0,
82                file_count: 0,
83                age: None,
84                metadata: ArtifactMetadata {
85                    restorable: true,
86                    restore_command: Some(self.restore_command(project_root)),
87                    lockfile: self.find_lockfile(project_root),
88                    ..Default::default()
89                },
90            });
91        }
92
93        // .next (Next.js)
94        let next_dir = project_root.join(".next");
95        if next_dir.exists() {
96            artifacts.push(Artifact {
97                path: next_dir,
98                kind: ArtifactKind::BuildOutput,
99                size: 0,
100                file_count: 0,
101                age: None,
102                metadata: ArtifactMetadata::restorable("npm run build"),
103            });
104        }
105
106        // .nuxt (Nuxt.js)
107        let nuxt_dir = project_root.join(".nuxt");
108        if nuxt_dir.exists() {
109            artifacts.push(Artifact {
110                path: nuxt_dir,
111                kind: ArtifactKind::BuildOutput,
112                size: 0,
113                file_count: 0,
114                age: None,
115                metadata: ArtifactMetadata::restorable("npm run build"),
116            });
117        }
118
119        // dist folder
120        let dist = project_root.join("dist");
121        if dist.exists() && dist.is_dir() {
122            artifacts.push(Artifact {
123                path: dist,
124                kind: ArtifactKind::BuildOutput,
125                size: 0,
126                file_count: 0,
127                age: None,
128                metadata: ArtifactMetadata::restorable("npm run build"),
129            });
130        }
131
132        // build folder (Create React App, etc.)
133        let build = project_root.join("build");
134        if build.exists() && build.is_dir() {
135            // Check if it's a build output, not source
136            if !project_root.join("build/index.html").exists()
137                || project_root.join("src").exists()
138            {
139                artifacts.push(Artifact {
140                    path: build,
141                    kind: ArtifactKind::BuildOutput,
142                    size: 0,
143                    file_count: 0,
144                    age: None,
145                    metadata: ArtifactMetadata::restorable("npm run build"),
146                });
147            }
148        }
149
150        // .cache (various tools)
151        let cache = project_root.join(".cache");
152        if cache.exists() {
153            artifacts.push(Artifact {
154                path: cache,
155                kind: ArtifactKind::Cache,
156                size: 0,
157                file_count: 0,
158                age: None,
159                metadata: ArtifactMetadata::default(),
160            });
161        }
162
163        // .parcel-cache
164        let parcel_cache = project_root.join(".parcel-cache");
165        if parcel_cache.exists() {
166            artifacts.push(Artifact {
167                path: parcel_cache,
168                kind: ArtifactKind::Cache,
169                size: 0,
170                file_count: 0,
171                age: None,
172                metadata: ArtifactMetadata::default(),
173            });
174        }
175
176        // .turbo (Turborepo)
177        let turbo = project_root.join(".turbo");
178        if turbo.exists() {
179            artifacts.push(Artifact {
180                path: turbo,
181                kind: ArtifactKind::Cache,
182                size: 0,
183                file_count: 0,
184                age: None,
185                metadata: ArtifactMetadata::default(),
186            });
187        }
188
189        // coverage (test coverage)
190        let coverage = project_root.join("coverage");
191        if coverage.exists() {
192            artifacts.push(Artifact {
193                path: coverage,
194                kind: ArtifactKind::TestOutput,
195                size: 0,
196                file_count: 0,
197                age: None,
198                metadata: ArtifactMetadata::restorable("npm test -- --coverage"),
199            });
200        }
201
202        // .nyc_output (Istanbul coverage)
203        let nyc = project_root.join(".nyc_output");
204        if nyc.exists() {
205            artifacts.push(Artifact {
206                path: nyc,
207                kind: ArtifactKind::TestOutput,
208                size: 0,
209                file_count: 0,
210                age: None,
211                metadata: ArtifactMetadata::default(),
212            });
213        }
214
215        // storybook-static
216        let storybook = project_root.join("storybook-static");
217        if storybook.exists() {
218            artifacts.push(Artifact {
219                path: storybook,
220                kind: ArtifactKind::BuildOutput,
221                size: 0,
222                file_count: 0,
223                age: None,
224                metadata: ArtifactMetadata::restorable("npm run build-storybook"),
225            });
226        }
227
228        // .svelte-kit
229        let svelte_kit = project_root.join(".svelte-kit");
230        if svelte_kit.exists() {
231            artifacts.push(Artifact {
232                path: svelte_kit,
233                kind: ArtifactKind::BuildOutput,
234                size: 0,
235                file_count: 0,
236                age: None,
237                metadata: ArtifactMetadata::restorable("npm run build"),
238            });
239        }
240
241        // out (Next.js static export)
242        let out = project_root.join("out");
243        if out.exists() && project_root.join("next.config.js").exists() {
244            artifacts.push(Artifact {
245                path: out,
246                kind: ArtifactKind::BuildOutput,
247                size: 0,
248                file_count: 0,
249                age: None,
250                metadata: ArtifactMetadata::restorable("npm run build"),
251            });
252        }
253
254        Ok(artifacts)
255    }
256
257    fn cleanable_dirs(&self) -> &[&'static str] {
258        &[
259            "node_modules",
260            ".next",
261            ".nuxt",
262            ".cache",
263            ".parcel-cache",
264            ".turbo",
265            "coverage",
266            ".nyc_output",
267            "storybook-static",
268            ".svelte-kit",
269        ]
270    }
271
272    fn priority(&self) -> u8 {
273        50
274    }
275}
276
277impl NodePlugin {
278    fn restore_command(&self, path: &Path) -> String {
279        if path.join("bun.lockb").exists() {
280            "bun install".into()
281        } else if path.join("pnpm-lock.yaml").exists() {
282            "pnpm install".into()
283        } else if path.join("yarn.lock").exists() {
284            "yarn install".into()
285        } else {
286            "npm install".into()
287        }
288    }
289
290    fn find_lockfile(&self, path: &Path) -> Option<PathBuf> {
291        let candidates = [
292            "bun.lockb",
293            "pnpm-lock.yaml",
294            "yarn.lock",
295            "package-lock.json",
296        ];
297
298        candidates.iter().map(|f| path.join(f)).find(|p| p.exists())
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use tempfile::TempDir;
306
307    fn setup_node_project(temp: &TempDir) {
308        std::fs::write(temp.path().join("package.json"), r#"{"name": "test"}"#).unwrap();
309    }
310
311    #[test]
312    fn test_detect_npm() {
313        let temp = TempDir::new().unwrap();
314        setup_node_project(&temp);
315
316        let plugin = NodePlugin;
317        assert_eq!(plugin.detect(temp.path()), Some(ProjectKind::NodeNpm));
318    }
319
320    #[test]
321    fn test_detect_yarn() {
322        let temp = TempDir::new().unwrap();
323        setup_node_project(&temp);
324        std::fs::write(temp.path().join("yarn.lock"), "").unwrap();
325
326        let plugin = NodePlugin;
327        assert_eq!(plugin.detect(temp.path()), Some(ProjectKind::NodeYarn));
328    }
329
330    #[test]
331    fn test_detect_pnpm() {
332        let temp = TempDir::new().unwrap();
333        setup_node_project(&temp);
334        std::fs::write(temp.path().join("pnpm-lock.yaml"), "").unwrap();
335
336        let plugin = NodePlugin;
337        assert_eq!(plugin.detect(temp.path()), Some(ProjectKind::NodePnpm));
338    }
339
340    #[test]
341    fn test_find_artifacts() {
342        let temp = TempDir::new().unwrap();
343        setup_node_project(&temp);
344        std::fs::create_dir(temp.path().join("node_modules")).unwrap();
345        std::fs::create_dir(temp.path().join(".next")).unwrap();
346
347        let plugin = NodePlugin;
348        let artifacts = plugin.find_artifacts(temp.path()).unwrap();
349
350        assert_eq!(artifacts.len(), 2);
351        assert!(artifacts.iter().any(|a| a.name() == "node_modules"));
352        assert!(artifacts.iter().any(|a| a.name() == ".next"));
353    }
354
355    #[test]
356    fn test_no_artifacts_without_dirs() {
357        let temp = TempDir::new().unwrap();
358        setup_node_project(&temp);
359
360        let plugin = NodePlugin;
361        let artifacts = plugin.find_artifacts(temp.path()).unwrap();
362
363        assert!(artifacts.is_empty());
364    }
365}