1use crate::core::{Artifact, ArtifactKind, ArtifactMetadata, MarkerKind, ProjectKind, ProjectMarker};
4use crate::error::Result;
5use crate::plugins::Plugin;
6use std::path::{Path, PathBuf};
7
8pub 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 if !path.join("package.json").is_file() {
57 return None;
58 }
59
60 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 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 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 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 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 let build = project_root.join("build");
134 if build.exists() && build.is_dir() {
135 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 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 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 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 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 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 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 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 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}