Skip to main content

fallow_config/workspace/
mod.rs

1mod package_json;
2mod parsers;
3
4use std::path::{Path, PathBuf};
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9pub use package_json::PackageJson;
10pub use parsers::parse_tsconfig_root_dir;
11use parsers::{expand_workspace_glob, parse_pnpm_workspace_yaml, parse_tsconfig_references};
12
13/// Workspace configuration for monorepo support.
14#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
15pub struct WorkspaceConfig {
16    /// Additional workspace patterns (beyond what's in root package.json).
17    #[serde(default)]
18    pub patterns: Vec<String>,
19}
20
21/// Discovered workspace info from package.json, pnpm-workspace.yaml, or tsconfig.json references.
22#[derive(Debug, Clone)]
23pub struct WorkspaceInfo {
24    /// Workspace root path.
25    pub root: PathBuf,
26    /// Package name from package.json.
27    pub name: String,
28    /// Whether this workspace is depended on by other workspaces.
29    pub is_internal_dependency: bool,
30}
31
32/// A diagnostic about workspace configuration issues.
33#[derive(Debug, Clone)]
34pub struct WorkspaceDiagnostic {
35    /// Path to the directory with the issue.
36    pub path: PathBuf,
37    /// Human-readable description of the issue.
38    pub message: String,
39}
40
41/// Discover all workspace packages in a monorepo.
42///
43/// Sources (additive, deduplicated by canonical path):
44/// 1. `package.json` `workspaces` field
45/// 2. `pnpm-workspace.yaml` `packages` field
46/// 3. `tsconfig.json` `references` field (TypeScript project references)
47#[must_use]
48pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
49    let patterns = collect_workspace_patterns(root);
50    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
51
52    let mut workspaces = expand_patterns_to_workspaces(root, &patterns, &canonical_root);
53    workspaces.extend(collect_tsconfig_workspaces(root, &canonical_root));
54
55    if workspaces.is_empty() {
56        return Vec::new();
57    }
58
59    mark_internal_dependencies(&mut workspaces);
60    workspaces.into_iter().map(|(ws, _)| ws).collect()
61}
62
63/// Find directories containing `package.json` that are not declared as workspaces.
64///
65/// Only meaningful in monorepos that declare workspaces (via `package.json` `workspaces`
66/// field or `pnpm-workspace.yaml`). Scans up to two directory levels deep, skipping
67/// hidden directories, `node_modules`, and `build`.
68#[must_use]
69pub fn find_undeclared_workspaces(
70    root: &Path,
71    declared: &[WorkspaceInfo],
72) -> Vec<WorkspaceDiagnostic> {
73    // Only run when workspaces are declared
74    let patterns = collect_workspace_patterns(root);
75    if patterns.is_empty() {
76        return Vec::new();
77    }
78
79    let declared_roots: rustc_hash::FxHashSet<PathBuf> = declared
80        .iter()
81        .map(|w| w.root.canonicalize().unwrap_or_else(|_| w.root.clone()))
82        .collect();
83
84    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
85
86    let mut undeclared = Vec::new();
87
88    // Walk first two levels of directories
89    let Ok(top_entries) = std::fs::read_dir(root) else {
90        return Vec::new();
91    };
92
93    for entry in top_entries.filter_map(Result::ok) {
94        let path = entry.path();
95        if !path.is_dir() {
96            continue;
97        }
98
99        let name = entry.file_name();
100        let name_str = name.to_string_lossy();
101        if name_str.starts_with('.') || name_str == "node_modules" || name_str == "build" {
102            continue;
103        }
104
105        // Check this directory itself
106        check_undeclared(
107            &path,
108            root,
109            &canonical_root,
110            &declared_roots,
111            &mut undeclared,
112        );
113
114        // Check immediate children (second level)
115        let Ok(child_entries) = std::fs::read_dir(&path) else {
116            continue;
117        };
118        for child in child_entries.filter_map(Result::ok) {
119            let child_path = child.path();
120            if !child_path.is_dir() {
121                continue;
122            }
123            let child_name = child.file_name();
124            let child_name_str = child_name.to_string_lossy();
125            if child_name_str.starts_with('.')
126                || child_name_str == "node_modules"
127                || child_name_str == "build"
128            {
129                continue;
130            }
131            check_undeclared(
132                &child_path,
133                root,
134                &canonical_root,
135                &declared_roots,
136                &mut undeclared,
137            );
138        }
139    }
140
141    undeclared
142}
143
144/// Check a single directory for an undeclared workspace.
145fn check_undeclared(
146    dir: &Path,
147    root: &Path,
148    canonical_root: &Path,
149    declared_roots: &rustc_hash::FxHashSet<PathBuf>,
150    undeclared: &mut Vec<WorkspaceDiagnostic>,
151) {
152    if !dir.join("package.json").exists() {
153        return;
154    }
155    let canonical = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
156    // Skip the project root itself
157    if canonical == *canonical_root {
158        return;
159    }
160    if declared_roots.contains(&canonical) {
161        return;
162    }
163    let relative = dir.strip_prefix(root).unwrap_or(dir);
164    undeclared.push(WorkspaceDiagnostic {
165        path: dir.to_path_buf(),
166        message: format!(
167            "Directory '{}' contains package.json but is not declared as a workspace",
168            relative.display()
169        ),
170    });
171}
172
173/// Collect glob patterns from `package.json` `workspaces` field and `pnpm-workspace.yaml`.
174fn collect_workspace_patterns(root: &Path) -> Vec<String> {
175    let mut patterns = Vec::new();
176
177    // Check root package.json for workspace patterns
178    let pkg_path = root.join("package.json");
179    if let Ok(pkg) = PackageJson::load(&pkg_path) {
180        patterns.extend(pkg.workspace_patterns());
181    }
182
183    // Check pnpm-workspace.yaml
184    let pnpm_workspace = root.join("pnpm-workspace.yaml");
185    if pnpm_workspace.exists()
186        && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
187    {
188        patterns.extend(parse_pnpm_workspace_yaml(&content));
189    }
190
191    patterns
192}
193
194/// Expand workspace glob patterns to discover workspace directories.
195///
196/// Handles positive/negated pattern splitting, glob matching, and package.json
197/// loading for each matched directory.
198fn expand_patterns_to_workspaces(
199    root: &Path,
200    patterns: &[String],
201    canonical_root: &Path,
202) -> Vec<(WorkspaceInfo, Vec<String>)> {
203    if patterns.is_empty() {
204        return Vec::new();
205    }
206
207    let mut workspaces = Vec::new();
208
209    // Separate positive and negated patterns.
210    // Negated patterns (e.g., `!**/test/**`) are used as exclusion filters —
211    // the `glob` crate does not support `!` prefixed patterns natively.
212    let (positive, negative): (Vec<&String>, Vec<&String>) =
213        patterns.iter().partition(|p| !p.starts_with('!'));
214    let negation_matchers: Vec<globset::GlobMatcher> = negative
215        .iter()
216        .filter_map(|p| {
217            let stripped = p.strip_prefix('!').unwrap_or(p);
218            globset::Glob::new(stripped)
219                .ok()
220                .map(|g| g.compile_matcher())
221        })
222        .collect();
223
224    for pattern in &positive {
225        // Normalize the pattern for directory matching:
226        // - `packages/*` → glob for `packages/*` (find all subdirs)
227        // - `packages/` → glob for `packages/*` (trailing slash means "contents of")
228        // - `apps`       → glob for `apps` (exact directory)
229        let glob_pattern = if pattern.ends_with('/') {
230            format!("{pattern}*")
231        } else if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('{') {
232            // Bare directory name — treat as exact match
233            (*pattern).clone()
234        } else {
235            (*pattern).clone()
236        };
237
238        // Walk directories matching the glob.
239        // expand_workspace_glob already filters to dirs with package.json
240        // and returns (original_path, canonical_path) — no redundant canonicalize().
241        let matched_dirs = expand_workspace_glob(root, &glob_pattern, canonical_root);
242        for (dir, canonical_dir) in matched_dirs {
243            // Skip workspace entries that point to the project root itself
244            // (e.g. pnpm-workspace.yaml listing `.` as a workspace)
245            if canonical_dir == *canonical_root {
246                continue;
247            }
248
249            // Check against negation patterns — skip directories that match any negated pattern
250            let relative = dir.strip_prefix(root).unwrap_or(&dir);
251            let relative_str = relative.to_string_lossy();
252            if negation_matchers
253                .iter()
254                .any(|m| m.is_match(relative_str.as_ref()))
255            {
256                continue;
257            }
258
259            // package.json existence already checked in expand_workspace_glob
260            let ws_pkg_path = dir.join("package.json");
261            if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
262                // Collect dependency names during initial load to avoid
263                // re-reading package.json later.
264                let dep_names = pkg.all_dependency_names();
265                let name = pkg.name.unwrap_or_else(|| {
266                    dir.file_name()
267                        .map(|n| n.to_string_lossy().to_string())
268                        .unwrap_or_default()
269                });
270                workspaces.push((
271                    WorkspaceInfo {
272                        root: dir,
273                        name,
274                        is_internal_dependency: false,
275                    },
276                    dep_names,
277                ));
278            }
279        }
280    }
281
282    workspaces
283}
284
285/// Discover workspaces from TypeScript project references in `tsconfig.json`.
286///
287/// Referenced directories are added as workspaces, supplementing npm/pnpm workspaces.
288/// This enables cross-workspace resolution for TypeScript composite projects.
289fn collect_tsconfig_workspaces(
290    root: &Path,
291    canonical_root: &Path,
292) -> Vec<(WorkspaceInfo, Vec<String>)> {
293    let mut workspaces = Vec::new();
294
295    for dir in parse_tsconfig_references(root) {
296        let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.clone());
297        // Security: skip references pointing to project root or outside it
298        if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
299            continue;
300        }
301
302        // Read package.json if available; otherwise use directory name
303        let ws_pkg_path = dir.join("package.json");
304        let (name, dep_names) = if ws_pkg_path.exists() {
305            if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
306                let deps = pkg.all_dependency_names();
307                let n = pkg.name.unwrap_or_else(|| dir_name(&dir));
308                (n, deps)
309            } else {
310                (dir_name(&dir), Vec::new())
311            }
312        } else {
313            // No package.json — use directory name, no deps.
314            // Valid for TypeScript-only composite projects.
315            (dir_name(&dir), Vec::new())
316        };
317
318        workspaces.push((
319            WorkspaceInfo {
320                root: dir,
321                name,
322                is_internal_dependency: false,
323            },
324            dep_names,
325        ));
326    }
327
328    workspaces
329}
330
331/// Deduplicate workspaces by canonical path and mark internal dependencies.
332///
333/// Overlapping sources (npm workspaces + tsconfig references pointing to the same
334/// directory) are collapsed. npm-discovered entries take precedence (they appear first).
335/// Workspaces depended on by other workspaces are marked as `is_internal_dependency`.
336fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
337    // Deduplicate by canonical path
338    {
339        let mut seen = rustc_hash::FxHashSet::default();
340        workspaces.retain(|(ws, _)| {
341            let canonical = ws.root.canonicalize().unwrap_or_else(|_| ws.root.clone());
342            seen.insert(canonical)
343        });
344    }
345
346    // Mark workspaces that are depended on by other workspaces.
347    // Uses dep names collected during initial package.json load
348    // to avoid re-reading all workspace package.json files.
349    let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
350        .iter()
351        .flat_map(|(_, deps)| deps.iter().cloned())
352        .collect();
353    for (ws, _) in &mut *workspaces {
354        ws.is_internal_dependency = all_dep_names.contains(&ws.name);
355    }
356}
357
358/// Extract the directory name as a string, for workspace name fallback.
359fn dir_name(dir: &Path) -> String {
360    dir.file_name()
361        .map(|n| n.to_string_lossy().to_string())
362        .unwrap_or_default()
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn discover_workspaces_from_tsconfig_references() {
371        let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
372        let _ = std::fs::remove_dir_all(&temp_dir);
373        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
374        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
375
376        // No package.json workspaces — only tsconfig references
377        std::fs::write(
378            temp_dir.join("tsconfig.json"),
379            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
380        )
381        .unwrap();
382
383        // core has package.json with a name
384        std::fs::write(
385            temp_dir.join("packages/core/package.json"),
386            r#"{"name": "@project/core"}"#,
387        )
388        .unwrap();
389
390        // ui has NO package.json — name should fall back to directory name
391        let workspaces = discover_workspaces(&temp_dir);
392        assert_eq!(workspaces.len(), 2);
393        assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
394        assert!(workspaces.iter().any(|ws| ws.name == "ui"));
395
396        let _ = std::fs::remove_dir_all(&temp_dir);
397    }
398
399    #[test]
400    fn tsconfig_references_outside_root_rejected() {
401        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
402        let _ = std::fs::remove_dir_all(&temp_dir);
403        std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
404        // "outside" is a sibling of "project", not inside it
405        std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
406
407        std::fs::write(
408            temp_dir.join("project/tsconfig.json"),
409            r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
410        )
411        .unwrap();
412
413        // Security: "../outside" points outside the project root and should be rejected
414        let workspaces = discover_workspaces(&temp_dir.join("project"));
415        assert_eq!(
416            workspaces.len(),
417            1,
418            "reference outside project root should be rejected: {workspaces:?}"
419        );
420        assert!(
421            workspaces[0]
422                .root
423                .to_string_lossy()
424                .contains("packages/core")
425        );
426
427        let _ = std::fs::remove_dir_all(&temp_dir);
428    }
429
430    // ── dir_name ────────────────────────────────────────────────────
431
432    #[test]
433    fn dir_name_extracts_last_component() {
434        assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
435        assert_eq!(dir_name(Path::new("/my-app")), "my-app");
436    }
437
438    #[test]
439    fn dir_name_empty_for_root_path() {
440        // Root path has no file_name component
441        assert_eq!(dir_name(Path::new("/")), "");
442    }
443
444    // ── WorkspaceConfig deserialization ──────────────────────────────
445
446    #[test]
447    fn workspace_config_deserialize_json() {
448        let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
449        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
450        assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
451    }
452
453    #[test]
454    fn workspace_config_deserialize_empty_patterns() {
455        let json = r#"{"patterns": []}"#;
456        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
457        assert!(config.patterns.is_empty());
458    }
459
460    #[test]
461    fn workspace_config_default_patterns() {
462        let json = "{}";
463        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
464        assert!(config.patterns.is_empty());
465    }
466
467    // ── WorkspaceInfo ───────────────────────────────────────────────
468
469    #[test]
470    fn workspace_info_default_not_internal() {
471        let ws = WorkspaceInfo {
472            root: PathBuf::from("/project/packages/a"),
473            name: "a".to_string(),
474            is_internal_dependency: false,
475        };
476        assert!(!ws.is_internal_dependency);
477    }
478
479    // ── mark_internal_dependencies ──────────────────────────────────
480
481    #[test]
482    fn mark_internal_deps_detects_cross_references() {
483        let temp_dir = tempfile::tempdir().expect("create temp dir");
484        let pkg_a = temp_dir.path().join("a");
485        let pkg_b = temp_dir.path().join("b");
486        std::fs::create_dir_all(&pkg_a).unwrap();
487        std::fs::create_dir_all(&pkg_b).unwrap();
488
489        let mut workspaces = vec![
490            (
491                WorkspaceInfo {
492                    root: pkg_a,
493                    name: "@scope/a".to_string(),
494                    is_internal_dependency: false,
495                },
496                vec!["@scope/b".to_string()], // "a" depends on "b"
497            ),
498            (
499                WorkspaceInfo {
500                    root: pkg_b,
501                    name: "@scope/b".to_string(),
502                    is_internal_dependency: false,
503                },
504                vec!["lodash".to_string()], // "b" depends on external only
505            ),
506        ];
507
508        mark_internal_dependencies(&mut workspaces);
509
510        // "b" is depended on by "a", so it should be marked as internal
511        let ws_a = workspaces
512            .iter()
513            .find(|(ws, _)| ws.name == "@scope/a")
514            .unwrap();
515        assert!(
516            !ws_a.0.is_internal_dependency,
517            "a is not depended on by others"
518        );
519
520        let ws_b = workspaces
521            .iter()
522            .find(|(ws, _)| ws.name == "@scope/b")
523            .unwrap();
524        assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
525    }
526
527    #[test]
528    fn mark_internal_deps_no_cross_references() {
529        let temp_dir = tempfile::tempdir().expect("create temp dir");
530        let pkg_a = temp_dir.path().join("a");
531        let pkg_b = temp_dir.path().join("b");
532        std::fs::create_dir_all(&pkg_a).unwrap();
533        std::fs::create_dir_all(&pkg_b).unwrap();
534
535        let mut workspaces = vec![
536            (
537                WorkspaceInfo {
538                    root: pkg_a,
539                    name: "a".to_string(),
540                    is_internal_dependency: false,
541                },
542                vec!["react".to_string()],
543            ),
544            (
545                WorkspaceInfo {
546                    root: pkg_b,
547                    name: "b".to_string(),
548                    is_internal_dependency: false,
549                },
550                vec!["lodash".to_string()],
551            ),
552        ];
553
554        mark_internal_dependencies(&mut workspaces);
555
556        assert!(!workspaces[0].0.is_internal_dependency);
557        assert!(!workspaces[1].0.is_internal_dependency);
558    }
559
560    #[test]
561    fn mark_internal_deps_deduplicates_by_path() {
562        let temp_dir = tempfile::tempdir().expect("create temp dir");
563        let pkg_a = temp_dir.path().join("a");
564        std::fs::create_dir_all(&pkg_a).unwrap();
565
566        let mut workspaces = vec![
567            (
568                WorkspaceInfo {
569                    root: pkg_a.clone(),
570                    name: "a".to_string(),
571                    is_internal_dependency: false,
572                },
573                vec![],
574            ),
575            (
576                WorkspaceInfo {
577                    root: pkg_a,
578                    name: "a".to_string(),
579                    is_internal_dependency: false,
580                },
581                vec![],
582            ),
583        ];
584
585        mark_internal_dependencies(&mut workspaces);
586        assert_eq!(
587            workspaces.len(),
588            1,
589            "duplicate paths should be deduplicated"
590        );
591    }
592
593    // ── collect_workspace_patterns ──────────────────────────────────
594
595    #[test]
596    fn collect_patterns_from_package_json() {
597        let dir = tempfile::tempdir().expect("create temp dir");
598        std::fs::write(
599            dir.path().join("package.json"),
600            r#"{"workspaces": ["packages/*", "apps/*"]}"#,
601        )
602        .unwrap();
603
604        let patterns = collect_workspace_patterns(dir.path());
605        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
606    }
607
608    #[test]
609    fn collect_patterns_from_pnpm_workspace() {
610        let dir = tempfile::tempdir().expect("create temp dir");
611        std::fs::write(
612            dir.path().join("pnpm-workspace.yaml"),
613            "packages:\n  - 'packages/*'\n  - 'libs/*'\n",
614        )
615        .unwrap();
616
617        let patterns = collect_workspace_patterns(dir.path());
618        assert_eq!(patterns, vec!["packages/*", "libs/*"]);
619    }
620
621    #[test]
622    fn collect_patterns_combines_sources() {
623        let dir = tempfile::tempdir().expect("create temp dir");
624        std::fs::write(
625            dir.path().join("package.json"),
626            r#"{"workspaces": ["packages/*"]}"#,
627        )
628        .unwrap();
629        std::fs::write(
630            dir.path().join("pnpm-workspace.yaml"),
631            "packages:\n  - 'apps/*'\n",
632        )
633        .unwrap();
634
635        let patterns = collect_workspace_patterns(dir.path());
636        assert!(patterns.contains(&"packages/*".to_string()));
637        assert!(patterns.contains(&"apps/*".to_string()));
638    }
639
640    #[test]
641    fn collect_patterns_empty_when_no_configs() {
642        let dir = tempfile::tempdir().expect("create temp dir");
643        let patterns = collect_workspace_patterns(dir.path());
644        assert!(patterns.is_empty());
645    }
646
647    // ── discover_workspaces integration ─────────────────────────────
648
649    #[test]
650    fn discover_workspaces_from_package_json() {
651        let dir = tempfile::tempdir().expect("create temp dir");
652        let pkg_a = dir.path().join("packages").join("a");
653        let pkg_b = dir.path().join("packages").join("b");
654        std::fs::create_dir_all(&pkg_a).unwrap();
655        std::fs::create_dir_all(&pkg_b).unwrap();
656
657        std::fs::write(
658            dir.path().join("package.json"),
659            r#"{"workspaces": ["packages/*"]}"#,
660        )
661        .unwrap();
662        std::fs::write(
663            pkg_a.join("package.json"),
664            r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
665        )
666        .unwrap();
667        std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
668
669        let workspaces = discover_workspaces(dir.path());
670        assert_eq!(workspaces.len(), 2);
671
672        let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
673        assert!(!ws_a.is_internal_dependency);
674
675        let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
676        assert!(ws_b.is_internal_dependency, "b is depended on by a");
677    }
678
679    #[test]
680    fn discover_workspaces_empty_project() {
681        let dir = tempfile::tempdir().expect("create temp dir");
682        let workspaces = discover_workspaces(dir.path());
683        assert!(workspaces.is_empty());
684    }
685
686    #[test]
687    fn discover_workspaces_with_negated_patterns() {
688        let dir = tempfile::tempdir().expect("create temp dir");
689        let pkg_a = dir.path().join("packages").join("a");
690        let pkg_test = dir.path().join("packages").join("test-utils");
691        std::fs::create_dir_all(&pkg_a).unwrap();
692        std::fs::create_dir_all(&pkg_test).unwrap();
693
694        std::fs::write(
695            dir.path().join("package.json"),
696            r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
697        )
698        .unwrap();
699        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
700        std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
701
702        let workspaces = discover_workspaces(dir.path());
703        assert_eq!(workspaces.len(), 1);
704        assert_eq!(workspaces[0].name, "a");
705    }
706
707    #[test]
708    fn discover_workspaces_skips_root_as_workspace() {
709        let dir = tempfile::tempdir().expect("create temp dir");
710        // pnpm-workspace.yaml listing "." should not add root as workspace
711        std::fs::write(
712            dir.path().join("pnpm-workspace.yaml"),
713            "packages:\n  - '.'\n",
714        )
715        .unwrap();
716        std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
717
718        let workspaces = discover_workspaces(dir.path());
719        assert!(
720            workspaces.is_empty(),
721            "root directory should not be added as workspace"
722        );
723    }
724
725    #[test]
726    fn discover_workspaces_name_fallback_to_dir_name() {
727        let dir = tempfile::tempdir().expect("create temp dir");
728        let pkg_a = dir.path().join("packages").join("my-app");
729        std::fs::create_dir_all(&pkg_a).unwrap();
730
731        std::fs::write(
732            dir.path().join("package.json"),
733            r#"{"workspaces": ["packages/*"]}"#,
734        )
735        .unwrap();
736        // package.json without a name field
737        std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
738
739        let workspaces = discover_workspaces(dir.path());
740        assert_eq!(workspaces.len(), 1);
741        assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
742    }
743
744    // ── find_undeclared_workspaces ─────────────────────────────────
745
746    #[test]
747    fn undeclared_workspace_detected() {
748        let dir = tempfile::tempdir().expect("create temp dir");
749        let pkg_a = dir.path().join("packages").join("a");
750        let pkg_b = dir.path().join("packages").join("b");
751        std::fs::create_dir_all(&pkg_a).unwrap();
752        std::fs::create_dir_all(&pkg_b).unwrap();
753
754        // Only packages/a is declared as a workspace
755        std::fs::write(
756            dir.path().join("package.json"),
757            r#"{"workspaces": ["packages/a"]}"#,
758        )
759        .unwrap();
760        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
761        std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
762
763        let declared = discover_workspaces(dir.path());
764        assert_eq!(declared.len(), 1);
765
766        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
767        assert_eq!(undeclared.len(), 1);
768        assert!(
769            undeclared[0]
770                .path
771                .to_string_lossy()
772                .replace('\\', "/")
773                .contains("packages/b"),
774            "should detect packages/b as undeclared: {:?}",
775            undeclared[0].path
776        );
777    }
778
779    #[test]
780    fn no_undeclared_when_all_covered() {
781        let dir = tempfile::tempdir().expect("create temp dir");
782        let pkg_a = dir.path().join("packages").join("a");
783        std::fs::create_dir_all(&pkg_a).unwrap();
784
785        std::fs::write(
786            dir.path().join("package.json"),
787            r#"{"workspaces": ["packages/*"]}"#,
788        )
789        .unwrap();
790        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
791
792        let declared = discover_workspaces(dir.path());
793        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
794        assert!(undeclared.is_empty());
795    }
796
797    #[test]
798    fn no_undeclared_when_no_workspace_patterns() {
799        let dir = tempfile::tempdir().expect("create temp dir");
800        let sub = dir.path().join("lib");
801        std::fs::create_dir_all(&sub).unwrap();
802
803        // No workspaces field at all — non-monorepo project
804        std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
805        std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
806
807        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
808        assert!(
809            undeclared.is_empty(),
810            "should skip check when no workspace patterns exist"
811        );
812    }
813
814    #[test]
815    fn undeclared_skips_node_modules_and_hidden_dirs() {
816        let dir = tempfile::tempdir().expect("create temp dir");
817        let nm = dir.path().join("node_modules").join("some-pkg");
818        let hidden = dir.path().join(".hidden");
819        std::fs::create_dir_all(&nm).unwrap();
820        std::fs::create_dir_all(&hidden).unwrap();
821
822        std::fs::write(
823            dir.path().join("package.json"),
824            r#"{"workspaces": ["packages/*"]}"#,
825        )
826        .unwrap();
827        // Put package.json in node_modules and hidden dirs
828        std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
829        std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
830
831        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
832        assert!(
833            undeclared.is_empty(),
834            "should not flag node_modules or hidden directories"
835        );
836    }
837}