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