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