Skip to main content

fallow_config/workspace/
package_json.rs

1use serde::{Deserialize, Serialize};
2
3/// Type alias for standard `HashMap` used in serde-deserialized structs.
4/// `rustc-hash` v2 does not have a `serde` feature, so fields deserialized
5/// from JSON must use `std::collections::HashMap`.
6#[expect(
7    clippy::disallowed_types,
8    reason = "rustc-hash v2 lacks serde feature — JSON deserialization needs std HashMap"
9)]
10type StdHashMap<K, V> = std::collections::HashMap<K, V>;
11
12/// Parsed package.json with fields relevant to fallow.
13#[derive(Debug, Clone, Default, Deserialize, Serialize)]
14pub struct PackageJson {
15    #[serde(default)]
16    pub name: Option<String>,
17    #[serde(default)]
18    pub main: Option<String>,
19    #[serde(default)]
20    pub module: Option<String>,
21    #[serde(default)]
22    pub types: Option<String>,
23    #[serde(default)]
24    pub typings: Option<String>,
25    #[serde(default)]
26    pub source: Option<String>,
27    #[serde(default)]
28    pub browser: Option<serde_json::Value>,
29    #[serde(default)]
30    pub bin: Option<serde_json::Value>,
31    #[serde(default)]
32    pub exports: Option<serde_json::Value>,
33    #[serde(default)]
34    pub dependencies: Option<StdHashMap<String, String>>,
35    #[serde(default, rename = "devDependencies")]
36    pub dev_dependencies: Option<StdHashMap<String, String>>,
37    #[serde(default, rename = "peerDependencies")]
38    pub peer_dependencies: Option<StdHashMap<String, String>>,
39    #[serde(default, rename = "optionalDependencies")]
40    pub optional_dependencies: Option<StdHashMap<String, String>>,
41    #[serde(default)]
42    pub scripts: Option<StdHashMap<String, String>>,
43    #[serde(default)]
44    pub workspaces: Option<serde_json::Value>,
45}
46
47impl PackageJson {
48    /// Load from a package.json file.
49    ///
50    /// # Errors
51    ///
52    /// Returns an error string when the file cannot be read or parsed as JSON.
53    pub fn load(path: &std::path::Path) -> Result<Self, String> {
54        let content = std::fs::read_to_string(path)
55            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
56        serde_json::from_str(&content)
57            .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
58    }
59
60    /// Get all dependency names (production + dev + peer + optional).
61    #[must_use]
62    pub fn all_dependency_names(&self) -> Vec<String> {
63        let mut deps = Vec::new();
64        if let Some(d) = &self.dependencies {
65            deps.extend(d.keys().cloned());
66        }
67        if let Some(d) = &self.dev_dependencies {
68            deps.extend(d.keys().cloned());
69        }
70        if let Some(d) = &self.peer_dependencies {
71            deps.extend(d.keys().cloned());
72        }
73        if let Some(d) = &self.optional_dependencies {
74            deps.extend(d.keys().cloned());
75        }
76        deps
77    }
78
79    /// Get production dependency names only.
80    #[must_use]
81    pub fn production_dependency_names(&self) -> Vec<String> {
82        self.dependencies
83            .as_ref()
84            .map(|d| d.keys().cloned().collect())
85            .unwrap_or_default()
86    }
87
88    /// Get dev dependency names only.
89    #[must_use]
90    pub fn dev_dependency_names(&self) -> Vec<String> {
91        self.dev_dependencies
92            .as_ref()
93            .map(|d| d.keys().cloned().collect())
94            .unwrap_or_default()
95    }
96
97    /// Get optional dependency names only.
98    #[must_use]
99    pub fn optional_dependency_names(&self) -> Vec<String> {
100        self.optional_dependencies
101            .as_ref()
102            .map(|d| d.keys().cloned().collect())
103            .unwrap_or_default()
104    }
105
106    /// Extract entry points from package.json fields.
107    #[must_use]
108    pub fn entry_points(&self) -> Vec<String> {
109        let mut entries = Vec::new();
110
111        if let Some(main) = &self.main {
112            entries.push(main.clone());
113        }
114        if let Some(module) = &self.module {
115            entries.push(module.clone());
116        }
117        if let Some(types) = &self.types {
118            entries.push(types.clone());
119        }
120        if let Some(typings) = &self.typings {
121            entries.push(typings.clone());
122        }
123        if let Some(source) = &self.source {
124            entries.push(source.clone());
125        }
126
127        // Handle browser field (string or object with path values)
128        if let Some(browser) = &self.browser {
129            match browser {
130                serde_json::Value::String(s) => entries.push(s.clone()),
131                serde_json::Value::Object(map) => {
132                    for v in map.values() {
133                        if let serde_json::Value::String(s) = v
134                            && (s.starts_with("./") || s.starts_with("../"))
135                        {
136                            entries.push(s.clone());
137                        }
138                    }
139                }
140                _ => {}
141            }
142        }
143
144        // Handle bin field (string or object)
145        if let Some(bin) = &self.bin {
146            match bin {
147                serde_json::Value::String(s) => entries.push(s.clone()),
148                serde_json::Value::Object(map) => {
149                    for v in map.values() {
150                        if let serde_json::Value::String(s) = v {
151                            entries.push(s.clone());
152                        }
153                    }
154                }
155                _ => {}
156            }
157        }
158
159        // Handle exports field (recursive)
160        if let Some(exports) = &self.exports {
161            extract_exports_entries(exports, &mut entries);
162        }
163
164        entries
165    }
166
167    /// Extract workspace patterns from package.json.
168    #[must_use]
169    pub fn workspace_patterns(&self) -> Vec<String> {
170        match &self.workspaces {
171            Some(serde_json::Value::Array(arr)) => arr
172                .iter()
173                .filter_map(|v| v.as_str().map(String::from))
174                .collect(),
175            Some(serde_json::Value::Object(obj)) => obj
176                .get("packages")
177                .and_then(|v| v.as_array())
178                .map(|arr| {
179                    arr.iter()
180                        .filter_map(|v| v.as_str().map(String::from))
181                        .collect()
182                })
183                .unwrap_or_default(),
184            _ => Vec::new(),
185        }
186    }
187}
188
189/// Recursively extract file paths from package.json exports field.
190fn extract_exports_entries(value: &serde_json::Value, entries: &mut Vec<String>) {
191    match value {
192        serde_json::Value::String(s) => {
193            if s.starts_with("./") || s.starts_with("../") {
194                entries.push(s.clone());
195            }
196        }
197        serde_json::Value::Object(map) => {
198            for v in map.values() {
199                extract_exports_entries(v, entries);
200            }
201        }
202        serde_json::Value::Array(arr) => {
203            for v in arr {
204                extract_exports_entries(v, entries);
205            }
206        }
207        _ => {}
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn package_json_workspace_patterns_array() {
217        let pkg: PackageJson =
218            serde_json::from_str(r#"{"workspaces": ["packages/*", "apps/*"]}"#).unwrap();
219        let patterns = pkg.workspace_patterns();
220        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
221    }
222
223    #[test]
224    fn package_json_workspace_patterns_object() {
225        let pkg: PackageJson =
226            serde_json::from_str(r#"{"workspaces": {"packages": ["packages/*"]}}"#).unwrap();
227        let patterns = pkg.workspace_patterns();
228        assert_eq!(patterns, vec!["packages/*"]);
229    }
230
231    #[test]
232    fn package_json_workspace_patterns_none() {
233        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
234        let patterns = pkg.workspace_patterns();
235        assert!(patterns.is_empty());
236    }
237
238    #[test]
239    fn package_json_workspace_patterns_empty_array() {
240        let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": []}"#).unwrap();
241        let patterns = pkg.workspace_patterns();
242        assert!(patterns.is_empty());
243    }
244
245    #[test]
246    fn package_json_load_valid() {
247        let temp_dir = std::env::temp_dir().join("fallow-test-pkg-json");
248        let _ = std::fs::create_dir_all(&temp_dir);
249        let pkg_path = temp_dir.join("package.json");
250        std::fs::write(&pkg_path, r#"{"name": "test", "main": "index.js"}"#).unwrap();
251
252        let pkg = PackageJson::load(&pkg_path).unwrap();
253        assert_eq!(pkg.name, Some("test".to_string()));
254        assert_eq!(pkg.main, Some("index.js".to_string()));
255
256        let _ = std::fs::remove_dir_all(&temp_dir);
257    }
258
259    #[test]
260    fn package_json_load_missing_file() {
261        let result = PackageJson::load(std::path::Path::new("/nonexistent/package.json"));
262        assert!(result.is_err());
263    }
264
265    #[test]
266    fn package_json_entry_points_combined() {
267        let pkg: PackageJson = serde_json::from_str(
268            r#"{
269            "main": "dist/index.js",
270            "module": "dist/index.mjs",
271            "types": "dist/index.d.ts",
272            "typings": "dist/types.d.ts"
273        }"#,
274        )
275        .unwrap();
276        let entries = pkg.entry_points();
277        assert_eq!(entries.len(), 4);
278        assert!(entries.contains(&"dist/index.js".to_string()));
279        assert!(entries.contains(&"dist/index.mjs".to_string()));
280        assert!(entries.contains(&"dist/index.d.ts".to_string()));
281        assert!(entries.contains(&"dist/types.d.ts".to_string()));
282    }
283
284    #[test]
285    fn package_json_exports_nested() {
286        let pkg: PackageJson = serde_json::from_str(
287            r#"{
288            "exports": {
289                ".": {
290                    "import": "./dist/index.mjs",
291                    "require": "./dist/index.cjs"
292                },
293                "./utils": {
294                    "import": "./dist/utils.mjs"
295                }
296            }
297        }"#,
298        )
299        .unwrap();
300        let entries = pkg.entry_points();
301        assert!(entries.contains(&"./dist/index.mjs".to_string()));
302        assert!(entries.contains(&"./dist/index.cjs".to_string()));
303        assert!(entries.contains(&"./dist/utils.mjs".to_string()));
304    }
305
306    #[test]
307    fn package_json_exports_array() {
308        let pkg: PackageJson = serde_json::from_str(
309            r#"{
310            "exports": {
311                ".": ["./dist/index.mjs", "./dist/index.cjs"]
312            }
313        }"#,
314        )
315        .unwrap();
316        let entries = pkg.entry_points();
317        assert!(entries.contains(&"./dist/index.mjs".to_string()));
318        assert!(entries.contains(&"./dist/index.cjs".to_string()));
319    }
320
321    #[test]
322    fn extract_exports_ignores_non_relative() {
323        let pkg: PackageJson = serde_json::from_str(
324            r#"{
325            "exports": {
326                ".": "not-a-relative-path"
327            }
328        }"#,
329        )
330        .unwrap();
331        let entries = pkg.entry_points();
332        // "not-a-relative-path" doesn't start with "./" so should be excluded
333        assert!(entries.is_empty());
334    }
335
336    #[test]
337    fn package_json_source_field() {
338        let pkg: PackageJson = serde_json::from_str(
339            r#"{
340            "main": "dist/index.js",
341            "source": "src/index.ts"
342        }"#,
343        )
344        .unwrap();
345        let entries = pkg.entry_points();
346        assert!(entries.contains(&"src/index.ts".to_string()));
347        assert!(entries.contains(&"dist/index.js".to_string()));
348    }
349
350    #[test]
351    fn package_json_browser_field_string() {
352        let pkg: PackageJson = serde_json::from_str(
353            r#"{
354            "browser": "./dist/browser.js"
355        }"#,
356        )
357        .unwrap();
358        let entries = pkg.entry_points();
359        assert!(entries.contains(&"./dist/browser.js".to_string()));
360    }
361
362    #[test]
363    fn package_json_browser_field_object() {
364        let pkg: PackageJson = serde_json::from_str(
365            r#"{
366            "browser": {
367                "./server.js": "./browser.js",
368                "module-name": false
369            }
370        }"#,
371        )
372        .unwrap();
373        let entries = pkg.entry_points();
374        assert!(entries.contains(&"./browser.js".to_string()));
375        // non-relative paths and false values should be excluded
376        assert_eq!(entries.len(), 1);
377    }
378
379    #[test]
380    fn package_json_exports_string() {
381        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
382        let entries = pkg.entry_points();
383        assert_eq!(entries, vec!["./dist/index.js"]);
384    }
385
386    #[test]
387    fn package_json_workspace_patterns_object_with_nohoist() {
388        let pkg: PackageJson = serde_json::from_str(
389            r#"{
390            "workspaces": {
391                "packages": ["packages/*", "apps/*"],
392                "nohoist": ["**/react-native"]
393            }
394        }"#,
395        )
396        .unwrap();
397        let patterns = pkg.workspace_patterns();
398        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
399    }
400
401    #[test]
402    fn package_json_missing_optional_fields() {
403        let pkg: PackageJson = serde_json::from_str(r"{}").unwrap();
404        assert!(pkg.name.is_none());
405        assert!(pkg.main.is_none());
406        assert!(pkg.module.is_none());
407        assert!(pkg.types.is_none());
408        assert!(pkg.typings.is_none());
409        assert!(pkg.source.is_none());
410        assert!(pkg.browser.is_none());
411        assert!(pkg.bin.is_none());
412        assert!(pkg.exports.is_none());
413        assert!(pkg.dependencies.is_none());
414        assert!(pkg.dev_dependencies.is_none());
415        assert!(pkg.peer_dependencies.is_none());
416        assert!(pkg.optional_dependencies.is_none());
417        assert!(pkg.scripts.is_none());
418        assert!(pkg.workspaces.is_none());
419        assert!(pkg.entry_points().is_empty());
420        assert!(pkg.workspace_patterns().is_empty());
421        assert!(pkg.all_dependency_names().is_empty());
422    }
423
424    #[test]
425    fn package_json_all_dependency_names() {
426        let pkg: PackageJson = serde_json::from_str(
427            r#"{
428            "dependencies": {"react": "^18", "react-dom": "^18"},
429            "devDependencies": {"typescript": "^5"},
430            "peerDependencies": {"node": ">=18"},
431            "optionalDependencies": {"fsevents": "^2"}
432        }"#,
433        )
434        .unwrap();
435        let deps = pkg.all_dependency_names();
436        assert_eq!(deps.len(), 5);
437        assert!(deps.contains(&"react".to_string()));
438        assert!(deps.contains(&"react-dom".to_string()));
439        assert!(deps.contains(&"typescript".to_string()));
440        assert!(deps.contains(&"node".to_string()));
441        assert!(deps.contains(&"fsevents".to_string()));
442    }
443
444    #[test]
445    fn package_json_production_dependency_names() {
446        let pkg: PackageJson = serde_json::from_str(
447            r#"{
448            "dependencies": {"react": "^18"},
449            "devDependencies": {"typescript": "^5"}
450        }"#,
451        )
452        .unwrap();
453        let prod = pkg.production_dependency_names();
454        assert_eq!(prod, vec!["react"]);
455        let dev = pkg.dev_dependency_names();
456        assert_eq!(dev, vec!["typescript"]);
457    }
458
459    #[test]
460    fn package_json_bin_field_string() {
461        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "./cli.js"}"#).unwrap();
462        let entries = pkg.entry_points();
463        assert!(entries.contains(&"./cli.js".to_string()));
464    }
465
466    #[test]
467    fn package_json_bin_field_object() {
468        let pkg: PackageJson = serde_json::from_str(
469            r#"{"bin": {"my-cli": "./bin/cli.js", "my-tool": "./bin/tool.js"}}"#,
470        )
471        .unwrap();
472        let entries = pkg.entry_points();
473        assert!(entries.contains(&"./bin/cli.js".to_string()));
474        assert!(entries.contains(&"./bin/tool.js".to_string()));
475    }
476
477    #[test]
478    fn package_json_exports_deeply_nested() {
479        let pkg: PackageJson = serde_json::from_str(
480            r#"{
481            "exports": {
482                ".": {
483                    "node": {
484                        "import": "./dist/node.mjs",
485                        "require": "./dist/node.cjs"
486                    },
487                    "browser": {
488                        "import": "./dist/browser.mjs"
489                    }
490                }
491            }
492        }"#,
493        )
494        .unwrap();
495        let entries = pkg.entry_points();
496        assert_eq!(entries.len(), 3);
497        assert!(entries.contains(&"./dist/node.mjs".to_string()));
498        assert!(entries.contains(&"./dist/node.cjs".to_string()));
499        assert!(entries.contains(&"./dist/browser.mjs".to_string()));
500    }
501
502    // ── Peer dependency names ───────────────────────────────────────
503
504    #[test]
505    fn package_json_peer_deps_only() {
506        let pkg: PackageJson =
507            serde_json::from_str(r#"{"peerDependencies": {"react": "^18", "react-dom": "^18"}}"#)
508                .unwrap();
509        let all = pkg.all_dependency_names();
510        assert_eq!(all.len(), 2);
511        assert!(all.contains(&"react".to_string()));
512        assert!(all.contains(&"react-dom".to_string()));
513
514        // No production or dev deps
515        assert!(pkg.production_dependency_names().is_empty());
516        assert!(pkg.dev_dependency_names().is_empty());
517    }
518
519    // ── Optional dependencies ───────────────────────────────────────
520
521    #[test]
522    fn package_json_optional_deps_in_all_names() {
523        let pkg: PackageJson =
524            serde_json::from_str(r#"{"optionalDependencies": {"fsevents": "^2"}}"#).unwrap();
525        let all = pkg.all_dependency_names();
526        assert!(all.contains(&"fsevents".to_string()));
527    }
528
529    // ── Browser field edge cases ────────────────────────────────────
530
531    #[test]
532    fn package_json_browser_array_ignored() {
533        // Browser field as array is not supported -- should not crash
534        let pkg: PackageJson =
535            serde_json::from_str(r#"{"browser": ["./a.js", "./b.js"]}"#).unwrap();
536        let entries = pkg.entry_points();
537        assert!(
538            entries.is_empty(),
539            "array browser field should not produce entries"
540        );
541    }
542
543    #[test]
544    fn package_json_browser_object_non_relative_skipped() {
545        let pkg: PackageJson = serde_json::from_str(
546            r#"{"browser": {"crypto": false, "./local.js": "./browser-local.js"}}"#,
547        )
548        .unwrap();
549        let entries = pkg.entry_points();
550        // false is not a string, "crypto" is not relative
551        // only "./browser-local.js" starts with "./"
552        assert_eq!(entries.len(), 1);
553        assert!(entries.contains(&"./browser-local.js".to_string()));
554    }
555
556    // ── Exports field edge cases ────────────────────────────────────
557
558    #[test]
559    fn package_json_exports_null_value() {
560        // Some packages use null for subpath exclusions
561        let pkg: PackageJson =
562            serde_json::from_str(r#"{"exports": {".": "./dist/index.js", "./internal": null}}"#)
563                .unwrap();
564        let entries = pkg.entry_points();
565        assert_eq!(entries.len(), 1);
566        assert!(entries.contains(&"./dist/index.js".to_string()));
567    }
568
569    #[test]
570    fn package_json_exports_empty_object() {
571        let pkg: PackageJson = serde_json::from_str(r#"{"exports": {}}"#).unwrap();
572        let entries = pkg.entry_points();
573        assert!(entries.is_empty());
574    }
575
576    // ── Workspace patterns edge cases ───────────────────────────────
577
578    #[test]
579    fn package_json_workspace_patterns_string_value_ignored() {
580        // workspaces as a string is not a valid format
581        let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": "packages/*"}"#).unwrap();
582        let patterns = pkg.workspace_patterns();
583        assert!(patterns.is_empty());
584    }
585
586    #[test]
587    fn package_json_workspace_patterns_object_missing_packages() {
588        let pkg: PackageJson =
589            serde_json::from_str(r#"{"workspaces": {"nohoist": ["**/react-native"]}}"#).unwrap();
590        let patterns = pkg.workspace_patterns();
591        assert!(patterns.is_empty());
592    }
593
594    // ── Load from invalid JSON ──────────────────────────────────────
595
596    #[test]
597    fn package_json_load_invalid_json() {
598        let temp_dir = std::env::temp_dir().join("fallow-test-invalid-pkg-json");
599        let _ = std::fs::create_dir_all(&temp_dir);
600        let pkg_path = temp_dir.join("package.json");
601        std::fs::write(&pkg_path, "{ not valid json }").unwrap();
602
603        let result = PackageJson::load(&pkg_path);
604        assert!(result.is_err());
605        let err = result.unwrap_err();
606        assert!(err.contains("Failed to parse"), "got: {err}");
607
608        let _ = std::fs::remove_dir_all(&temp_dir);
609    }
610
611    // ── Bin field with non-string value ─────────────────────────────
612
613    #[test]
614    fn package_json_bin_object_non_string_values_skipped() {
615        let pkg: PackageJson =
616            serde_json::from_str(r#"{"bin": {"cli": "./bin/cli.js", "bad": 42}}"#).unwrap();
617        let entries = pkg.entry_points();
618        assert_eq!(entries.len(), 1);
619        assert!(entries.contains(&"./bin/cli.js".to_string()));
620    }
621
622    // ── Default trait ───────────────────────────────────────────────
623
624    #[test]
625    fn package_json_default() {
626        let pkg = PackageJson::default();
627        assert!(pkg.name.is_none());
628        assert!(pkg.main.is_none());
629        assert!(pkg.entry_points().is_empty());
630        assert!(pkg.all_dependency_names().is_empty());
631        assert!(pkg.workspace_patterns().is_empty());
632    }
633}