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(start_path: &Path) -> PackageJsonFindResult {
50    let mut results = Vec::new();
51    let root_package_json = start_path.join("package.json");
52
53    let mut root_exists = false;
54    let mut workspace_config = WorkspaceConfig {
55        ws_type: WorkspaceType::None,
56        patterns: Vec::new(),
57    };
58
59    if fs::metadata(&root_package_json).await.is_ok() {
60        root_exists = true;
61        workspace_config = detect_workspaces(&root_package_json).await;
62        results.push(PackageJsonLocation {
63            path: root_package_json,
64            is_root: true,
65            is_workspace: false,
66            workspace_pattern: None,
67        });
68    }
69
70    match workspace_config.ws_type {
71        WorkspaceType::None => {
72            if root_exists {
73                let nested = find_nested_package_json_files(start_path).await;
74                results.extend(nested);
75            }
76        }
77        _ => {
78            let ws_packages = find_workspace_packages(start_path, &workspace_config).await;
79            results.extend(ws_packages);
80        }
81    }
82
83    // Workspace patterns can overlap (e.g. "packages/*" and "packages/a", or a
84    // glob plus an exact path), which would otherwise yield the same
85    // package.json more than once. De-duplicate by path, preserving discovery
86    // order so the root entry stays first.
87    let mut seen = std::collections::HashSet::new();
88    results.retain(|loc| seen.insert(loc.path.clone()));
89
90    PackageJsonFindResult {
91        files: results,
92        workspace_type: workspace_config.ws_type,
93    }
94}
95
96/// Detect workspace configuration from package.json.
97pub async fn detect_workspaces(package_json_path: &Path) -> WorkspaceConfig {
98    let default = WorkspaceConfig {
99        ws_type: WorkspaceType::None,
100        patterns: Vec::new(),
101    };
102
103    // Check for pnpm workspaces first — pnpm projects may also have
104    // "workspaces" in package.json for compatibility, but pnpm-workspace.yaml
105    // is the definitive signal. It lives next to package.json and does not
106    // depend on package.json being present or even valid JSON, so it must be
107    // checked *before* parsing package.json — otherwise a malformed (e.g.
108    // JSONC, or simply broken) root manifest would wrongly demote a real pnpm
109    // workspace to "no workspace".
110    let dir = package_json_path.parent().unwrap_or(Path::new("."));
111    let pnpm_workspace = dir.join("pnpm-workspace.yaml");
112    if let Ok(yaml_content) = fs::read_to_string(&pnpm_workspace).await {
113        let patterns = parse_pnpm_workspace_patterns(&yaml_content);
114        return WorkspaceConfig {
115            ws_type: WorkspaceType::Pnpm,
116            patterns,
117        };
118    }
119
120    let content = match fs::read_to_string(package_json_path).await {
121        Ok(c) => c,
122        Err(_) => return default,
123    };
124
125    let pkg: serde_json::Value = match serde_json::from_str(&content) {
126        Ok(v) => v,
127        Err(_) => return default,
128    };
129
130    // Check for npm/yarn workspaces
131    if let Some(workspaces) = pkg.get("workspaces") {
132        let patterns = if let Some(arr) = workspaces.as_array() {
133            arr.iter()
134                .filter_map(|v| v.as_str().map(String::from))
135                .collect()
136        } else if let Some(obj) = workspaces.as_object() {
137            obj.get("packages")
138                .and_then(|v| v.as_array())
139                .map(|arr| {
140                    arr.iter()
141                        .filter_map(|v| v.as_str().map(String::from))
142                        .collect()
143                })
144                .unwrap_or_default()
145        } else {
146            Vec::new()
147        };
148
149        return WorkspaceConfig {
150            ws_type: WorkspaceType::Npm,
151            patterns,
152        };
153    }
154
155    default
156}
157
158/// Simple parser for pnpm-workspace.yaml packages field.
159fn parse_pnpm_workspace_patterns(yaml_content: &str) -> Vec<String> {
160    let mut patterns = Vec::new();
161    let mut in_packages = false;
162
163    for line in yaml_content.lines() {
164        let trimmed = line.trim();
165
166        if trimmed == "packages:" {
167            in_packages = true;
168            continue;
169        }
170
171        if in_packages {
172            if !trimmed.is_empty() && !trimmed.starts_with('-') && !trimmed.starts_with('#') {
173                break;
174            }
175
176            if let Some(rest) = trimmed.strip_prefix('-') {
177                let item = parse_yaml_list_value(rest);
178                if !item.is_empty() {
179                    patterns.push(item);
180                }
181            }
182        }
183    }
184
185    patterns
186}
187
188/// Extract the scalar value of a YAML list item, handling surrounding quotes
189/// and trailing inline comments (`# ...`).
190fn parse_yaml_list_value(raw: &str) -> String {
191    let s = raw.trim();
192
193    // Quoted scalar: take the content between the first matching pair of
194    // quotes. Anything after the closing quote (e.g. an inline comment) is
195    // ignored, and a `#` inside the quotes stays part of the value.
196    for q in ['\'', '"'] {
197        if let Some(rest) = s.strip_prefix(q) {
198            if let Some(end) = rest.find(q) {
199                return rest[..end].to_string();
200            }
201        }
202    }
203
204    // Unquoted scalar: a `#` preceded by whitespace begins an inline comment.
205    let bytes = s.as_bytes();
206    let comment_start =
207        (1..bytes.len()).find(|&i| bytes[i] == b'#' && bytes[i - 1].is_ascii_whitespace());
208    let value = match comment_start {
209        Some(idx) => &s[..idx],
210        None => s,
211    };
212    value.trim().to_string()
213}
214
215/// Find workspace packages based on workspace patterns.
216async fn find_workspace_packages(
217    root_path: &Path,
218    config: &WorkspaceConfig,
219) -> Vec<PackageJsonLocation> {
220    let mut results = Vec::new();
221
222    for pattern in &config.patterns {
223        let packages = find_packages_matching_pattern(root_path, pattern).await;
224        for p in packages {
225            results.push(PackageJsonLocation {
226                path: p,
227                is_root: false,
228                is_workspace: true,
229                workspace_pattern: Some(pattern.clone()),
230            });
231        }
232    }
233
234    results
235}
236
237/// Find packages matching a workspace pattern.
238async fn find_packages_matching_pattern(root_path: &Path, pattern: &str) -> Vec<PathBuf> {
239    let mut results = Vec::new();
240
241    // A trailing `*`/`**` segment is a glob; everything before the final `/`
242    // is a (possibly empty, possibly multi-segment) directory prefix. Split on
243    // the *last* `/` so bare globs (`*`, `**`) and deeper prefixes (`a/b/*`)
244    // are handled, not just the two-segment `prefix/*` form.
245    let (prefix, last) = pattern.rsplit_once('/').unwrap_or(("", pattern));
246
247    match last {
248        "*" | "**" => {
249            let search_path = if prefix.is_empty() {
250                root_path.to_path_buf()
251            } else {
252                root_path.join(prefix)
253            };
254            if last == "*" {
255                search_one_level(&search_path, &mut results).await;
256            } else {
257                search_recursive(&search_path, &mut results).await;
258            }
259        }
260        _ => {
261            let pkg_json = root_path.join(pattern).join("package.json");
262            if fs::metadata(&pkg_json).await.is_ok() {
263                results.push(pkg_json);
264            }
265        }
266    }
267
268    results
269}
270
271/// Directories that are never workspace members and must be skipped while
272/// walking the tree (hidden dirs plus dependency/output directories).
273fn is_ignored_dir(name: &str) -> bool {
274    name.starts_with('.') || name == "node_modules" || name == "dist" || name == "build"
275}
276
277/// Search one level deep for package.json files.
278async fn search_one_level(dir: &Path, results: &mut Vec<PathBuf>) {
279    let mut entries = match fs::read_dir(dir).await {
280        Ok(e) => e,
281        Err(_) => return,
282    };
283
284    while let Ok(Some(entry)) = entries.next_entry().await {
285        let ft = match entry.file_type().await {
286            Ok(ft) => ft,
287            Err(_) => continue,
288        };
289        if !ft.is_dir() {
290            continue;
291        }
292        // A `dir/*` pattern must not pick up node_modules/hidden/output dirs as
293        // workspace members, matching the recursive searchers below.
294        if is_ignored_dir(&entry.file_name().to_string_lossy()) {
295            continue;
296        }
297        let pkg_json = entry.path().join("package.json");
298        if fs::metadata(&pkg_json).await.is_ok() {
299            results.push(pkg_json);
300        }
301    }
302}
303
304/// Search recursively for package.json files.
305async fn search_recursive(dir: &Path, results: &mut Vec<PathBuf>) {
306    let mut entries = match fs::read_dir(dir).await {
307        Ok(e) => e,
308        Err(_) => return,
309    };
310
311    while let Ok(Some(entry)) = entries.next_entry().await {
312        let ft = match entry.file_type().await {
313            Ok(ft) => ft,
314            Err(_) => continue,
315        };
316        if !ft.is_dir() {
317            continue;
318        }
319
320        let name = entry.file_name();
321        let name_str = name.to_string_lossy();
322
323        // Skip hidden directories, node_modules, dist, build
324        if is_ignored_dir(&name_str) {
325            continue;
326        }
327
328        let full_path = entry.path();
329        let pkg_json = full_path.join("package.json");
330        if fs::metadata(&pkg_json).await.is_ok() {
331            results.push(pkg_json);
332        }
333
334        Box::pin(search_recursive(&full_path, results)).await;
335    }
336}
337
338/// Find nested package.json files without workspace configuration.
339async fn find_nested_package_json_files(start_path: &Path) -> Vec<PackageJsonLocation> {
340    let mut results = Vec::new();
341    let root_pkg = start_path.join("package.json");
342    search_nested(start_path, &root_pkg, 0, &mut results).await;
343    results
344}
345
346async fn search_nested(
347    dir: &Path,
348    root_pkg: &Path,
349    depth: usize,
350    results: &mut Vec<PackageJsonLocation>,
351) {
352    if depth > 5 {
353        return;
354    }
355
356    let mut entries = match fs::read_dir(dir).await {
357        Ok(e) => e,
358        Err(_) => return,
359    };
360
361    while let Ok(Some(entry)) = entries.next_entry().await {
362        let ft = match entry.file_type().await {
363            Ok(ft) => ft,
364            Err(_) => continue,
365        };
366        if !ft.is_dir() {
367            continue;
368        }
369
370        let name = entry.file_name();
371        let name_str = name.to_string_lossy();
372
373        if is_ignored_dir(&name_str) {
374            continue;
375        }
376
377        let full_path = entry.path();
378        let pkg_json = full_path.join("package.json");
379        if fs::metadata(&pkg_json).await.is_ok() && pkg_json != root_pkg {
380            results.push(PackageJsonLocation {
381                path: pkg_json,
382                is_root: false,
383                is_workspace: false,
384                workspace_pattern: None,
385            });
386        }
387
388        Box::pin(search_nested(&full_path, root_pkg, depth + 1, results)).await;
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    // ── Group 1: parse_pnpm_workspace_patterns ───────────────────────
397
398    #[test]
399    fn test_parse_pnpm_basic() {
400        let yaml = "packages:\n  - packages/*";
401        assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]);
402    }
403
404    #[test]
405    fn test_parse_pnpm_multiple_patterns() {
406        let yaml = "packages:\n  - packages/*\n  - apps/*\n  - tools/*";
407        assert_eq!(
408            parse_pnpm_workspace_patterns(yaml),
409            vec!["packages/*", "apps/*", "tools/*"]
410        );
411    }
412
413    #[test]
414    fn test_parse_pnpm_quoted_patterns() {
415        let yaml = "packages:\n  - 'packages/*'\n  - \"apps/*\"";
416        assert_eq!(
417            parse_pnpm_workspace_patterns(yaml),
418            vec!["packages/*", "apps/*"]
419        );
420    }
421
422    #[test]
423    fn test_parse_pnpm_comments_interspersed() {
424        let yaml = "packages:\n  # workspace packages\n  - packages/*\n  # apps\n  - apps/*";
425        assert_eq!(
426            parse_pnpm_workspace_patterns(yaml),
427            vec!["packages/*", "apps/*"]
428        );
429    }
430
431    #[test]
432    fn test_parse_pnpm_empty_content() {
433        assert!(parse_pnpm_workspace_patterns("").is_empty());
434    }
435
436    #[test]
437    fn test_parse_pnpm_no_packages_key() {
438        let yaml = "name: my-project\nversion: 1.0.0";
439        assert!(parse_pnpm_workspace_patterns(yaml).is_empty());
440    }
441
442    #[test]
443    fn test_parse_pnpm_stops_at_next_section() {
444        let yaml = "packages:\n  - packages/*\ncatalog:\n  lodash: 4.17.21";
445        assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]);
446    }
447
448    #[test]
449    fn test_parse_pnpm_indented_key() {
450        // The parser uses `trimmed == "packages:"` so leading spaces should match
451        let yaml = "  packages:\n  - packages/*";
452        assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]);
453    }
454
455    #[test]
456    fn test_parse_pnpm_dash_only_line() {
457        let yaml = "packages:\n  -\n  - packages/*";
458        // A bare "-" with no value should be skipped (empty after trim)
459        assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]);
460    }
461
462    #[test]
463    fn test_parse_pnpm_glob_star_star() {
464        let yaml = "packages:\n  - packages/**";
465        assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/**"]);
466    }
467
468    // ── Group 2: workspace detection + file discovery ────────────────
469
470    #[tokio::test]
471    async fn test_detect_workspaces_npm_array() {
472        let dir = tempfile::tempdir().unwrap();
473        let pkg = dir.path().join("package.json");
474        fs::write(&pkg, r#"{"workspaces": ["packages/*"]}"#)
475            .await
476            .unwrap();
477        let config = detect_workspaces(&pkg).await;
478        assert!(matches!(config.ws_type, WorkspaceType::Npm));
479        assert_eq!(config.patterns, vec!["packages/*"]);
480    }
481
482    #[tokio::test]
483    async fn test_detect_workspaces_npm_object() {
484        let dir = tempfile::tempdir().unwrap();
485        let pkg = dir.path().join("package.json");
486        fs::write(
487            &pkg,
488            r#"{"workspaces": {"packages": ["packages/*", "apps/*"]}}"#,
489        )
490        .await
491        .unwrap();
492        let config = detect_workspaces(&pkg).await;
493        assert!(matches!(config.ws_type, WorkspaceType::Npm));
494        assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
495    }
496
497    #[tokio::test]
498    async fn test_detect_workspaces_pnpm() {
499        let dir = tempfile::tempdir().unwrap();
500        let pkg = dir.path().join("package.json");
501        fs::write(&pkg, r#"{"name": "root"}"#).await.unwrap();
502        let pnpm = dir.path().join("pnpm-workspace.yaml");
503        fs::write(&pnpm, "packages:\n  - packages/*").await.unwrap();
504        let config = detect_workspaces(&pkg).await;
505        assert!(matches!(config.ws_type, WorkspaceType::Pnpm));
506        assert_eq!(config.patterns, vec!["packages/*"]);
507    }
508
509    #[tokio::test]
510    async fn test_detect_workspaces_pnpm_with_workspaces_field() {
511        // When both pnpm-workspace.yaml AND "workspaces" in package.json
512        // exist, pnpm should take priority
513        let dir = tempfile::tempdir().unwrap();
514        let pkg = dir.path().join("package.json");
515        fs::write(&pkg, r#"{"name": "root", "workspaces": ["packages/*"]}"#)
516            .await
517            .unwrap();
518        let pnpm = dir.path().join("pnpm-workspace.yaml");
519        fs::write(&pnpm, "packages:\n  - workspaces/*")
520            .await
521            .unwrap();
522        let config = detect_workspaces(&pkg).await;
523        assert!(matches!(config.ws_type, WorkspaceType::Pnpm));
524        // Should use pnpm-workspace.yaml patterns, not package.json workspaces
525        assert_eq!(config.patterns, vec!["workspaces/*"]);
526    }
527
528    #[tokio::test]
529    async fn test_detect_workspaces_pnpm_with_malformed_package_json() {
530        // Regression: pnpm-workspace.yaml is the definitive signal and must be
531        // honored even when the root package.json is not valid JSON. Previously
532        // the JSON parse error short-circuited before the pnpm check.
533        let dir = tempfile::tempdir().unwrap();
534        let pkg = dir.path().join("package.json");
535        // JSONC-style comment — valid for some tooling, invalid for serde_json.
536        fs::write(&pkg, "{\n  // a comment\n  \"name\": \"root\"\n}")
537            .await
538            .unwrap();
539        let pnpm = dir.path().join("pnpm-workspace.yaml");
540        fs::write(&pnpm, "packages:\n  - packages/*").await.unwrap();
541        let config = detect_workspaces(&pkg).await;
542        assert!(matches!(config.ws_type, WorkspaceType::Pnpm));
543        assert_eq!(config.patterns, vec!["packages/*"]);
544    }
545
546    #[tokio::test]
547    async fn test_detect_workspaces_none() {
548        let dir = tempfile::tempdir().unwrap();
549        let pkg = dir.path().join("package.json");
550        fs::write(&pkg, r#"{"name": "root"}"#).await.unwrap();
551        let config = detect_workspaces(&pkg).await;
552        assert!(matches!(config.ws_type, WorkspaceType::None));
553        assert!(config.patterns.is_empty());
554    }
555
556    #[tokio::test]
557    async fn test_detect_workspaces_invalid_json() {
558        let dir = tempfile::tempdir().unwrap();
559        let pkg = dir.path().join("package.json");
560        fs::write(&pkg, "not valid json!!!").await.unwrap();
561        let config = detect_workspaces(&pkg).await;
562        assert!(matches!(config.ws_type, WorkspaceType::None));
563    }
564
565    #[tokio::test]
566    async fn test_detect_workspaces_file_not_found() {
567        let dir = tempfile::tempdir().unwrap();
568        let pkg = dir.path().join("nonexistent.json");
569        let config = detect_workspaces(&pkg).await;
570        assert!(matches!(config.ws_type, WorkspaceType::None));
571    }
572
573    #[tokio::test]
574    async fn test_find_no_root_package_json() {
575        let dir = tempfile::tempdir().unwrap();
576        let result = find_package_json_files(dir.path()).await;
577        assert!(result.files.is_empty());
578    }
579
580    #[tokio::test]
581    async fn test_find_root_only() {
582        let dir = tempfile::tempdir().unwrap();
583        fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#)
584            .await
585            .unwrap();
586        let result = find_package_json_files(dir.path()).await;
587        assert_eq!(result.files.len(), 1);
588        assert!(result.files[0].is_root);
589    }
590
591    #[tokio::test]
592    async fn test_find_npm_workspaces() {
593        let dir = tempfile::tempdir().unwrap();
594        fs::write(
595            dir.path().join("package.json"),
596            r#"{"workspaces": ["packages/*"]}"#,
597        )
598        .await
599        .unwrap();
600        let pkg_a = dir.path().join("packages").join("a");
601        fs::create_dir_all(&pkg_a).await.unwrap();
602        fs::write(pkg_a.join("package.json"), r#"{"name":"a"}"#)
603            .await
604            .unwrap();
605        let result = find_package_json_files(dir.path()).await;
606        assert!(matches!(result.workspace_type, WorkspaceType::Npm));
607        // root + workspace member
608        assert_eq!(result.files.len(), 2);
609        assert!(result.files[0].is_root);
610        assert!(result.files[1].is_workspace);
611    }
612
613    #[tokio::test]
614    async fn test_find_pnpm_workspaces() {
615        let dir = tempfile::tempdir().unwrap();
616        fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#)
617            .await
618            .unwrap();
619        fs::write(
620            dir.path().join("pnpm-workspace.yaml"),
621            "packages:\n  - packages/*",
622        )
623        .await
624        .unwrap();
625        let pkg_a = dir.path().join("packages").join("a");
626        fs::create_dir_all(&pkg_a).await.unwrap();
627        fs::write(pkg_a.join("package.json"), r#"{"name":"a"}"#)
628            .await
629            .unwrap();
630        let result = find_package_json_files(dir.path()).await;
631        assert!(matches!(result.workspace_type, WorkspaceType::Pnpm));
632        // find_package_json_files still returns all files;
633        // filtering for pnpm is done by the caller (setup command)
634        assert_eq!(result.files.len(), 2);
635        assert!(result.files[0].is_root);
636        assert!(result.files[1].is_workspace);
637    }
638
639    #[tokio::test]
640    async fn test_find_nested_skips_node_modules() {
641        let dir = tempfile::tempdir().unwrap();
642        fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#)
643            .await
644            .unwrap();
645        let nm = dir.path().join("node_modules").join("lodash");
646        fs::create_dir_all(&nm).await.unwrap();
647        fs::write(nm.join("package.json"), r#"{"name":"lodash"}"#)
648            .await
649            .unwrap();
650        let result = find_package_json_files(dir.path()).await;
651        // Only root, node_modules should be skipped
652        assert_eq!(result.files.len(), 1);
653        assert!(result.files[0].is_root);
654    }
655
656    #[tokio::test]
657    async fn test_find_nested_depth_limit() {
658        let dir = tempfile::tempdir().unwrap();
659        fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#)
660            .await
661            .unwrap();
662        // Create deeply nested package.json at depth 7 (> limit of 5)
663        let mut deep = dir.path().to_path_buf();
664        for i in 0..7 {
665            deep = deep.join(format!("level{}", i));
666        }
667        fs::create_dir_all(&deep).await.unwrap();
668        fs::write(deep.join("package.json"), r#"{"name":"deep"}"#)
669            .await
670            .unwrap();
671        let result = find_package_json_files(dir.path()).await;
672        // Only root (the deep one exceeds depth limit)
673        assert_eq!(result.files.len(), 1);
674    }
675
676    #[tokio::test]
677    async fn test_find_workspace_double_glob() {
678        let dir = tempfile::tempdir().unwrap();
679        fs::write(
680            dir.path().join("package.json"),
681            r#"{"workspaces": ["apps/**"]}"#,
682        )
683        .await
684        .unwrap();
685        let nested = dir.path().join("apps").join("web").join("client");
686        fs::create_dir_all(&nested).await.unwrap();
687        fs::write(nested.join("package.json"), r#"{"name":"client"}"#)
688            .await
689            .unwrap();
690        let result = find_package_json_files(dir.path()).await;
691        // root + recursively found workspace member
692        assert!(result.files.len() >= 2);
693    }
694
695    #[tokio::test]
696    async fn test_find_workspace_exact_path() {
697        let dir = tempfile::tempdir().unwrap();
698        fs::write(
699            dir.path().join("package.json"),
700            r#"{"workspaces": ["packages/core"]}"#,
701        )
702        .await
703        .unwrap();
704        let core = dir.path().join("packages").join("core");
705        fs::create_dir_all(&core).await.unwrap();
706        fs::write(core.join("package.json"), r#"{"name":"core"}"#)
707            .await
708            .unwrap();
709        let result = find_package_json_files(dir.path()).await;
710        assert_eq!(result.files.len(), 2);
711    }
712
713    #[test]
714    fn test_parse_pnpm_inline_comment_stripped() {
715        // A `# ...` inline comment after a pattern must not become part of it.
716        let yaml = "packages:\n  - packages/*  # workspace packages\n  - apps/*\t# trailing tab";
717        assert_eq!(
718            parse_pnpm_workspace_patterns(yaml),
719            vec!["packages/*", "apps/*"]
720        );
721    }
722
723    #[test]
724    fn test_parse_pnpm_quoted_value_keeps_hash() {
725        // A `#` inside quotes is part of the value, not a comment.
726        let yaml = "packages:\n  - 'packages/#weird'  # but this is a comment";
727        assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/#weird"]);
728    }
729
730    #[tokio::test]
731    async fn test_find_overlapping_patterns_no_duplicates() {
732        // "packages/*" and the exact "packages/a" both match the same member;
733        // the result must contain it only once.
734        let dir = tempfile::tempdir().unwrap();
735        fs::write(
736            dir.path().join("package.json"),
737            r#"{"workspaces": ["packages/*", "packages/a"]}"#,
738        )
739        .await
740        .unwrap();
741        let a = dir.path().join("packages").join("a");
742        fs::create_dir_all(&a).await.unwrap();
743        fs::write(a.join("package.json"), r#"{"name":"a"}"#)
744            .await
745            .unwrap();
746        let result = find_package_json_files(dir.path()).await;
747        // root + exactly one workspace member (no duplicate for packages/a)
748        assert_eq!(result.files.len(), 2);
749        assert!(result.files[0].is_root);
750        let workspace_count = result.files.iter().filter(|f| f.is_workspace).count();
751        assert_eq!(workspace_count, 1);
752    }
753
754    #[tokio::test]
755    async fn test_find_star_pattern_skips_node_modules() {
756        // A `packages/*` glob must not treat node_modules (or hidden/output
757        // dirs) as a workspace member, even if they contain a package.json.
758        let dir = tempfile::tempdir().unwrap();
759        fs::write(
760            dir.path().join("package.json"),
761            r#"{"workspaces": ["packages/*"]}"#,
762        )
763        .await
764        .unwrap();
765        let real = dir.path().join("packages").join("real");
766        fs::create_dir_all(&real).await.unwrap();
767        fs::write(real.join("package.json"), r#"{"name":"real"}"#)
768            .await
769            .unwrap();
770        for ignored in ["node_modules", ".cache", "dist", "build"] {
771            let d = dir.path().join("packages").join(ignored);
772            fs::create_dir_all(&d).await.unwrap();
773            fs::write(d.join("package.json"), r#"{"name":"x"}"#)
774                .await
775                .unwrap();
776        }
777        let result = find_package_json_files(dir.path()).await;
778        // root + only the "real" member
779        assert_eq!(result.files.len(), 2);
780        let workspace_count = result.files.iter().filter(|f| f.is_workspace).count();
781        assert_eq!(workspace_count, 1);
782    }
783
784    #[tokio::test]
785    async fn test_find_workspace_bare_star() {
786        // A bare `*` glob means "every immediate subdirectory" and must be
787        // expanded, not treated as a literal directory named `*`.
788        let dir = tempfile::tempdir().unwrap();
789        fs::write(dir.path().join("package.json"), r#"{"workspaces": ["*"]}"#)
790            .await
791            .unwrap();
792        for member in ["a", "b"] {
793            let m = dir.path().join(member);
794            fs::create_dir_all(&m).await.unwrap();
795            fs::write(m.join("package.json"), r#"{"name":"m"}"#)
796                .await
797                .unwrap();
798        }
799        // node_modules must still be ignored even for a root-level `*`.
800        let nm = dir.path().join("node_modules").join("dep");
801        fs::create_dir_all(&nm).await.unwrap();
802        fs::write(nm.join("package.json"), r#"{"name":"dep"}"#)
803            .await
804            .unwrap();
805        let result = find_package_json_files(dir.path()).await;
806        let workspace_count = result.files.iter().filter(|f| f.is_workspace).count();
807        // root + members a and b (node_modules excluded)
808        assert_eq!(workspace_count, 2);
809        assert!(result.files[0].is_root);
810    }
811
812    #[tokio::test]
813    async fn test_find_workspace_bare_double_glob() {
814        // A bare `**` glob recurses from the root.
815        let dir = tempfile::tempdir().unwrap();
816        fs::write(dir.path().join("package.json"), r#"{"workspaces": ["**"]}"#)
817            .await
818            .unwrap();
819        let nested = dir.path().join("a").join("b");
820        fs::create_dir_all(&nested).await.unwrap();
821        fs::write(nested.join("package.json"), r#"{"name":"b"}"#)
822            .await
823            .unwrap();
824        let result = find_package_json_files(dir.path()).await;
825        let workspace_count = result.files.iter().filter(|f| f.is_workspace).count();
826        assert!(workspace_count >= 1);
827    }
828
829    #[tokio::test]
830    async fn test_find_workspace_deep_prefix_glob() {
831        // A glob with a multi-segment prefix (`group/sub/*`) must expand the
832        // directory under that prefix, not be treated as a literal path.
833        let dir = tempfile::tempdir().unwrap();
834        fs::write(
835            dir.path().join("package.json"),
836            r#"{"workspaces": ["group/sub/*"]}"#,
837        )
838        .await
839        .unwrap();
840        let member = dir.path().join("group").join("sub").join("pkg");
841        fs::create_dir_all(&member).await.unwrap();
842        fs::write(member.join("package.json"), r#"{"name":"pkg"}"#)
843            .await
844            .unwrap();
845        let result = find_package_json_files(dir.path()).await;
846        let workspace_count = result.files.iter().filter(|f| f.is_workspace).count();
847        assert_eq!(workspace_count, 1);
848    }
849
850    // ── detect_package_manager ──────────────────────────────────────
851
852    #[tokio::test]
853    async fn test_detect_npm_by_default() {
854        let dir = tempfile::tempdir().unwrap();
855        let pm = detect_package_manager(dir.path()).await;
856        assert_eq!(pm, PackageManager::Npm);
857    }
858
859    #[tokio::test]
860    async fn test_detect_pnpm_lock_yaml() {
861        let dir = tempfile::tempdir().unwrap();
862        fs::write(dir.path().join("pnpm-lock.yaml"), "lockfileVersion: 9.0\n")
863            .await
864            .unwrap();
865        let pm = detect_package_manager(dir.path()).await;
866        assert_eq!(pm, PackageManager::Pnpm);
867    }
868
869    #[tokio::test]
870    async fn test_detect_pnpm_workspace_yaml() {
871        let dir = tempfile::tempdir().unwrap();
872        fs::write(
873            dir.path().join("pnpm-workspace.yaml"),
874            "packages:\n  - packages/*",
875        )
876        .await
877        .unwrap();
878        let pm = detect_package_manager(dir.path()).await;
879        assert_eq!(pm, PackageManager::Pnpm);
880    }
881}