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