Skip to main content

fallow_config/
workspace.rs

1use std::io::Read as _;
2use std::path::{Path, PathBuf};
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7/// Workspace configuration for monorepo support.
8#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
9pub struct WorkspaceConfig {
10    /// Additional workspace patterns (beyond what's in root package.json).
11    #[serde(default)]
12    pub patterns: Vec<String>,
13}
14
15/// Discovered workspace info from package.json, pnpm-workspace.yaml, or tsconfig.json references.
16#[derive(Debug, Clone)]
17pub struct WorkspaceInfo {
18    /// Workspace root path.
19    pub root: PathBuf,
20    /// Package name from package.json.
21    pub name: String,
22    /// Whether this workspace is depended on by other workspaces.
23    pub is_internal_dependency: bool,
24}
25
26/// Discover all workspace packages in a monorepo.
27///
28/// Sources (additive, deduplicated by canonical path):
29/// 1. `package.json` `workspaces` field
30/// 2. `pnpm-workspace.yaml` `packages` field
31/// 3. `tsconfig.json` `references` field (TypeScript project references)
32pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
33    let patterns = collect_workspace_patterns(root);
34    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
35
36    let mut workspaces = expand_patterns_to_workspaces(root, &patterns, &canonical_root);
37    workspaces.extend(collect_tsconfig_workspaces(root, &canonical_root));
38
39    if workspaces.is_empty() {
40        return Vec::new();
41    }
42
43    mark_internal_dependencies(&mut workspaces);
44    workspaces.into_iter().map(|(ws, _)| ws).collect()
45}
46
47/// Collect glob patterns from `package.json` `workspaces` field and `pnpm-workspace.yaml`.
48fn collect_workspace_patterns(root: &Path) -> Vec<String> {
49    let mut patterns = Vec::new();
50
51    // Check root package.json for workspace patterns
52    let pkg_path = root.join("package.json");
53    if let Ok(pkg) = PackageJson::load(&pkg_path) {
54        patterns.extend(pkg.workspace_patterns());
55    }
56
57    // Check pnpm-workspace.yaml
58    let pnpm_workspace = root.join("pnpm-workspace.yaml");
59    if pnpm_workspace.exists()
60        && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
61    {
62        patterns.extend(parse_pnpm_workspace_yaml(&content));
63    }
64
65    patterns
66}
67
68/// Expand workspace glob patterns to discover workspace directories.
69///
70/// Handles positive/negated pattern splitting, glob matching, and package.json
71/// loading for each matched directory.
72fn expand_patterns_to_workspaces(
73    root: &Path,
74    patterns: &[String],
75    canonical_root: &Path,
76) -> Vec<(WorkspaceInfo, Vec<String>)> {
77    if patterns.is_empty() {
78        return Vec::new();
79    }
80
81    let mut workspaces = Vec::new();
82
83    // Separate positive and negated patterns.
84    // Negated patterns (e.g., `!**/test/**`) are used as exclusion filters —
85    // the `glob` crate does not support `!` prefixed patterns natively.
86    let (positive, negative): (Vec<&String>, Vec<&String>) =
87        patterns.iter().partition(|p| !p.starts_with('!'));
88    let negation_matchers: Vec<globset::GlobMatcher> = negative
89        .iter()
90        .filter_map(|p| {
91            let stripped = p.strip_prefix('!').unwrap_or(p);
92            globset::Glob::new(stripped)
93                .ok()
94                .map(|g| g.compile_matcher())
95        })
96        .collect();
97
98    for pattern in &positive {
99        // Normalize the pattern for directory matching:
100        // - `packages/*` → glob for `packages/*` (find all subdirs)
101        // - `packages/` → glob for `packages/*` (trailing slash means "contents of")
102        // - `apps`       → glob for `apps` (exact directory)
103        let glob_pattern = if pattern.ends_with('/') {
104            format!("{pattern}*")
105        } else if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('{') {
106            // Bare directory name — treat as exact match
107            (*pattern).clone()
108        } else {
109            (*pattern).clone()
110        };
111
112        // Walk directories matching the glob.
113        // expand_workspace_glob already filters to dirs with package.json
114        // and returns (original_path, canonical_path) — no redundant canonicalize().
115        let matched_dirs = expand_workspace_glob(root, &glob_pattern, canonical_root);
116        for (dir, canonical_dir) in matched_dirs {
117            // Skip workspace entries that point to the project root itself
118            // (e.g. pnpm-workspace.yaml listing `.` as a workspace)
119            if canonical_dir == *canonical_root {
120                continue;
121            }
122
123            // Check against negation patterns — skip directories that match any negated pattern
124            let relative = dir.strip_prefix(root).unwrap_or(&dir);
125            let relative_str = relative.to_string_lossy();
126            if negation_matchers
127                .iter()
128                .any(|m| m.is_match(relative_str.as_ref()))
129            {
130                continue;
131            }
132
133            // package.json existence already checked in expand_workspace_glob
134            let ws_pkg_path = dir.join("package.json");
135            if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
136                // Collect dependency names during initial load to avoid
137                // re-reading package.json later.
138                let dep_names = pkg.all_dependency_names();
139                let name = pkg.name.unwrap_or_else(|| {
140                    dir.file_name()
141                        .map(|n| n.to_string_lossy().to_string())
142                        .unwrap_or_default()
143                });
144                workspaces.push((
145                    WorkspaceInfo {
146                        root: dir,
147                        name,
148                        is_internal_dependency: false,
149                    },
150                    dep_names,
151                ));
152            }
153        }
154    }
155
156    workspaces
157}
158
159/// Discover workspaces from TypeScript project references in `tsconfig.json`.
160///
161/// Referenced directories are added as workspaces, supplementing npm/pnpm workspaces.
162/// This enables cross-workspace resolution for TypeScript composite projects.
163fn collect_tsconfig_workspaces(
164    root: &Path,
165    canonical_root: &Path,
166) -> Vec<(WorkspaceInfo, Vec<String>)> {
167    let mut workspaces = Vec::new();
168
169    for dir in parse_tsconfig_references(root) {
170        let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.clone());
171        // Security: skip references pointing to project root or outside it
172        if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
173            continue;
174        }
175
176        // Read package.json if available; otherwise use directory name
177        let ws_pkg_path = dir.join("package.json");
178        let (name, dep_names) = if ws_pkg_path.exists() {
179            if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
180                let deps = pkg.all_dependency_names();
181                let n = pkg.name.unwrap_or_else(|| dir_name(&dir));
182                (n, deps)
183            } else {
184                (dir_name(&dir), Vec::new())
185            }
186        } else {
187            // No package.json — use directory name, no deps.
188            // Valid for TypeScript-only composite projects.
189            (dir_name(&dir), Vec::new())
190        };
191
192        workspaces.push((
193            WorkspaceInfo {
194                root: dir,
195                name,
196                is_internal_dependency: false,
197            },
198            dep_names,
199        ));
200    }
201
202    workspaces
203}
204
205/// Deduplicate workspaces by canonical path and mark internal dependencies.
206///
207/// Overlapping sources (npm workspaces + tsconfig references pointing to the same
208/// directory) are collapsed. npm-discovered entries take precedence (they appear first).
209/// Workspaces depended on by other workspaces are marked as `is_internal_dependency`.
210fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
211    // Deduplicate by canonical path
212    {
213        let mut seen = rustc_hash::FxHashSet::default();
214        workspaces.retain(|(ws, _)| {
215            let canonical = ws.root.canonicalize().unwrap_or_else(|_| ws.root.clone());
216            seen.insert(canonical)
217        });
218    }
219
220    // Mark workspaces that are depended on by other workspaces.
221    // Uses dep names collected during initial package.json load
222    // to avoid re-reading all workspace package.json files.
223    let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
224        .iter()
225        .flat_map(|(_, deps)| deps.iter().cloned())
226        .collect();
227    for (ws, _) in &mut *workspaces {
228        ws.is_internal_dependency = all_dep_names.contains(&ws.name);
229    }
230}
231
232/// Extract the directory name as a string, for workspace name fallback.
233fn dir_name(dir: &Path) -> String {
234    dir.file_name()
235        .map(|n| n.to_string_lossy().to_string())
236        .unwrap_or_default()
237}
238
239/// Parse `tsconfig.json` at the project root and extract `references[].path` directories.
240///
241/// Returns directories that exist on disk. tsconfig.json is JSONC (comments + trailing commas),
242/// so we strip both before parsing.
243fn parse_tsconfig_references(root: &Path) -> Vec<PathBuf> {
244    let tsconfig_path = root.join("tsconfig.json");
245    let Ok(content) = std::fs::read_to_string(&tsconfig_path) else {
246        return Vec::new();
247    };
248
249    // Strip UTF-8 BOM if present (common in Windows-authored tsconfig files)
250    let content = content.trim_start_matches('\u{FEFF}');
251
252    // Strip JSONC comments
253    let mut stripped = String::new();
254    if json_comments::StripComments::new(content.as_bytes())
255        .read_to_string(&mut stripped)
256        .is_err()
257    {
258        return Vec::new();
259    }
260
261    // Strip trailing commas (common in tsconfig.json)
262    let cleaned = strip_trailing_commas(&stripped);
263
264    let Ok(value) = serde_json::from_str::<serde_json::Value>(&cleaned) else {
265        return Vec::new();
266    };
267
268    let Some(refs) = value.get("references").and_then(|v| v.as_array()) else {
269        return Vec::new();
270    };
271
272    refs.iter()
273        .filter_map(|r| {
274            r.get("path").and_then(|p| p.as_str()).map(|p| {
275                // strip_prefix removes exactly one leading "./" (unlike trim_start_matches
276                // which would strip repeatedly)
277                let cleaned = p.strip_prefix("./").unwrap_or(p);
278                root.join(cleaned)
279            })
280        })
281        .filter(|p| p.is_dir())
282        .collect()
283}
284
285/// Strip trailing commas before `]` and `}` in JSON-like content.
286///
287/// tsconfig.json commonly uses trailing commas which are valid JSONC but not valid JSON.
288/// This strips them so `serde_json` can parse the content.
289fn strip_trailing_commas(input: &str) -> String {
290    let bytes = input.as_bytes();
291    let len = bytes.len();
292    let mut result = Vec::with_capacity(len);
293    let mut in_string = false;
294    let mut i = 0;
295
296    while i < len {
297        let b = bytes[i];
298
299        if in_string {
300            result.push(b);
301            if b == b'\\' && i + 1 < len {
302                // Push escaped character and skip it
303                i += 1;
304                result.push(bytes[i]);
305            } else if b == b'"' {
306                in_string = false;
307            }
308            i += 1;
309            continue;
310        }
311
312        if b == b'"' {
313            in_string = true;
314            result.push(b);
315            i += 1;
316            continue;
317        }
318
319        if b == b',' {
320            // Look ahead past whitespace for ] or }
321            let mut j = i + 1;
322            while j < len && bytes[j].is_ascii_whitespace() {
323                j += 1;
324            }
325            if j < len && (bytes[j] == b']' || bytes[j] == b'}') {
326                // Skip the trailing comma
327                i += 1;
328                continue;
329            }
330        }
331
332        result.push(b);
333        i += 1;
334    }
335
336    // We only removed ASCII commas and preserved all other bytes unchanged,
337    // so the result is valid UTF-8 if the input was. Use from_utf8 to be safe.
338    String::from_utf8(result).unwrap_or_else(|_| input.to_string())
339}
340
341/// Expand a workspace glob pattern to matching directories.
342///
343/// Returns `(original_path, canonical_path)` tuples so callers can skip redundant
344/// `canonicalize()` calls. Only directories containing a `package.json` are
345/// canonicalized — this avoids expensive syscalls on the many non-workspace
346/// directories that globs like `packages/*` or `**` can match.
347///
348/// `canonical_root` is pre-computed to avoid repeated `canonicalize()` syscalls.
349#[expect(clippy::print_stderr)]
350fn expand_workspace_glob(
351    root: &Path,
352    pattern: &str,
353    canonical_root: &Path,
354) -> Vec<(PathBuf, PathBuf)> {
355    let full_pattern = root.join(pattern).to_string_lossy().to_string();
356    match glob::glob(&full_pattern) {
357        Ok(paths) => paths
358            .filter_map(Result::ok)
359            .filter(|p| p.is_dir())
360            // Fast pre-filter: skip directories without package.json before
361            // paying the cost of canonicalize() (the P0 perf fix — avoids
362            // canonicalizing 759+ non-workspace dirs in large monorepos).
363            .filter(|p| p.join("package.json").exists())
364            .filter_map(|p| {
365                // Security: ensure workspace directory is within project root
366                p.canonicalize()
367                    .ok()
368                    .filter(|cp| cp.starts_with(canonical_root))
369                    .map(|cp| (p, cp))
370            })
371            .collect(),
372        Err(e) => {
373            eprintln!("Warning: Invalid workspace glob pattern '{pattern}': {e}");
374            Vec::new()
375        }
376    }
377}
378
379/// Parse pnpm-workspace.yaml to extract package patterns.
380fn parse_pnpm_workspace_yaml(content: &str) -> Vec<String> {
381    // Simple YAML parsing for the common format:
382    // packages:
383    //   - 'packages/*'
384    //   - 'apps/*'
385    let mut patterns = Vec::new();
386    let mut in_packages = false;
387
388    for line in content.lines() {
389        let trimmed = line.trim();
390        if trimmed == "packages:" {
391            in_packages = true;
392            continue;
393        }
394        if in_packages {
395            if trimmed.starts_with("- ") {
396                let value = trimmed
397                    .strip_prefix("- ")
398                    .unwrap_or(trimmed)
399                    .trim_matches('\'')
400                    .trim_matches('"');
401                patterns.push(value.to_string());
402            } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
403                break; // New top-level key
404            }
405        }
406    }
407
408    patterns
409}
410
411/// Type alias for standard `HashMap` used in serde-deserialized structs.
412/// `rustc-hash` v2 does not have a `serde` feature, so fields deserialized
413/// from JSON must use `std::collections::HashMap`.
414#[expect(clippy::disallowed_types)]
415type StdHashMap<K, V> = std::collections::HashMap<K, V>;
416
417/// Parsed package.json with fields relevant to fallow.
418#[derive(Debug, Clone, Default, Deserialize, Serialize)]
419pub struct PackageJson {
420    #[serde(default)]
421    pub name: Option<String>,
422    #[serde(default)]
423    pub main: Option<String>,
424    #[serde(default)]
425    pub module: Option<String>,
426    #[serde(default)]
427    pub types: Option<String>,
428    #[serde(default)]
429    pub typings: Option<String>,
430    #[serde(default)]
431    pub source: Option<String>,
432    #[serde(default)]
433    pub browser: Option<serde_json::Value>,
434    #[serde(default)]
435    pub bin: Option<serde_json::Value>,
436    #[serde(default)]
437    pub exports: Option<serde_json::Value>,
438    #[serde(default)]
439    pub dependencies: Option<StdHashMap<String, String>>,
440    #[serde(default, rename = "devDependencies")]
441    pub dev_dependencies: Option<StdHashMap<String, String>>,
442    #[serde(default, rename = "peerDependencies")]
443    pub peer_dependencies: Option<StdHashMap<String, String>>,
444    #[serde(default, rename = "optionalDependencies")]
445    pub optional_dependencies: Option<StdHashMap<String, String>>,
446    #[serde(default)]
447    pub scripts: Option<StdHashMap<String, String>>,
448    #[serde(default)]
449    pub workspaces: Option<serde_json::Value>,
450}
451
452impl PackageJson {
453    /// Load from a package.json file.
454    pub fn load(path: &std::path::Path) -> Result<Self, String> {
455        let content = std::fs::read_to_string(path)
456            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
457        serde_json::from_str(&content)
458            .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
459    }
460
461    /// Get all dependency names (production + dev + peer + optional).
462    pub fn all_dependency_names(&self) -> Vec<String> {
463        let mut deps = Vec::new();
464        if let Some(d) = &self.dependencies {
465            deps.extend(d.keys().cloned());
466        }
467        if let Some(d) = &self.dev_dependencies {
468            deps.extend(d.keys().cloned());
469        }
470        if let Some(d) = &self.peer_dependencies {
471            deps.extend(d.keys().cloned());
472        }
473        if let Some(d) = &self.optional_dependencies {
474            deps.extend(d.keys().cloned());
475        }
476        deps
477    }
478
479    /// Get production dependency names only.
480    pub fn production_dependency_names(&self) -> Vec<String> {
481        self.dependencies
482            .as_ref()
483            .map(|d| d.keys().cloned().collect())
484            .unwrap_or_default()
485    }
486
487    /// Get dev dependency names only.
488    pub fn dev_dependency_names(&self) -> Vec<String> {
489        self.dev_dependencies
490            .as_ref()
491            .map(|d| d.keys().cloned().collect())
492            .unwrap_or_default()
493    }
494
495    /// Get optional dependency names only.
496    pub fn optional_dependency_names(&self) -> Vec<String> {
497        self.optional_dependencies
498            .as_ref()
499            .map(|d| d.keys().cloned().collect())
500            .unwrap_or_default()
501    }
502
503    /// Extract entry points from package.json fields.
504    pub fn entry_points(&self) -> Vec<String> {
505        let mut entries = Vec::new();
506
507        if let Some(main) = &self.main {
508            entries.push(main.clone());
509        }
510        if let Some(module) = &self.module {
511            entries.push(module.clone());
512        }
513        if let Some(types) = &self.types {
514            entries.push(types.clone());
515        }
516        if let Some(typings) = &self.typings {
517            entries.push(typings.clone());
518        }
519        if let Some(source) = &self.source {
520            entries.push(source.clone());
521        }
522
523        // Handle browser field (string or object with path values)
524        if let Some(browser) = &self.browser {
525            match browser {
526                serde_json::Value::String(s) => entries.push(s.clone()),
527                serde_json::Value::Object(map) => {
528                    for v in map.values() {
529                        if let serde_json::Value::String(s) = v
530                            && (s.starts_with("./") || s.starts_with("../"))
531                        {
532                            entries.push(s.clone());
533                        }
534                    }
535                }
536                _ => {}
537            }
538        }
539
540        // Handle bin field (string or object)
541        if let Some(bin) = &self.bin {
542            match bin {
543                serde_json::Value::String(s) => entries.push(s.clone()),
544                serde_json::Value::Object(map) => {
545                    for v in map.values() {
546                        if let serde_json::Value::String(s) = v {
547                            entries.push(s.clone());
548                        }
549                    }
550                }
551                _ => {}
552            }
553        }
554
555        // Handle exports field (recursive)
556        if let Some(exports) = &self.exports {
557            extract_exports_entries(exports, &mut entries);
558        }
559
560        entries
561    }
562
563    /// Extract workspace patterns from package.json.
564    pub fn workspace_patterns(&self) -> Vec<String> {
565        match &self.workspaces {
566            Some(serde_json::Value::Array(arr)) => arr
567                .iter()
568                .filter_map(|v| v.as_str().map(String::from))
569                .collect(),
570            Some(serde_json::Value::Object(obj)) => obj
571                .get("packages")
572                .and_then(|v| v.as_array())
573                .map(|arr| {
574                    arr.iter()
575                        .filter_map(|v| v.as_str().map(String::from))
576                        .collect()
577                })
578                .unwrap_or_default(),
579            _ => Vec::new(),
580        }
581    }
582}
583
584/// Recursively extract file paths from package.json exports field.
585fn extract_exports_entries(value: &serde_json::Value, entries: &mut Vec<String>) {
586    match value {
587        serde_json::Value::String(s) => {
588            if s.starts_with("./") || s.starts_with("../") {
589                entries.push(s.clone());
590            }
591        }
592        serde_json::Value::Object(map) => {
593            for v in map.values() {
594                extract_exports_entries(v, entries);
595            }
596        }
597        serde_json::Value::Array(arr) => {
598            for v in arr {
599                extract_exports_entries(v, entries);
600            }
601        }
602        _ => {}
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn parse_pnpm_workspace_basic() {
612        let yaml = "packages:\n  - 'packages/*'\n  - 'apps/*'\n";
613        let patterns = parse_pnpm_workspace_yaml(yaml);
614        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
615    }
616
617    #[test]
618    fn parse_pnpm_workspace_double_quotes() {
619        let yaml = "packages:\n  - \"packages/*\"\n  - \"apps/*\"\n";
620        let patterns = parse_pnpm_workspace_yaml(yaml);
621        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
622    }
623
624    #[test]
625    fn parse_pnpm_workspace_no_quotes() {
626        let yaml = "packages:\n  - packages/*\n  - apps/*\n";
627        let patterns = parse_pnpm_workspace_yaml(yaml);
628        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
629    }
630
631    #[test]
632    fn parse_pnpm_workspace_empty() {
633        let yaml = "";
634        let patterns = parse_pnpm_workspace_yaml(yaml);
635        assert!(patterns.is_empty());
636    }
637
638    #[test]
639    fn parse_pnpm_workspace_no_packages_key() {
640        let yaml = "other:\n  - something\n";
641        let patterns = parse_pnpm_workspace_yaml(yaml);
642        assert!(patterns.is_empty());
643    }
644
645    #[test]
646    fn parse_pnpm_workspace_with_comments() {
647        let yaml = "packages:\n  # Comment\n  - 'packages/*'\n";
648        let patterns = parse_pnpm_workspace_yaml(yaml);
649        assert_eq!(patterns, vec!["packages/*"]);
650    }
651
652    #[test]
653    fn parse_pnpm_workspace_stops_at_next_key() {
654        let yaml = "packages:\n  - 'packages/*'\ncatalog:\n  react: ^18\n";
655        let patterns = parse_pnpm_workspace_yaml(yaml);
656        assert_eq!(patterns, vec!["packages/*"]);
657    }
658
659    #[test]
660    fn strip_trailing_commas_basic() {
661        assert_eq!(
662            strip_trailing_commas(r#"{"a": 1, "b": 2,}"#),
663            r#"{"a": 1, "b": 2}"#
664        );
665    }
666
667    #[test]
668    fn strip_trailing_commas_array() {
669        assert_eq!(strip_trailing_commas(r#"[1, 2, 3,]"#), r#"[1, 2, 3]"#);
670    }
671
672    #[test]
673    fn strip_trailing_commas_with_whitespace() {
674        assert_eq!(
675            strip_trailing_commas("{\n  \"a\": 1,\n}"),
676            "{\n  \"a\": 1\n}"
677        );
678    }
679
680    #[test]
681    fn strip_trailing_commas_preserves_strings() {
682        // Commas inside strings should NOT be stripped
683        assert_eq!(
684            strip_trailing_commas(r#"{"a": "hello,}"}"#),
685            r#"{"a": "hello,}"}"#
686        );
687    }
688
689    #[test]
690    fn strip_trailing_commas_nested() {
691        let input = r#"{"refs": [{"path": "./a",}, {"path": "./b",},],}"#;
692        let expected = r#"{"refs": [{"path": "./a"}, {"path": "./b"}]}"#;
693        assert_eq!(strip_trailing_commas(input), expected);
694    }
695
696    #[test]
697    fn strip_trailing_commas_escaped_quotes() {
698        assert_eq!(
699            strip_trailing_commas(r#"{"a": "he\"llo,}",}"#),
700            r#"{"a": "he\"llo,}"}"#
701        );
702    }
703
704    #[test]
705    fn tsconfig_references_from_dir() {
706        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-refs");
707        let _ = std::fs::remove_dir_all(&temp_dir);
708        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
709        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
710
711        std::fs::write(
712            temp_dir.join("tsconfig.json"),
713            r#"{
714                // Root tsconfig with project references
715                "references": [
716                    {"path": "./packages/core"},
717                    {"path": "./packages/ui"},
718                ],
719            }"#,
720        )
721        .unwrap();
722
723        let refs = parse_tsconfig_references(&temp_dir);
724        assert_eq!(refs.len(), 2);
725        assert!(refs.iter().any(|p| p.ends_with("packages/core")));
726        assert!(refs.iter().any(|p| p.ends_with("packages/ui")));
727
728        let _ = std::fs::remove_dir_all(&temp_dir);
729    }
730
731    #[test]
732    fn tsconfig_references_no_file() {
733        let refs = parse_tsconfig_references(std::path::Path::new("/nonexistent"));
734        assert!(refs.is_empty());
735    }
736
737    #[test]
738    fn tsconfig_references_no_references_field() {
739        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-no-refs");
740        let _ = std::fs::remove_dir_all(&temp_dir);
741        std::fs::create_dir_all(&temp_dir).unwrap();
742
743        std::fs::write(
744            temp_dir.join("tsconfig.json"),
745            r#"{"compilerOptions": {"strict": true}}"#,
746        )
747        .unwrap();
748
749        let refs = parse_tsconfig_references(&temp_dir);
750        assert!(refs.is_empty());
751
752        let _ = std::fs::remove_dir_all(&temp_dir);
753    }
754
755    #[test]
756    fn tsconfig_references_skips_nonexistent_dirs() {
757        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-missing-dir");
758        let _ = std::fs::remove_dir_all(&temp_dir);
759        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
760
761        std::fs::write(
762            temp_dir.join("tsconfig.json"),
763            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/missing"}]}"#,
764        )
765        .unwrap();
766
767        let refs = parse_tsconfig_references(&temp_dir);
768        assert_eq!(refs.len(), 1);
769        assert!(refs[0].ends_with("packages/core"));
770
771        let _ = std::fs::remove_dir_all(&temp_dir);
772    }
773
774    #[test]
775    fn discover_workspaces_from_tsconfig_references() {
776        let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
777        let _ = std::fs::remove_dir_all(&temp_dir);
778        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
779        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
780
781        // No package.json workspaces — only tsconfig references
782        std::fs::write(
783            temp_dir.join("tsconfig.json"),
784            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
785        )
786        .unwrap();
787
788        // core has package.json with a name
789        std::fs::write(
790            temp_dir.join("packages/core/package.json"),
791            r#"{"name": "@project/core"}"#,
792        )
793        .unwrap();
794
795        // ui has NO package.json — name should fall back to directory name
796        let workspaces = discover_workspaces(&temp_dir);
797        assert_eq!(workspaces.len(), 2);
798        assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
799        assert!(workspaces.iter().any(|ws| ws.name == "ui"));
800
801        let _ = std::fs::remove_dir_all(&temp_dir);
802    }
803
804    #[test]
805    fn tsconfig_references_outside_root_rejected() {
806        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
807        let _ = std::fs::remove_dir_all(&temp_dir);
808        std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
809        // "outside" is a sibling of "project", not inside it
810        std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
811
812        std::fs::write(
813            temp_dir.join("project/tsconfig.json"),
814            r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
815        )
816        .unwrap();
817
818        // Security: "../outside" points outside the project root and should be rejected
819        let workspaces = discover_workspaces(&temp_dir.join("project"));
820        assert_eq!(
821            workspaces.len(),
822            1,
823            "reference outside project root should be rejected: {workspaces:?}"
824        );
825        assert!(
826            workspaces[0]
827                .root
828                .to_string_lossy()
829                .contains("packages/core")
830        );
831
832        let _ = std::fs::remove_dir_all(&temp_dir);
833    }
834
835    #[test]
836    fn package_json_workspace_patterns_array() {
837        let pkg: PackageJson =
838            serde_json::from_str(r#"{"workspaces": ["packages/*", "apps/*"]}"#).unwrap();
839        let patterns = pkg.workspace_patterns();
840        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
841    }
842
843    #[test]
844    fn package_json_workspace_patterns_object() {
845        let pkg: PackageJson =
846            serde_json::from_str(r#"{"workspaces": {"packages": ["packages/*"]}}"#).unwrap();
847        let patterns = pkg.workspace_patterns();
848        assert_eq!(patterns, vec!["packages/*"]);
849    }
850
851    #[test]
852    fn package_json_workspace_patterns_none() {
853        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
854        let patterns = pkg.workspace_patterns();
855        assert!(patterns.is_empty());
856    }
857
858    #[test]
859    fn package_json_workspace_patterns_empty_array() {
860        let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": []}"#).unwrap();
861        let patterns = pkg.workspace_patterns();
862        assert!(patterns.is_empty());
863    }
864
865    #[test]
866    fn package_json_load_valid() {
867        let temp_dir = std::env::temp_dir().join("fallow-test-pkg-json");
868        let _ = std::fs::create_dir_all(&temp_dir);
869        let pkg_path = temp_dir.join("package.json");
870        std::fs::write(&pkg_path, r#"{"name": "test", "main": "index.js"}"#).unwrap();
871
872        let pkg = PackageJson::load(&pkg_path).unwrap();
873        assert_eq!(pkg.name, Some("test".to_string()));
874        assert_eq!(pkg.main, Some("index.js".to_string()));
875
876        let _ = std::fs::remove_dir_all(&temp_dir);
877    }
878
879    #[test]
880    fn package_json_load_missing_file() {
881        let result = PackageJson::load(std::path::Path::new("/nonexistent/package.json"));
882        assert!(result.is_err());
883    }
884
885    #[test]
886    fn package_json_entry_points_combined() {
887        let pkg: PackageJson = serde_json::from_str(
888            r#"{
889            "main": "dist/index.js",
890            "module": "dist/index.mjs",
891            "types": "dist/index.d.ts",
892            "typings": "dist/types.d.ts"
893        }"#,
894        )
895        .unwrap();
896        let entries = pkg.entry_points();
897        assert_eq!(entries.len(), 4);
898        assert!(entries.contains(&"dist/index.js".to_string()));
899        assert!(entries.contains(&"dist/index.mjs".to_string()));
900        assert!(entries.contains(&"dist/index.d.ts".to_string()));
901        assert!(entries.contains(&"dist/types.d.ts".to_string()));
902    }
903
904    #[test]
905    fn package_json_exports_nested() {
906        let pkg: PackageJson = serde_json::from_str(
907            r#"{
908            "exports": {
909                ".": {
910                    "import": "./dist/index.mjs",
911                    "require": "./dist/index.cjs"
912                },
913                "./utils": {
914                    "import": "./dist/utils.mjs"
915                }
916            }
917        }"#,
918        )
919        .unwrap();
920        let entries = pkg.entry_points();
921        assert!(entries.contains(&"./dist/index.mjs".to_string()));
922        assert!(entries.contains(&"./dist/index.cjs".to_string()));
923        assert!(entries.contains(&"./dist/utils.mjs".to_string()));
924    }
925
926    #[test]
927    fn package_json_exports_array() {
928        let pkg: PackageJson = serde_json::from_str(
929            r#"{
930            "exports": {
931                ".": ["./dist/index.mjs", "./dist/index.cjs"]
932            }
933        }"#,
934        )
935        .unwrap();
936        let entries = pkg.entry_points();
937        assert!(entries.contains(&"./dist/index.mjs".to_string()));
938        assert!(entries.contains(&"./dist/index.cjs".to_string()));
939    }
940
941    #[test]
942    fn extract_exports_ignores_non_relative() {
943        let pkg: PackageJson = serde_json::from_str(
944            r#"{
945            "exports": {
946                ".": "not-a-relative-path"
947            }
948        }"#,
949        )
950        .unwrap();
951        let entries = pkg.entry_points();
952        // "not-a-relative-path" doesn't start with "./" so should be excluded
953        assert!(entries.is_empty());
954    }
955
956    #[test]
957    fn package_json_source_field() {
958        let pkg: PackageJson = serde_json::from_str(
959            r#"{
960            "main": "dist/index.js",
961            "source": "src/index.ts"
962        }"#,
963        )
964        .unwrap();
965        let entries = pkg.entry_points();
966        assert!(entries.contains(&"src/index.ts".to_string()));
967        assert!(entries.contains(&"dist/index.js".to_string()));
968    }
969
970    #[test]
971    fn package_json_browser_field_string() {
972        let pkg: PackageJson = serde_json::from_str(
973            r#"{
974            "browser": "./dist/browser.js"
975        }"#,
976        )
977        .unwrap();
978        let entries = pkg.entry_points();
979        assert!(entries.contains(&"./dist/browser.js".to_string()));
980    }
981
982    #[test]
983    fn package_json_browser_field_object() {
984        let pkg: PackageJson = serde_json::from_str(
985            r#"{
986            "browser": {
987                "./server.js": "./browser.js",
988                "module-name": false
989            }
990        }"#,
991        )
992        .unwrap();
993        let entries = pkg.entry_points();
994        assert!(entries.contains(&"./browser.js".to_string()));
995        // non-relative paths and false values should be excluded
996        assert_eq!(entries.len(), 1);
997    }
998}