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(clippy::disallowed_types)]
7type StdHashMap<K, V> = std::collections::HashMap<K, V>;
8
9/// Parsed package.json with fields relevant to fallow.
10#[derive(Debug, Clone, Default, Deserialize, Serialize)]
11pub struct PackageJson {
12    #[serde(default)]
13    pub name: Option<String>,
14    #[serde(default)]
15    pub main: Option<String>,
16    #[serde(default)]
17    pub module: Option<String>,
18    #[serde(default)]
19    pub types: Option<String>,
20    #[serde(default)]
21    pub typings: Option<String>,
22    #[serde(default)]
23    pub source: Option<String>,
24    #[serde(default)]
25    pub browser: Option<serde_json::Value>,
26    #[serde(default)]
27    pub bin: Option<serde_json::Value>,
28    #[serde(default)]
29    pub exports: Option<serde_json::Value>,
30    #[serde(default)]
31    pub dependencies: Option<StdHashMap<String, String>>,
32    #[serde(default, rename = "devDependencies")]
33    pub dev_dependencies: Option<StdHashMap<String, String>>,
34    #[serde(default, rename = "peerDependencies")]
35    pub peer_dependencies: Option<StdHashMap<String, String>>,
36    #[serde(default, rename = "optionalDependencies")]
37    pub optional_dependencies: Option<StdHashMap<String, String>>,
38    #[serde(default)]
39    pub scripts: Option<StdHashMap<String, String>>,
40    #[serde(default)]
41    pub workspaces: Option<serde_json::Value>,
42}
43
44impl PackageJson {
45    /// Load from a package.json file.
46    ///
47    /// # Errors
48    ///
49    /// Returns an error string when the file cannot be read or parsed as JSON.
50    pub fn load(path: &std::path::Path) -> Result<Self, String> {
51        let content = std::fs::read_to_string(path)
52            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
53        serde_json::from_str(&content)
54            .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
55    }
56
57    /// Get all dependency names (production + dev + peer + optional).
58    #[must_use]
59    pub fn all_dependency_names(&self) -> Vec<String> {
60        let mut deps = Vec::new();
61        if let Some(d) = &self.dependencies {
62            deps.extend(d.keys().cloned());
63        }
64        if let Some(d) = &self.dev_dependencies {
65            deps.extend(d.keys().cloned());
66        }
67        if let Some(d) = &self.peer_dependencies {
68            deps.extend(d.keys().cloned());
69        }
70        if let Some(d) = &self.optional_dependencies {
71            deps.extend(d.keys().cloned());
72        }
73        deps
74    }
75
76    /// Get production dependency names only.
77    #[must_use]
78    pub fn production_dependency_names(&self) -> Vec<String> {
79        self.dependencies
80            .as_ref()
81            .map(|d| d.keys().cloned().collect())
82            .unwrap_or_default()
83    }
84
85    /// Get dev dependency names only.
86    #[must_use]
87    pub fn dev_dependency_names(&self) -> Vec<String> {
88        self.dev_dependencies
89            .as_ref()
90            .map(|d| d.keys().cloned().collect())
91            .unwrap_or_default()
92    }
93
94    /// Get optional dependency names only.
95    #[must_use]
96    pub fn optional_dependency_names(&self) -> Vec<String> {
97        self.optional_dependencies
98            .as_ref()
99            .map(|d| d.keys().cloned().collect())
100            .unwrap_or_default()
101    }
102
103    /// Extract entry points from package.json fields.
104    #[must_use]
105    pub fn entry_points(&self) -> Vec<String> {
106        let mut entries = Vec::new();
107
108        if let Some(main) = &self.main {
109            entries.push(main.clone());
110        }
111        if let Some(module) = &self.module {
112            entries.push(module.clone());
113        }
114        if let Some(types) = &self.types {
115            entries.push(types.clone());
116        }
117        if let Some(typings) = &self.typings {
118            entries.push(typings.clone());
119        }
120        if let Some(source) = &self.source {
121            entries.push(source.clone());
122        }
123
124        // Handle browser field (string or object with path values)
125        if let Some(browser) = &self.browser {
126            match browser {
127                serde_json::Value::String(s) => entries.push(s.clone()),
128                serde_json::Value::Object(map) => {
129                    for v in map.values() {
130                        if let serde_json::Value::String(s) = v
131                            && (s.starts_with("./") || s.starts_with("../"))
132                        {
133                            entries.push(s.clone());
134                        }
135                    }
136                }
137                _ => {}
138            }
139        }
140
141        // Handle bin field (string or object)
142        if let Some(bin) = &self.bin {
143            match bin {
144                serde_json::Value::String(s) => entries.push(s.clone()),
145                serde_json::Value::Object(map) => {
146                    for v in map.values() {
147                        if let serde_json::Value::String(s) = v {
148                            entries.push(s.clone());
149                        }
150                    }
151                }
152                _ => {}
153            }
154        }
155
156        // Handle exports field (recursive)
157        if let Some(exports) = &self.exports {
158            extract_exports_entries(exports, &mut entries);
159        }
160
161        entries
162    }
163
164    /// Extract workspace patterns from package.json.
165    #[must_use]
166    pub fn workspace_patterns(&self) -> Vec<String> {
167        match &self.workspaces {
168            Some(serde_json::Value::Array(arr)) => arr
169                .iter()
170                .filter_map(|v| v.as_str().map(String::from))
171                .collect(),
172            Some(serde_json::Value::Object(obj)) => obj
173                .get("packages")
174                .and_then(|v| v.as_array())
175                .map(|arr| {
176                    arr.iter()
177                        .filter_map(|v| v.as_str().map(String::from))
178                        .collect()
179                })
180                .unwrap_or_default(),
181            _ => Vec::new(),
182        }
183    }
184}
185
186/// Recursively extract file paths from package.json exports field.
187fn extract_exports_entries(value: &serde_json::Value, entries: &mut Vec<String>) {
188    match value {
189        serde_json::Value::String(s) => {
190            if s.starts_with("./") || s.starts_with("../") {
191                entries.push(s.clone());
192            }
193        }
194        serde_json::Value::Object(map) => {
195            for v in map.values() {
196                extract_exports_entries(v, entries);
197            }
198        }
199        serde_json::Value::Array(arr) => {
200            for v in arr {
201                extract_exports_entries(v, entries);
202            }
203        }
204        _ => {}
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn package_json_workspace_patterns_array() {
214        let pkg: PackageJson =
215            serde_json::from_str(r#"{"workspaces": ["packages/*", "apps/*"]}"#).unwrap();
216        let patterns = pkg.workspace_patterns();
217        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
218    }
219
220    #[test]
221    fn package_json_workspace_patterns_object() {
222        let pkg: PackageJson =
223            serde_json::from_str(r#"{"workspaces": {"packages": ["packages/*"]}}"#).unwrap();
224        let patterns = pkg.workspace_patterns();
225        assert_eq!(patterns, vec!["packages/*"]);
226    }
227
228    #[test]
229    fn package_json_workspace_patterns_none() {
230        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
231        let patterns = pkg.workspace_patterns();
232        assert!(patterns.is_empty());
233    }
234
235    #[test]
236    fn package_json_workspace_patterns_empty_array() {
237        let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": []}"#).unwrap();
238        let patterns = pkg.workspace_patterns();
239        assert!(patterns.is_empty());
240    }
241
242    #[test]
243    fn package_json_load_valid() {
244        let temp_dir = std::env::temp_dir().join("fallow-test-pkg-json");
245        let _ = std::fs::create_dir_all(&temp_dir);
246        let pkg_path = temp_dir.join("package.json");
247        std::fs::write(&pkg_path, r#"{"name": "test", "main": "index.js"}"#).unwrap();
248
249        let pkg = PackageJson::load(&pkg_path).unwrap();
250        assert_eq!(pkg.name, Some("test".to_string()));
251        assert_eq!(pkg.main, Some("index.js".to_string()));
252
253        let _ = std::fs::remove_dir_all(&temp_dir);
254    }
255
256    #[test]
257    fn package_json_load_missing_file() {
258        let result = PackageJson::load(std::path::Path::new("/nonexistent/package.json"));
259        assert!(result.is_err());
260    }
261
262    #[test]
263    fn package_json_entry_points_combined() {
264        let pkg: PackageJson = serde_json::from_str(
265            r#"{
266            "main": "dist/index.js",
267            "module": "dist/index.mjs",
268            "types": "dist/index.d.ts",
269            "typings": "dist/types.d.ts"
270        }"#,
271        )
272        .unwrap();
273        let entries = pkg.entry_points();
274        assert_eq!(entries.len(), 4);
275        assert!(entries.contains(&"dist/index.js".to_string()));
276        assert!(entries.contains(&"dist/index.mjs".to_string()));
277        assert!(entries.contains(&"dist/index.d.ts".to_string()));
278        assert!(entries.contains(&"dist/types.d.ts".to_string()));
279    }
280
281    #[test]
282    fn package_json_exports_nested() {
283        let pkg: PackageJson = serde_json::from_str(
284            r#"{
285            "exports": {
286                ".": {
287                    "import": "./dist/index.mjs",
288                    "require": "./dist/index.cjs"
289                },
290                "./utils": {
291                    "import": "./dist/utils.mjs"
292                }
293            }
294        }"#,
295        )
296        .unwrap();
297        let entries = pkg.entry_points();
298        assert!(entries.contains(&"./dist/index.mjs".to_string()));
299        assert!(entries.contains(&"./dist/index.cjs".to_string()));
300        assert!(entries.contains(&"./dist/utils.mjs".to_string()));
301    }
302
303    #[test]
304    fn package_json_exports_array() {
305        let pkg: PackageJson = serde_json::from_str(
306            r#"{
307            "exports": {
308                ".": ["./dist/index.mjs", "./dist/index.cjs"]
309            }
310        }"#,
311        )
312        .unwrap();
313        let entries = pkg.entry_points();
314        assert!(entries.contains(&"./dist/index.mjs".to_string()));
315        assert!(entries.contains(&"./dist/index.cjs".to_string()));
316    }
317
318    #[test]
319    fn extract_exports_ignores_non_relative() {
320        let pkg: PackageJson = serde_json::from_str(
321            r#"{
322            "exports": {
323                ".": "not-a-relative-path"
324            }
325        }"#,
326        )
327        .unwrap();
328        let entries = pkg.entry_points();
329        // "not-a-relative-path" doesn't start with "./" so should be excluded
330        assert!(entries.is_empty());
331    }
332
333    #[test]
334    fn package_json_source_field() {
335        let pkg: PackageJson = serde_json::from_str(
336            r#"{
337            "main": "dist/index.js",
338            "source": "src/index.ts"
339        }"#,
340        )
341        .unwrap();
342        let entries = pkg.entry_points();
343        assert!(entries.contains(&"src/index.ts".to_string()));
344        assert!(entries.contains(&"dist/index.js".to_string()));
345    }
346
347    #[test]
348    fn package_json_browser_field_string() {
349        let pkg: PackageJson = serde_json::from_str(
350            r#"{
351            "browser": "./dist/browser.js"
352        }"#,
353        )
354        .unwrap();
355        let entries = pkg.entry_points();
356        assert!(entries.contains(&"./dist/browser.js".to_string()));
357    }
358
359    #[test]
360    fn package_json_browser_field_object() {
361        let pkg: PackageJson = serde_json::from_str(
362            r#"{
363            "browser": {
364                "./server.js": "./browser.js",
365                "module-name": false
366            }
367        }"#,
368        )
369        .unwrap();
370        let entries = pkg.entry_points();
371        assert!(entries.contains(&"./browser.js".to_string()));
372        // non-relative paths and false values should be excluded
373        assert_eq!(entries.len(), 1);
374    }
375
376    #[test]
377    fn package_json_exports_string() {
378        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
379        let entries = pkg.entry_points();
380        assert_eq!(entries, vec!["./dist/index.js"]);
381    }
382
383    #[test]
384    fn package_json_workspace_patterns_object_with_nohoist() {
385        let pkg: PackageJson = serde_json::from_str(
386            r#"{
387            "workspaces": {
388                "packages": ["packages/*", "apps/*"],
389                "nohoist": ["**/react-native"]
390            }
391        }"#,
392        )
393        .unwrap();
394        let patterns = pkg.workspace_patterns();
395        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
396    }
397
398    #[test]
399    fn package_json_missing_optional_fields() {
400        let pkg: PackageJson = serde_json::from_str(r"{}").unwrap();
401        assert!(pkg.name.is_none());
402        assert!(pkg.main.is_none());
403        assert!(pkg.module.is_none());
404        assert!(pkg.types.is_none());
405        assert!(pkg.typings.is_none());
406        assert!(pkg.source.is_none());
407        assert!(pkg.browser.is_none());
408        assert!(pkg.bin.is_none());
409        assert!(pkg.exports.is_none());
410        assert!(pkg.dependencies.is_none());
411        assert!(pkg.dev_dependencies.is_none());
412        assert!(pkg.peer_dependencies.is_none());
413        assert!(pkg.optional_dependencies.is_none());
414        assert!(pkg.scripts.is_none());
415        assert!(pkg.workspaces.is_none());
416        assert!(pkg.entry_points().is_empty());
417        assert!(pkg.workspace_patterns().is_empty());
418        assert!(pkg.all_dependency_names().is_empty());
419    }
420
421    #[test]
422    fn package_json_all_dependency_names() {
423        let pkg: PackageJson = serde_json::from_str(
424            r#"{
425            "dependencies": {"react": "^18", "react-dom": "^18"},
426            "devDependencies": {"typescript": "^5"},
427            "peerDependencies": {"node": ">=18"},
428            "optionalDependencies": {"fsevents": "^2"}
429        }"#,
430        )
431        .unwrap();
432        let deps = pkg.all_dependency_names();
433        assert_eq!(deps.len(), 5);
434        assert!(deps.contains(&"react".to_string()));
435        assert!(deps.contains(&"react-dom".to_string()));
436        assert!(deps.contains(&"typescript".to_string()));
437        assert!(deps.contains(&"node".to_string()));
438        assert!(deps.contains(&"fsevents".to_string()));
439    }
440
441    #[test]
442    fn package_json_production_dependency_names() {
443        let pkg: PackageJson = serde_json::from_str(
444            r#"{
445            "dependencies": {"react": "^18"},
446            "devDependencies": {"typescript": "^5"}
447        }"#,
448        )
449        .unwrap();
450        let prod = pkg.production_dependency_names();
451        assert_eq!(prod, vec!["react"]);
452        let dev = pkg.dev_dependency_names();
453        assert_eq!(dev, vec!["typescript"]);
454    }
455
456    #[test]
457    fn package_json_bin_field_string() {
458        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "./cli.js"}"#).unwrap();
459        let entries = pkg.entry_points();
460        assert!(entries.contains(&"./cli.js".to_string()));
461    }
462
463    #[test]
464    fn package_json_bin_field_object() {
465        let pkg: PackageJson = serde_json::from_str(
466            r#"{"bin": {"my-cli": "./bin/cli.js", "my-tool": "./bin/tool.js"}}"#,
467        )
468        .unwrap();
469        let entries = pkg.entry_points();
470        assert!(entries.contains(&"./bin/cli.js".to_string()));
471        assert!(entries.contains(&"./bin/tool.js".to_string()));
472    }
473
474    #[test]
475    fn package_json_exports_deeply_nested() {
476        let pkg: PackageJson = serde_json::from_str(
477            r#"{
478            "exports": {
479                ".": {
480                    "node": {
481                        "import": "./dist/node.mjs",
482                        "require": "./dist/node.cjs"
483                    },
484                    "browser": {
485                        "import": "./dist/browser.mjs"
486                    }
487                }
488            }
489        }"#,
490        )
491        .unwrap();
492        let entries = pkg.entry_points();
493        assert_eq!(entries.len(), 3);
494        assert!(entries.contains(&"./dist/node.mjs".to_string()));
495        assert!(entries.contains(&"./dist/node.cjs".to_string()));
496        assert!(entries.contains(&"./dist/browser.mjs".to_string()));
497    }
498}