Skip to main content

socket_patch_core/package_json/
find.rs

1use std::path::{Path, PathBuf};
2use tokio::fs;
3
4use super::detect::PackageManager;
5
6/// Detect the package manager based on lockfiles in the project root.
7/// Checks for pnpm-lock.yaml, pnpm-lock.yml, and pnpm-workspace.yaml.
8pub 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/// Workspace configuration type.
18#[derive(Debug, Clone)]
19pub enum WorkspaceType {
20    Npm,
21    Pnpm,
22    None,
23}
24
25/// Workspace configuration.
26#[derive(Debug, Clone)]
27pub struct WorkspaceConfig {
28    pub ws_type: WorkspaceType,
29    pub patterns: Vec<String>,
30}
31
32/// Location of a discovered package.json file.
33#[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/// Result of finding package.json files.
42#[derive(Debug)]
43pub struct PackageJsonFindResult {
44    pub files: Vec<PackageJsonLocation>,
45    pub workspace_type: WorkspaceType,
46}
47
48/// Find all package.json files, respecting workspace configurations.
49pub 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
92/// Detect workspace configuration from package.json.
93pub 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    // Check for pnpm workspaces first — pnpm projects may also have
110    // "workspaces" in package.json for compatibility, but
111    // pnpm-workspace.yaml is the definitive signal.
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    // Check for npm/yarn workspaces
123    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
150/// Simple parser for pnpm-workspace.yaml packages field.
151fn 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
183/// Find workspace packages based on workspace patterns.
184async 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
205/// Find packages matching a workspace pattern.
206async 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
229/// Search one level deep for package.json files.
230async 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
251/// Search recursively for package.json files.
252async 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        // Skip hidden directories, node_modules, dist, build
271        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
289/// Find nested package.json files without workspace configuration.
290async 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    // ── Group 1: parse_pnpm_workspace_patterns ───────────────────────
354
355    #[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        // The parser uses `trimmed == "packages:"` so leading spaces should match
408        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        // A bare "-" with no value should be skipped (empty after trim)
416        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    // ── Group 2: workspace detection + file discovery ────────────────
426
427    #[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        // When both pnpm-workspace.yaml AND "workspaces" in package.json
471        // exist, pnpm should take priority
472        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        // Should use pnpm-workspace.yaml patterns, not package.json workspaces
487        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        // root + workspace member
552        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        // find_package_json_files still returns all files;
577        // filtering for pnpm is done by the caller (setup command)
578        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        // Only root, node_modules should be skipped
596        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        // Create deeply nested package.json at depth 7 (> limit of 5)
607        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        // Only root (the deep one exceeds depth limit)
617        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        // root + recursively found workspace member
636        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    // ── detect_package_manager ──────────────────────────────────────
658
659    #[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}