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