socket_patch_core/package_json/
find.rs1use std::path::{Path, PathBuf};
2use tokio::fs;
3
4use super::detect::PackageManager;
5
6pub async fn detect_package_manager(start_path: &Path) -> PackageManager {
9 for name in &["pnpm-lock.yaml", "pnpm-lock.yml", "pnpm-workspace.yaml"] {
10 if fs::metadata(start_path.join(name)).await.is_ok() {
11 return PackageManager::Pnpm;
12 }
13 }
14 PackageManager::Npm
15}
16
17#[derive(Debug, Clone)]
19pub enum WorkspaceType {
20 Npm,
21 Pnpm,
22 None,
23}
24
25#[derive(Debug, Clone)]
27pub struct WorkspaceConfig {
28 pub ws_type: WorkspaceType,
29 pub patterns: Vec<String>,
30}
31
32#[derive(Debug, Clone)]
34pub struct PackageJsonLocation {
35 pub path: PathBuf,
36 pub is_root: bool,
37 pub is_workspace: bool,
38 pub workspace_pattern: Option<String>,
39}
40
41#[derive(Debug)]
43pub struct PackageJsonFindResult {
44 pub files: Vec<PackageJsonLocation>,
45 pub workspace_type: WorkspaceType,
46}
47
48pub async fn find_package_json_files(
50 start_path: &Path,
51) -> PackageJsonFindResult {
52 let mut results = Vec::new();
53 let root_package_json = start_path.join("package.json");
54
55 let mut root_exists = false;
56 let mut workspace_config = WorkspaceConfig {
57 ws_type: WorkspaceType::None,
58 patterns: Vec::new(),
59 };
60
61 if fs::metadata(&root_package_json).await.is_ok() {
62 root_exists = true;
63 workspace_config = detect_workspaces(&root_package_json).await;
64 results.push(PackageJsonLocation {
65 path: root_package_json,
66 is_root: true,
67 is_workspace: false,
68 workspace_pattern: None,
69 });
70 }
71
72 match workspace_config.ws_type {
73 WorkspaceType::None => {
74 if root_exists {
75 let nested = find_nested_package_json_files(start_path).await;
76 results.extend(nested);
77 }
78 }
79 _ => {
80 let ws_packages =
81 find_workspace_packages(start_path, &workspace_config).await;
82 results.extend(ws_packages);
83 }
84 }
85
86 PackageJsonFindResult {
87 files: results,
88 workspace_type: workspace_config.ws_type,
89 }
90}
91
92pub async fn detect_workspaces(package_json_path: &Path) -> WorkspaceConfig {
94 let default = WorkspaceConfig {
95 ws_type: WorkspaceType::None,
96 patterns: Vec::new(),
97 };
98
99 let content = match fs::read_to_string(package_json_path).await {
100 Ok(c) => c,
101 Err(_) => return default,
102 };
103
104 let pkg: serde_json::Value = match serde_json::from_str(&content) {
105 Ok(v) => v,
106 Err(_) => return default,
107 };
108
109 let dir = package_json_path.parent().unwrap_or(Path::new("."));
113 let pnpm_workspace = dir.join("pnpm-workspace.yaml");
114 if let Ok(yaml_content) = fs::read_to_string(&pnpm_workspace).await {
115 let patterns = parse_pnpm_workspace_patterns(&yaml_content);
116 return WorkspaceConfig {
117 ws_type: WorkspaceType::Pnpm,
118 patterns,
119 };
120 }
121
122 if let Some(workspaces) = pkg.get("workspaces") {
124 let patterns = if let Some(arr) = workspaces.as_array() {
125 arr.iter()
126 .filter_map(|v| v.as_str().map(String::from))
127 .collect()
128 } else if let Some(obj) = workspaces.as_object() {
129 obj.get("packages")
130 .and_then(|v| v.as_array())
131 .map(|arr| {
132 arr.iter()
133 .filter_map(|v| v.as_str().map(String::from))
134 .collect()
135 })
136 .unwrap_or_default()
137 } else {
138 Vec::new()
139 };
140
141 return WorkspaceConfig {
142 ws_type: WorkspaceType::Npm,
143 patterns,
144 };
145 }
146
147 default
148}
149
150fn parse_pnpm_workspace_patterns(yaml_content: &str) -> Vec<String> {
152 let mut patterns = Vec::new();
153 let mut in_packages = false;
154
155 for line in yaml_content.lines() {
156 let trimmed = line.trim();
157
158 if trimmed == "packages:" {
159 in_packages = true;
160 continue;
161 }
162
163 if in_packages {
164 if !trimmed.is_empty()
165 && !trimmed.starts_with('-')
166 && !trimmed.starts_with('#')
167 {
168 break;
169 }
170
171 if let Some(rest) = trimmed.strip_prefix('-') {
172 let item = rest.trim().trim_matches('\'').trim_matches('"');
173 if !item.is_empty() {
174 patterns.push(item.to_string());
175 }
176 }
177 }
178 }
179
180 patterns
181}
182
183async fn find_workspace_packages(
185 root_path: &Path,
186 config: &WorkspaceConfig,
187) -> Vec<PackageJsonLocation> {
188 let mut results = Vec::new();
189
190 for pattern in &config.patterns {
191 let packages = find_packages_matching_pattern(root_path, pattern).await;
192 for p in packages {
193 results.push(PackageJsonLocation {
194 path: p,
195 is_root: false,
196 is_workspace: true,
197 workspace_pattern: Some(pattern.clone()),
198 });
199 }
200 }
201
202 results
203}
204
205async fn find_packages_matching_pattern(
207 root_path: &Path,
208 pattern: &str,
209) -> Vec<PathBuf> {
210 let mut results = Vec::new();
211 let parts: Vec<&str> = pattern.split('/').collect();
212
213 if parts.len() == 2 && parts[1] == "*" {
214 let search_path = root_path.join(parts[0]);
215 search_one_level(&search_path, &mut results).await;
216 } else if parts.len() == 2 && parts[1] == "**" {
217 let search_path = root_path.join(parts[0]);
218 search_recursive(&search_path, &mut results).await;
219 } else {
220 let pkg_json = root_path.join(pattern).join("package.json");
221 if fs::metadata(&pkg_json).await.is_ok() {
222 results.push(pkg_json);
223 }
224 }
225
226 results
227}
228
229async fn search_one_level(dir: &Path, results: &mut Vec<PathBuf>) {
231 let mut entries = match fs::read_dir(dir).await {
232 Ok(e) => e,
233 Err(_) => return,
234 };
235
236 while let Ok(Some(entry)) = entries.next_entry().await {
237 let ft = match entry.file_type().await {
238 Ok(ft) => ft,
239 Err(_) => continue,
240 };
241 if !ft.is_dir() {
242 continue;
243 }
244 let pkg_json = entry.path().join("package.json");
245 if fs::metadata(&pkg_json).await.is_ok() {
246 results.push(pkg_json);
247 }
248 }
249}
250
251async fn search_recursive(dir: &Path, results: &mut Vec<PathBuf>) {
253 let mut entries = match fs::read_dir(dir).await {
254 Ok(e) => e,
255 Err(_) => return,
256 };
257
258 while let Ok(Some(entry)) = entries.next_entry().await {
259 let ft = match entry.file_type().await {
260 Ok(ft) => ft,
261 Err(_) => continue,
262 };
263 if !ft.is_dir() {
264 continue;
265 }
266
267 let name = entry.file_name();
268 let name_str = name.to_string_lossy();
269
270 if name_str.starts_with('.')
272 || name_str == "node_modules"
273 || name_str == "dist"
274 || name_str == "build"
275 {
276 continue;
277 }
278
279 let full_path = entry.path();
280 let pkg_json = full_path.join("package.json");
281 if fs::metadata(&pkg_json).await.is_ok() {
282 results.push(pkg_json);
283 }
284
285 Box::pin(search_recursive(&full_path, results)).await;
286 }
287}
288
289async fn find_nested_package_json_files(
291 start_path: &Path,
292) -> Vec<PackageJsonLocation> {
293 let mut results = Vec::new();
294 let root_pkg = start_path.join("package.json");
295 search_nested(start_path, &root_pkg, 0, &mut results).await;
296 results
297}
298
299async fn search_nested(
300 dir: &Path,
301 root_pkg: &Path,
302 depth: usize,
303 results: &mut Vec<PackageJsonLocation>,
304) {
305 if depth > 5 {
306 return;
307 }
308
309 let mut entries = match fs::read_dir(dir).await {
310 Ok(e) => e,
311 Err(_) => return,
312 };
313
314 while let Ok(Some(entry)) = entries.next_entry().await {
315 let ft = match entry.file_type().await {
316 Ok(ft) => ft,
317 Err(_) => continue,
318 };
319 if !ft.is_dir() {
320 continue;
321 }
322
323 let name = entry.file_name();
324 let name_str = name.to_string_lossy();
325
326 if name_str.starts_with('.')
327 || name_str == "node_modules"
328 || name_str == "dist"
329 || name_str == "build"
330 {
331 continue;
332 }
333
334 let full_path = entry.path();
335 let pkg_json = full_path.join("package.json");
336 if fs::metadata(&pkg_json).await.is_ok() && pkg_json != root_pkg {
337 results.push(PackageJsonLocation {
338 path: pkg_json,
339 is_root: false,
340 is_workspace: false,
341 workspace_pattern: None,
342 });
343 }
344
345 Box::pin(search_nested(&full_path, root_pkg, depth + 1, results)).await;
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
356 fn test_parse_pnpm_basic() {
357 let yaml = "packages:\n - packages/*";
358 assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]);
359 }
360
361 #[test]
362 fn test_parse_pnpm_multiple_patterns() {
363 let yaml = "packages:\n - packages/*\n - apps/*\n - tools/*";
364 assert_eq!(
365 parse_pnpm_workspace_patterns(yaml),
366 vec!["packages/*", "apps/*", "tools/*"]
367 );
368 }
369
370 #[test]
371 fn test_parse_pnpm_quoted_patterns() {
372 let yaml = "packages:\n - 'packages/*'\n - \"apps/*\"";
373 assert_eq!(
374 parse_pnpm_workspace_patterns(yaml),
375 vec!["packages/*", "apps/*"]
376 );
377 }
378
379 #[test]
380 fn test_parse_pnpm_comments_interspersed() {
381 let yaml = "packages:\n # workspace packages\n - packages/*\n # apps\n - apps/*";
382 assert_eq!(
383 parse_pnpm_workspace_patterns(yaml),
384 vec!["packages/*", "apps/*"]
385 );
386 }
387
388 #[test]
389 fn test_parse_pnpm_empty_content() {
390 assert!(parse_pnpm_workspace_patterns("").is_empty());
391 }
392
393 #[test]
394 fn test_parse_pnpm_no_packages_key() {
395 let yaml = "name: my-project\nversion: 1.0.0";
396 assert!(parse_pnpm_workspace_patterns(yaml).is_empty());
397 }
398
399 #[test]
400 fn test_parse_pnpm_stops_at_next_section() {
401 let yaml = "packages:\n - packages/*\ncatalog:\n lodash: 4.17.21";
402 assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]);
403 }
404
405 #[test]
406 fn test_parse_pnpm_indented_key() {
407 let yaml = " packages:\n - packages/*";
409 assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]);
410 }
411
412 #[test]
413 fn test_parse_pnpm_dash_only_line() {
414 let yaml = "packages:\n -\n - packages/*";
415 assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]);
417 }
418
419 #[test]
420 fn test_parse_pnpm_glob_star_star() {
421 let yaml = "packages:\n - packages/**";
422 assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/**"]);
423 }
424
425 #[tokio::test]
428 async fn test_detect_workspaces_npm_array() {
429 let dir = tempfile::tempdir().unwrap();
430 let pkg = dir.path().join("package.json");
431 fs::write(&pkg, r#"{"workspaces": ["packages/*"]}"#)
432 .await
433 .unwrap();
434 let config = detect_workspaces(&pkg).await;
435 assert!(matches!(config.ws_type, WorkspaceType::Npm));
436 assert_eq!(config.patterns, vec!["packages/*"]);
437 }
438
439 #[tokio::test]
440 async fn test_detect_workspaces_npm_object() {
441 let dir = tempfile::tempdir().unwrap();
442 let pkg = dir.path().join("package.json");
443 fs::write(
444 &pkg,
445 r#"{"workspaces": {"packages": ["packages/*", "apps/*"]}}"#,
446 )
447 .await
448 .unwrap();
449 let config = detect_workspaces(&pkg).await;
450 assert!(matches!(config.ws_type, WorkspaceType::Npm));
451 assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
452 }
453
454 #[tokio::test]
455 async fn test_detect_workspaces_pnpm() {
456 let dir = tempfile::tempdir().unwrap();
457 let pkg = dir.path().join("package.json");
458 fs::write(&pkg, r#"{"name": "root"}"#).await.unwrap();
459 let pnpm = dir.path().join("pnpm-workspace.yaml");
460 fs::write(&pnpm, "packages:\n - packages/*")
461 .await
462 .unwrap();
463 let config = detect_workspaces(&pkg).await;
464 assert!(matches!(config.ws_type, WorkspaceType::Pnpm));
465 assert_eq!(config.patterns, vec!["packages/*"]);
466 }
467
468 #[tokio::test]
469 async fn test_detect_workspaces_pnpm_with_workspaces_field() {
470 let dir = tempfile::tempdir().unwrap();
473 let pkg = dir.path().join("package.json");
474 fs::write(
475 &pkg,
476 r#"{"name": "root", "workspaces": ["packages/*"]}"#,
477 )
478 .await
479 .unwrap();
480 let pnpm = dir.path().join("pnpm-workspace.yaml");
481 fs::write(&pnpm, "packages:\n - workspaces/*")
482 .await
483 .unwrap();
484 let config = detect_workspaces(&pkg).await;
485 assert!(matches!(config.ws_type, WorkspaceType::Pnpm));
486 assert_eq!(config.patterns, vec!["workspaces/*"]);
488 }
489
490 #[tokio::test]
491 async fn test_detect_workspaces_none() {
492 let dir = tempfile::tempdir().unwrap();
493 let pkg = dir.path().join("package.json");
494 fs::write(&pkg, r#"{"name": "root"}"#).await.unwrap();
495 let config = detect_workspaces(&pkg).await;
496 assert!(matches!(config.ws_type, WorkspaceType::None));
497 assert!(config.patterns.is_empty());
498 }
499
500 #[tokio::test]
501 async fn test_detect_workspaces_invalid_json() {
502 let dir = tempfile::tempdir().unwrap();
503 let pkg = dir.path().join("package.json");
504 fs::write(&pkg, "not valid json!!!").await.unwrap();
505 let config = detect_workspaces(&pkg).await;
506 assert!(matches!(config.ws_type, WorkspaceType::None));
507 }
508
509 #[tokio::test]
510 async fn test_detect_workspaces_file_not_found() {
511 let dir = tempfile::tempdir().unwrap();
512 let pkg = dir.path().join("nonexistent.json");
513 let config = detect_workspaces(&pkg).await;
514 assert!(matches!(config.ws_type, WorkspaceType::None));
515 }
516
517 #[tokio::test]
518 async fn test_find_no_root_package_json() {
519 let dir = tempfile::tempdir().unwrap();
520 let result = find_package_json_files(dir.path()).await;
521 assert!(result.files.is_empty());
522 }
523
524 #[tokio::test]
525 async fn test_find_root_only() {
526 let dir = tempfile::tempdir().unwrap();
527 fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#)
528 .await
529 .unwrap();
530 let result = find_package_json_files(dir.path()).await;
531 assert_eq!(result.files.len(), 1);
532 assert!(result.files[0].is_root);
533 }
534
535 #[tokio::test]
536 async fn test_find_npm_workspaces() {
537 let dir = tempfile::tempdir().unwrap();
538 fs::write(
539 dir.path().join("package.json"),
540 r#"{"workspaces": ["packages/*"]}"#,
541 )
542 .await
543 .unwrap();
544 let pkg_a = dir.path().join("packages").join("a");
545 fs::create_dir_all(&pkg_a).await.unwrap();
546 fs::write(pkg_a.join("package.json"), r#"{"name":"a"}"#)
547 .await
548 .unwrap();
549 let result = find_package_json_files(dir.path()).await;
550 assert!(matches!(result.workspace_type, WorkspaceType::Npm));
551 assert_eq!(result.files.len(), 2);
553 assert!(result.files[0].is_root);
554 assert!(result.files[1].is_workspace);
555 }
556
557 #[tokio::test]
558 async fn test_find_pnpm_workspaces() {
559 let dir = tempfile::tempdir().unwrap();
560 fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#)
561 .await
562 .unwrap();
563 fs::write(
564 dir.path().join("pnpm-workspace.yaml"),
565 "packages:\n - packages/*",
566 )
567 .await
568 .unwrap();
569 let pkg_a = dir.path().join("packages").join("a");
570 fs::create_dir_all(&pkg_a).await.unwrap();
571 fs::write(pkg_a.join("package.json"), r#"{"name":"a"}"#)
572 .await
573 .unwrap();
574 let result = find_package_json_files(dir.path()).await;
575 assert!(matches!(result.workspace_type, WorkspaceType::Pnpm));
576 assert_eq!(result.files.len(), 2);
579 assert!(result.files[0].is_root);
580 assert!(result.files[1].is_workspace);
581 }
582
583 #[tokio::test]
584 async fn test_find_nested_skips_node_modules() {
585 let dir = tempfile::tempdir().unwrap();
586 fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#)
587 .await
588 .unwrap();
589 let nm = dir.path().join("node_modules").join("lodash");
590 fs::create_dir_all(&nm).await.unwrap();
591 fs::write(nm.join("package.json"), r#"{"name":"lodash"}"#)
592 .await
593 .unwrap();
594 let result = find_package_json_files(dir.path()).await;
595 assert_eq!(result.files.len(), 1);
597 assert!(result.files[0].is_root);
598 }
599
600 #[tokio::test]
601 async fn test_find_nested_depth_limit() {
602 let dir = tempfile::tempdir().unwrap();
603 fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#)
604 .await
605 .unwrap();
606 let mut deep = dir.path().to_path_buf();
608 for i in 0..7 {
609 deep = deep.join(format!("level{}", i));
610 }
611 fs::create_dir_all(&deep).await.unwrap();
612 fs::write(deep.join("package.json"), r#"{"name":"deep"}"#)
613 .await
614 .unwrap();
615 let result = find_package_json_files(dir.path()).await;
616 assert_eq!(result.files.len(), 1);
618 }
619
620 #[tokio::test]
621 async fn test_find_workspace_double_glob() {
622 let dir = tempfile::tempdir().unwrap();
623 fs::write(
624 dir.path().join("package.json"),
625 r#"{"workspaces": ["apps/**"]}"#,
626 )
627 .await
628 .unwrap();
629 let nested = dir.path().join("apps").join("web").join("client");
630 fs::create_dir_all(&nested).await.unwrap();
631 fs::write(nested.join("package.json"), r#"{"name":"client"}"#)
632 .await
633 .unwrap();
634 let result = find_package_json_files(dir.path()).await;
635 assert!(result.files.len() >= 2);
637 }
638
639 #[tokio::test]
640 async fn test_find_workspace_exact_path() {
641 let dir = tempfile::tempdir().unwrap();
642 fs::write(
643 dir.path().join("package.json"),
644 r#"{"workspaces": ["packages/core"]}"#,
645 )
646 .await
647 .unwrap();
648 let core = dir.path().join("packages").join("core");
649 fs::create_dir_all(&core).await.unwrap();
650 fs::write(core.join("package.json"), r#"{"name":"core"}"#)
651 .await
652 .unwrap();
653 let result = find_package_json_files(dir.path()).await;
654 assert_eq!(result.files.len(), 2);
655 }
656
657 #[tokio::test]
660 async fn test_detect_npm_by_default() {
661 let dir = tempfile::tempdir().unwrap();
662 let pm = detect_package_manager(dir.path()).await;
663 assert_eq!(pm, PackageManager::Npm);
664 }
665
666 #[tokio::test]
667 async fn test_detect_pnpm_lock_yaml() {
668 let dir = tempfile::tempdir().unwrap();
669 fs::write(dir.path().join("pnpm-lock.yaml"), "lockfileVersion: 9.0\n")
670 .await
671 .unwrap();
672 let pm = detect_package_manager(dir.path()).await;
673 assert_eq!(pm, PackageManager::Pnpm);
674 }
675
676 #[tokio::test]
677 async fn test_detect_pnpm_workspace_yaml() {
678 let dir = tempfile::tempdir().unwrap();
679 fs::write(
680 dir.path().join("pnpm-workspace.yaml"),
681 "packages:\n - packages/*",
682 )
683 .await
684 .unwrap();
685 let pm = detect_package_manager(dir.path()).await;
686 assert_eq!(pm, PackageManager::Pnpm);
687 }
688}