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.
349fn expand_workspace_glob(
350    root: &Path,
351    pattern: &str,
352    canonical_root: &Path,
353) -> Vec<(PathBuf, PathBuf)> {
354    let full_pattern = root.join(pattern).to_string_lossy().to_string();
355    match glob::glob(&full_pattern) {
356        Ok(paths) => paths
357            .filter_map(Result::ok)
358            .filter(|p| p.is_dir())
359            // Fast pre-filter: skip directories without package.json before
360            // paying the cost of canonicalize() (the P0 perf fix — avoids
361            // canonicalizing 759+ non-workspace dirs in large monorepos).
362            .filter(|p| p.join("package.json").exists())
363            .filter_map(|p| {
364                // Security: ensure workspace directory is within project root
365                p.canonicalize()
366                    .ok()
367                    .filter(|cp| cp.starts_with(canonical_root))
368                    .map(|cp| (p, cp))
369            })
370            .collect(),
371        Err(e) => {
372            tracing::warn!("invalid workspace glob pattern '{pattern}': {e}");
373            Vec::new()
374        }
375    }
376}
377
378/// Parse pnpm-workspace.yaml to extract package patterns.
379fn parse_pnpm_workspace_yaml(content: &str) -> Vec<String> {
380    // Simple YAML parsing for the common format:
381    // packages:
382    //   - 'packages/*'
383    //   - 'apps/*'
384    let mut patterns = Vec::new();
385    let mut in_packages = false;
386
387    for line in content.lines() {
388        let trimmed = line.trim();
389        if trimmed == "packages:" {
390            in_packages = true;
391            continue;
392        }
393        if in_packages {
394            if trimmed.starts_with("- ") {
395                let value = trimmed
396                    .strip_prefix("- ")
397                    .unwrap_or(trimmed)
398                    .trim_matches('\'')
399                    .trim_matches('"');
400                patterns.push(value.to_string());
401            } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
402                break; // New top-level key
403            }
404        }
405    }
406
407    patterns
408}
409
410/// Type alias for standard `HashMap` used in serde-deserialized structs.
411/// `rustc-hash` v2 does not have a `serde` feature, so fields deserialized
412/// from JSON must use `std::collections::HashMap`.
413#[expect(clippy::disallowed_types)]
414type StdHashMap<K, V> = std::collections::HashMap<K, V>;
415
416/// Parsed package.json with fields relevant to fallow.
417#[derive(Debug, Clone, Default, Deserialize, Serialize)]
418pub struct PackageJson {
419    #[serde(default)]
420    pub name: Option<String>,
421    #[serde(default)]
422    pub main: Option<String>,
423    #[serde(default)]
424    pub module: Option<String>,
425    #[serde(default)]
426    pub types: Option<String>,
427    #[serde(default)]
428    pub typings: Option<String>,
429    #[serde(default)]
430    pub source: Option<String>,
431    #[serde(default)]
432    pub browser: Option<serde_json::Value>,
433    #[serde(default)]
434    pub bin: Option<serde_json::Value>,
435    #[serde(default)]
436    pub exports: Option<serde_json::Value>,
437    #[serde(default)]
438    pub dependencies: Option<StdHashMap<String, String>>,
439    #[serde(default, rename = "devDependencies")]
440    pub dev_dependencies: Option<StdHashMap<String, String>>,
441    #[serde(default, rename = "peerDependencies")]
442    pub peer_dependencies: Option<StdHashMap<String, String>>,
443    #[serde(default, rename = "optionalDependencies")]
444    pub optional_dependencies: Option<StdHashMap<String, String>>,
445    #[serde(default)]
446    pub scripts: Option<StdHashMap<String, String>>,
447    #[serde(default)]
448    pub workspaces: Option<serde_json::Value>,
449}
450
451impl PackageJson {
452    /// Load from a package.json file.
453    pub fn load(path: &std::path::Path) -> Result<Self, String> {
454        let content = std::fs::read_to_string(path)
455            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
456        serde_json::from_str(&content)
457            .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
458    }
459
460    /// Get all dependency names (production + dev + peer + optional).
461    pub fn all_dependency_names(&self) -> Vec<String> {
462        let mut deps = Vec::new();
463        if let Some(d) = &self.dependencies {
464            deps.extend(d.keys().cloned());
465        }
466        if let Some(d) = &self.dev_dependencies {
467            deps.extend(d.keys().cloned());
468        }
469        if let Some(d) = &self.peer_dependencies {
470            deps.extend(d.keys().cloned());
471        }
472        if let Some(d) = &self.optional_dependencies {
473            deps.extend(d.keys().cloned());
474        }
475        deps
476    }
477
478    /// Get production dependency names only.
479    pub fn production_dependency_names(&self) -> Vec<String> {
480        self.dependencies
481            .as_ref()
482            .map(|d| d.keys().cloned().collect())
483            .unwrap_or_default()
484    }
485
486    /// Get dev dependency names only.
487    pub fn dev_dependency_names(&self) -> Vec<String> {
488        self.dev_dependencies
489            .as_ref()
490            .map(|d| d.keys().cloned().collect())
491            .unwrap_or_default()
492    }
493
494    /// Get optional dependency names only.
495    pub fn optional_dependency_names(&self) -> Vec<String> {
496        self.optional_dependencies
497            .as_ref()
498            .map(|d| d.keys().cloned().collect())
499            .unwrap_or_default()
500    }
501
502    /// Extract entry points from package.json fields.
503    pub fn entry_points(&self) -> Vec<String> {
504        let mut entries = Vec::new();
505
506        if let Some(main) = &self.main {
507            entries.push(main.clone());
508        }
509        if let Some(module) = &self.module {
510            entries.push(module.clone());
511        }
512        if let Some(types) = &self.types {
513            entries.push(types.clone());
514        }
515        if let Some(typings) = &self.typings {
516            entries.push(typings.clone());
517        }
518        if let Some(source) = &self.source {
519            entries.push(source.clone());
520        }
521
522        // Handle browser field (string or object with path values)
523        if let Some(browser) = &self.browser {
524            match browser {
525                serde_json::Value::String(s) => entries.push(s.clone()),
526                serde_json::Value::Object(map) => {
527                    for v in map.values() {
528                        if let serde_json::Value::String(s) = v
529                            && (s.starts_with("./") || s.starts_with("../"))
530                        {
531                            entries.push(s.clone());
532                        }
533                    }
534                }
535                _ => {}
536            }
537        }
538
539        // Handle bin field (string or object)
540        if let Some(bin) = &self.bin {
541            match bin {
542                serde_json::Value::String(s) => entries.push(s.clone()),
543                serde_json::Value::Object(map) => {
544                    for v in map.values() {
545                        if let serde_json::Value::String(s) = v {
546                            entries.push(s.clone());
547                        }
548                    }
549                }
550                _ => {}
551            }
552        }
553
554        // Handle exports field (recursive)
555        if let Some(exports) = &self.exports {
556            extract_exports_entries(exports, &mut entries);
557        }
558
559        entries
560    }
561
562    /// Extract workspace patterns from package.json.
563    pub fn workspace_patterns(&self) -> Vec<String> {
564        match &self.workspaces {
565            Some(serde_json::Value::Array(arr)) => arr
566                .iter()
567                .filter_map(|v| v.as_str().map(String::from))
568                .collect(),
569            Some(serde_json::Value::Object(obj)) => obj
570                .get("packages")
571                .and_then(|v| v.as_array())
572                .map(|arr| {
573                    arr.iter()
574                        .filter_map(|v| v.as_str().map(String::from))
575                        .collect()
576                })
577                .unwrap_or_default(),
578            _ => Vec::new(),
579        }
580    }
581}
582
583/// Recursively extract file paths from package.json exports field.
584fn extract_exports_entries(value: &serde_json::Value, entries: &mut Vec<String>) {
585    match value {
586        serde_json::Value::String(s) => {
587            if s.starts_with("./") || s.starts_with("../") {
588                entries.push(s.clone());
589            }
590        }
591        serde_json::Value::Object(map) => {
592            for v in map.values() {
593                extract_exports_entries(v, entries);
594            }
595        }
596        serde_json::Value::Array(arr) => {
597            for v in arr {
598                extract_exports_entries(v, entries);
599            }
600        }
601        _ => {}
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn parse_pnpm_workspace_basic() {
611        let yaml = "packages:\n  - 'packages/*'\n  - 'apps/*'\n";
612        let patterns = parse_pnpm_workspace_yaml(yaml);
613        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
614    }
615
616    #[test]
617    fn parse_pnpm_workspace_double_quotes() {
618        let yaml = "packages:\n  - \"packages/*\"\n  - \"apps/*\"\n";
619        let patterns = parse_pnpm_workspace_yaml(yaml);
620        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
621    }
622
623    #[test]
624    fn parse_pnpm_workspace_no_quotes() {
625        let yaml = "packages:\n  - packages/*\n  - apps/*\n";
626        let patterns = parse_pnpm_workspace_yaml(yaml);
627        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
628    }
629
630    #[test]
631    fn parse_pnpm_workspace_empty() {
632        let yaml = "";
633        let patterns = parse_pnpm_workspace_yaml(yaml);
634        assert!(patterns.is_empty());
635    }
636
637    #[test]
638    fn parse_pnpm_workspace_no_packages_key() {
639        let yaml = "other:\n  - something\n";
640        let patterns = parse_pnpm_workspace_yaml(yaml);
641        assert!(patterns.is_empty());
642    }
643
644    #[test]
645    fn parse_pnpm_workspace_with_comments() {
646        let yaml = "packages:\n  # Comment\n  - 'packages/*'\n";
647        let patterns = parse_pnpm_workspace_yaml(yaml);
648        assert_eq!(patterns, vec!["packages/*"]);
649    }
650
651    #[test]
652    fn parse_pnpm_workspace_stops_at_next_key() {
653        let yaml = "packages:\n  - 'packages/*'\ncatalog:\n  react: ^18\n";
654        let patterns = parse_pnpm_workspace_yaml(yaml);
655        assert_eq!(patterns, vec!["packages/*"]);
656    }
657
658    #[test]
659    fn strip_trailing_commas_basic() {
660        assert_eq!(
661            strip_trailing_commas(r#"{"a": 1, "b": 2,}"#),
662            r#"{"a": 1, "b": 2}"#
663        );
664    }
665
666    #[test]
667    fn strip_trailing_commas_array() {
668        assert_eq!(strip_trailing_commas(r#"[1, 2, 3,]"#), r#"[1, 2, 3]"#);
669    }
670
671    #[test]
672    fn strip_trailing_commas_with_whitespace() {
673        assert_eq!(
674            strip_trailing_commas("{\n  \"a\": 1,\n}"),
675            "{\n  \"a\": 1\n}"
676        );
677    }
678
679    #[test]
680    fn strip_trailing_commas_preserves_strings() {
681        // Commas inside strings should NOT be stripped
682        assert_eq!(
683            strip_trailing_commas(r#"{"a": "hello,}"}"#),
684            r#"{"a": "hello,}"}"#
685        );
686    }
687
688    #[test]
689    fn strip_trailing_commas_nested() {
690        let input = r#"{"refs": [{"path": "./a",}, {"path": "./b",},],}"#;
691        let expected = r#"{"refs": [{"path": "./a"}, {"path": "./b"}]}"#;
692        assert_eq!(strip_trailing_commas(input), expected);
693    }
694
695    #[test]
696    fn strip_trailing_commas_escaped_quotes() {
697        assert_eq!(
698            strip_trailing_commas(r#"{"a": "he\"llo,}",}"#),
699            r#"{"a": "he\"llo,}"}"#
700        );
701    }
702
703    #[test]
704    fn tsconfig_references_from_dir() {
705        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-refs");
706        let _ = std::fs::remove_dir_all(&temp_dir);
707        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
708        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
709
710        std::fs::write(
711            temp_dir.join("tsconfig.json"),
712            r#"{
713                // Root tsconfig with project references
714                "references": [
715                    {"path": "./packages/core"},
716                    {"path": "./packages/ui"},
717                ],
718            }"#,
719        )
720        .unwrap();
721
722        let refs = parse_tsconfig_references(&temp_dir);
723        assert_eq!(refs.len(), 2);
724        assert!(refs.iter().any(|p| p.ends_with("packages/core")));
725        assert!(refs.iter().any(|p| p.ends_with("packages/ui")));
726
727        let _ = std::fs::remove_dir_all(&temp_dir);
728    }
729
730    #[test]
731    fn tsconfig_references_no_file() {
732        let refs = parse_tsconfig_references(std::path::Path::new("/nonexistent"));
733        assert!(refs.is_empty());
734    }
735
736    #[test]
737    fn tsconfig_references_no_references_field() {
738        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-no-refs");
739        let _ = std::fs::remove_dir_all(&temp_dir);
740        std::fs::create_dir_all(&temp_dir).unwrap();
741
742        std::fs::write(
743            temp_dir.join("tsconfig.json"),
744            r#"{"compilerOptions": {"strict": true}}"#,
745        )
746        .unwrap();
747
748        let refs = parse_tsconfig_references(&temp_dir);
749        assert!(refs.is_empty());
750
751        let _ = std::fs::remove_dir_all(&temp_dir);
752    }
753
754    #[test]
755    fn tsconfig_references_skips_nonexistent_dirs() {
756        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-missing-dir");
757        let _ = std::fs::remove_dir_all(&temp_dir);
758        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
759
760        std::fs::write(
761            temp_dir.join("tsconfig.json"),
762            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/missing"}]}"#,
763        )
764        .unwrap();
765
766        let refs = parse_tsconfig_references(&temp_dir);
767        assert_eq!(refs.len(), 1);
768        assert!(refs[0].ends_with("packages/core"));
769
770        let _ = std::fs::remove_dir_all(&temp_dir);
771    }
772
773    #[test]
774    fn discover_workspaces_from_tsconfig_references() {
775        let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
776        let _ = std::fs::remove_dir_all(&temp_dir);
777        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
778        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
779
780        // No package.json workspaces — only tsconfig references
781        std::fs::write(
782            temp_dir.join("tsconfig.json"),
783            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
784        )
785        .unwrap();
786
787        // core has package.json with a name
788        std::fs::write(
789            temp_dir.join("packages/core/package.json"),
790            r#"{"name": "@project/core"}"#,
791        )
792        .unwrap();
793
794        // ui has NO package.json — name should fall back to directory name
795        let workspaces = discover_workspaces(&temp_dir);
796        assert_eq!(workspaces.len(), 2);
797        assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
798        assert!(workspaces.iter().any(|ws| ws.name == "ui"));
799
800        let _ = std::fs::remove_dir_all(&temp_dir);
801    }
802
803    #[test]
804    fn tsconfig_references_outside_root_rejected() {
805        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
806        let _ = std::fs::remove_dir_all(&temp_dir);
807        std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
808        // "outside" is a sibling of "project", not inside it
809        std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
810
811        std::fs::write(
812            temp_dir.join("project/tsconfig.json"),
813            r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
814        )
815        .unwrap();
816
817        // Security: "../outside" points outside the project root and should be rejected
818        let workspaces = discover_workspaces(&temp_dir.join("project"));
819        assert_eq!(
820            workspaces.len(),
821            1,
822            "reference outside project root should be rejected: {workspaces:?}"
823        );
824        assert!(
825            workspaces[0]
826                .root
827                .to_string_lossy()
828                .contains("packages/core")
829        );
830
831        let _ = std::fs::remove_dir_all(&temp_dir);
832    }
833
834    #[test]
835    fn package_json_workspace_patterns_array() {
836        let pkg: PackageJson =
837            serde_json::from_str(r#"{"workspaces": ["packages/*", "apps/*"]}"#).unwrap();
838        let patterns = pkg.workspace_patterns();
839        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
840    }
841
842    #[test]
843    fn package_json_workspace_patterns_object() {
844        let pkg: PackageJson =
845            serde_json::from_str(r#"{"workspaces": {"packages": ["packages/*"]}}"#).unwrap();
846        let patterns = pkg.workspace_patterns();
847        assert_eq!(patterns, vec!["packages/*"]);
848    }
849
850    #[test]
851    fn package_json_workspace_patterns_none() {
852        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
853        let patterns = pkg.workspace_patterns();
854        assert!(patterns.is_empty());
855    }
856
857    #[test]
858    fn package_json_workspace_patterns_empty_array() {
859        let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": []}"#).unwrap();
860        let patterns = pkg.workspace_patterns();
861        assert!(patterns.is_empty());
862    }
863
864    #[test]
865    fn package_json_load_valid() {
866        let temp_dir = std::env::temp_dir().join("fallow-test-pkg-json");
867        let _ = std::fs::create_dir_all(&temp_dir);
868        let pkg_path = temp_dir.join("package.json");
869        std::fs::write(&pkg_path, r#"{"name": "test", "main": "index.js"}"#).unwrap();
870
871        let pkg = PackageJson::load(&pkg_path).unwrap();
872        assert_eq!(pkg.name, Some("test".to_string()));
873        assert_eq!(pkg.main, Some("index.js".to_string()));
874
875        let _ = std::fs::remove_dir_all(&temp_dir);
876    }
877
878    #[test]
879    fn package_json_load_missing_file() {
880        let result = PackageJson::load(std::path::Path::new("/nonexistent/package.json"));
881        assert!(result.is_err());
882    }
883
884    #[test]
885    fn package_json_entry_points_combined() {
886        let pkg: PackageJson = serde_json::from_str(
887            r#"{
888            "main": "dist/index.js",
889            "module": "dist/index.mjs",
890            "types": "dist/index.d.ts",
891            "typings": "dist/types.d.ts"
892        }"#,
893        )
894        .unwrap();
895        let entries = pkg.entry_points();
896        assert_eq!(entries.len(), 4);
897        assert!(entries.contains(&"dist/index.js".to_string()));
898        assert!(entries.contains(&"dist/index.mjs".to_string()));
899        assert!(entries.contains(&"dist/index.d.ts".to_string()));
900        assert!(entries.contains(&"dist/types.d.ts".to_string()));
901    }
902
903    #[test]
904    fn package_json_exports_nested() {
905        let pkg: PackageJson = serde_json::from_str(
906            r#"{
907            "exports": {
908                ".": {
909                    "import": "./dist/index.mjs",
910                    "require": "./dist/index.cjs"
911                },
912                "./utils": {
913                    "import": "./dist/utils.mjs"
914                }
915            }
916        }"#,
917        )
918        .unwrap();
919        let entries = pkg.entry_points();
920        assert!(entries.contains(&"./dist/index.mjs".to_string()));
921        assert!(entries.contains(&"./dist/index.cjs".to_string()));
922        assert!(entries.contains(&"./dist/utils.mjs".to_string()));
923    }
924
925    #[test]
926    fn package_json_exports_array() {
927        let pkg: PackageJson = serde_json::from_str(
928            r#"{
929            "exports": {
930                ".": ["./dist/index.mjs", "./dist/index.cjs"]
931            }
932        }"#,
933        )
934        .unwrap();
935        let entries = pkg.entry_points();
936        assert!(entries.contains(&"./dist/index.mjs".to_string()));
937        assert!(entries.contains(&"./dist/index.cjs".to_string()));
938    }
939
940    #[test]
941    fn extract_exports_ignores_non_relative() {
942        let pkg: PackageJson = serde_json::from_str(
943            r#"{
944            "exports": {
945                ".": "not-a-relative-path"
946            }
947        }"#,
948        )
949        .unwrap();
950        let entries = pkg.entry_points();
951        // "not-a-relative-path" doesn't start with "./" so should be excluded
952        assert!(entries.is_empty());
953    }
954
955    #[test]
956    fn package_json_source_field() {
957        let pkg: PackageJson = serde_json::from_str(
958            r#"{
959            "main": "dist/index.js",
960            "source": "src/index.ts"
961        }"#,
962        )
963        .unwrap();
964        let entries = pkg.entry_points();
965        assert!(entries.contains(&"src/index.ts".to_string()));
966        assert!(entries.contains(&"dist/index.js".to_string()));
967    }
968
969    #[test]
970    fn package_json_browser_field_string() {
971        let pkg: PackageJson = serde_json::from_str(
972            r#"{
973            "browser": "./dist/browser.js"
974        }"#,
975        )
976        .unwrap();
977        let entries = pkg.entry_points();
978        assert!(entries.contains(&"./dist/browser.js".to_string()));
979    }
980
981    #[test]
982    fn package_json_browser_field_object() {
983        let pkg: PackageJson = serde_json::from_str(
984            r#"{
985            "browser": {
986                "./server.js": "./browser.js",
987                "module-name": false
988            }
989        }"#,
990        )
991        .unwrap();
992        let entries = pkg.entry_points();
993        assert!(entries.contains(&"./browser.js".to_string()));
994        // non-relative paths and false values should be excluded
995        assert_eq!(entries.len(), 1);
996    }
997}