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/// Result of finding package.json files.
29#[derive(Debug)]
30pub struct PackageJsonFindResult {
31    pub files: Vec<PackageJsonLocation>,
32    pub workspace_type: WorkspaceType,
33}
34
35/// Find all package.json files, respecting workspace configurations.
36pub 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
79/// Detect workspace configuration from package.json.
80pub 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    // Check for pnpm workspaces first — pnpm projects may also have
97    // "workspaces" in package.json for compatibility, but
98    // pnpm-workspace.yaml is the definitive signal.
99    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    // Check for npm/yarn workspaces
110    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
137/// Simple parser for pnpm-workspace.yaml packages field.
138fn 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
170/// Find workspace packages based on workspace patterns.
171async 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
192/// Find packages matching a workspace pattern.
193async 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
216/// Search one level deep for package.json files.
217async 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
238/// Search recursively for package.json files.
239async 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        // Skip hidden directories, node_modules, dist, build
258        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
276/// Find nested package.json files without workspace configuration.
277async 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    // ── Group 1: parse_pnpm_workspace_patterns ───────────────────────
341
342    #[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        // The parser uses `trimmed == "packages:"` so leading spaces should match
395        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        // A bare "-" with no value should be skipped (empty after trim)
403        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    // ── Group 2: workspace detection + file discovery ────────────────
413
414    #[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        // When both pnpm-workspace.yaml AND "workspaces" in package.json
458        // exist, pnpm should take priority
459        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        // Should use pnpm-workspace.yaml patterns, not package.json workspaces
474        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        // root + workspace member
539        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        // find_package_json_files still returns all files;
564        // filtering for pnpm is done by the caller (setup command)
565        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        // Only root, node_modules should be skipped
583        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        // Create deeply nested package.json at depth 7 (> limit of 5)
594        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        // Only root (the deep one exceeds depth limit)
604        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        // root + recursively found workspace member
623        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}