Skip to main content

socket_patch_core/package_json/
find.rs

1use std::path::{Path, PathBuf};
2use tokio::fs;
3
4/// Workspace configuration type.
5#[derive(Debug, Clone)]
6pub enum WorkspaceType {
7    Npm,
8    Pnpm,
9    None,
10}
11
12/// Workspace configuration.
13#[derive(Debug, Clone)]
14pub struct WorkspaceConfig {
15    pub ws_type: WorkspaceType,
16    pub patterns: Vec<String>,
17}
18
19/// Location of a discovered package.json file.
20#[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/// Find all package.json files, respecting workspace configurations.
29pub 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
69/// Detect workspace configuration from package.json.
70pub 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    // Check for npm/yarn workspaces
87    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    // Check for pnpm workspaces
112    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
125/// Simple parser for pnpm-workspace.yaml packages field.
126fn 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
158/// Find workspace packages based on workspace patterns.
159async 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
180/// Find packages matching a workspace pattern.
181async 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
204/// Search one level deep for package.json files.
205async 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
226/// Search recursively for package.json files.
227async 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        // Skip hidden directories, node_modules, dist, build
246        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
264/// Find nested package.json files without workspace configuration.
265async 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    // ── Group 1: parse_pnpm_workspace_patterns ───────────────────────
329
330    #[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        // The parser uses `trimmed == "packages:"` so leading spaces should match
383        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        // A bare "-" with no value should be skipped (empty after trim)
391        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    // ── Group 2: workspace detection + file discovery ────────────────
401
402    #[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        // root + workspace member
504        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        // Only root, node_modules should be skipped
545        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        // Create deeply nested package.json at depth 7 (> limit of 5)
556        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        // Only root (the deep one exceeds depth limit)
566        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        // root + recursively found workspace member
585        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}