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