Skip to main content

fallow_config/workspace/
package_json.rs

1use serde::{Deserialize, Deserializer, Serialize, de::Error};
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
12fn deserialize_optional_bool_lenient<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
13where
14    D: Deserializer<'de>,
15{
16    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
17    Ok(match value {
18        Some(serde_json::Value::Bool(value)) => Some(value),
19        _ => None,
20    })
21}
22
23fn deserialize_string_array_lenient<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
24where
25    D: Deserializer<'de>,
26{
27    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
28    Ok(match value {
29        Some(serde_json::Value::Array(values)) => values
30            .into_iter()
31            .filter_map(|value| value.as_str().map(str::to_owned))
32            .collect(),
33        _ => Vec::new(),
34    })
35}
36
37fn deserialize_optional_string_lenient<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
38where
39    D: Deserializer<'de>,
40{
41    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
42    Ok(match value {
43        Some(serde_json::Value::String(value)) if !value.trim().is_empty() => Some(value),
44        _ => None,
45    })
46}
47
48fn deserialize_optional_napi_config<'de, D>(deserializer: D) -> Result<Option<NapiConfig>, D::Error>
49where
50    D: Deserializer<'de>,
51{
52    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
53    match value {
54        Some(serde_json::Value::Object(map)) => {
55            serde_json::from_value(serde_json::Value::Object(map))
56                .map(Some)
57                .map_err(D::Error::custom)
58        }
59        _ => Ok(None),
60    }
61}
62
63#[derive(Debug, Clone, Default, Deserialize, Serialize)]
64pub struct PeerDependencyMeta {
65    #[serde(default)]
66    pub optional: bool,
67}
68
69/// The NAPI-RS `napi` block of a `package.json`, used to recognize the
70/// per-platform artifact packages a native addon generates so they are not
71/// reported as unused dependencies. All fields deserialize leniently: a
72/// wrong-typed value becomes `None`/empty instead of failing manifest parsing.
73#[derive(Debug, Clone, Default, Deserialize, Serialize)]
74pub struct NapiConfig {
75    /// The `binaryName` field: basename of the generated `.node` binary.
76    #[serde(
77        default,
78        rename = "binaryName",
79        deserialize_with = "deserialize_optional_string_lenient"
80    )]
81    pub binary_name: Option<String>,
82    /// The `packageName` field: base npm package name from which per-platform
83    /// artifact package names (`<name>-<triple>`) are derived.
84    #[serde(
85        default,
86        rename = "packageName",
87        deserialize_with = "deserialize_optional_string_lenient"
88    )]
89    pub package_name: Option<String>,
90    /// The `targets` field: platform triples the addon is built for.
91    #[serde(default, deserialize_with = "deserialize_string_array_lenient")]
92    pub targets: Vec<String>,
93}
94
95/// Parsed package.json with fields relevant to fallow.
96#[derive(Debug, Clone, Default, Deserialize, Serialize)]
97pub struct PackageJson {
98    /// The `name` field: the package's npm name, used to identify workspace
99    /// packages and match `public_packages` config entries.
100    #[serde(default)]
101    pub name: Option<String>,
102    /// The `private` field. Non-boolean values become `None` rather than
103    /// failing manifest parsing.
104    #[serde(default, deserialize_with = "deserialize_optional_bool_lenient")]
105    pub private: Option<bool>,
106    /// The `main` field: CommonJS entry-point path, seeded as an entry point.
107    #[serde(default)]
108    pub main: Option<String>,
109    /// The `module` field: ESM entry-point path, seeded as an entry point.
110    #[serde(default)]
111    pub module: Option<String>,
112    /// The `types` field: type-declaration entry-point path.
113    #[serde(default)]
114    pub types: Option<String>,
115    /// The `typings` field: legacy spelling of `types`.
116    #[serde(default)]
117    pub typings: Option<String>,
118    /// The `source` field: unbundled source entry point used by bundler-based
119    /// packages (Parcel convention).
120    #[serde(default)]
121    pub source: Option<String>,
122    /// The `files` field: publish allowlist patterns. Non-array values become
123    /// empty rather than failing manifest parsing.
124    #[serde(default, deserialize_with = "deserialize_string_array_lenient")]
125    pub files: Vec<String>,
126    /// The `browser` field, kept raw because it is either a string entry point
127    /// or a path-remap object.
128    #[serde(default)]
129    pub browser: Option<serde_json::Value>,
130    /// The `bin` field, kept raw because it is either a string path or a
131    /// name-to-path object; every referenced file is an entry point.
132    #[serde(default)]
133    pub bin: Option<serde_json::Value>,
134    /// The `exports` field, kept raw because of its many shapes (string,
135    /// conditions object, subpath map); referenced files are entry points and
136    /// the map drives subpath resolution.
137    #[serde(default)]
138    pub exports: Option<serde_json::Value>,
139    /// The `imports` field (`#`-prefixed internal subpath map), kept raw and
140    /// consumed by the module resolver.
141    #[serde(default)]
142    pub imports: Option<serde_json::Value>,
143    /// The `dependencies` map (package name to version range), the base set
144    /// for unused/unlisted-dependency accounting.
145    #[serde(default)]
146    pub dependencies: Option<StdHashMap<String, String>>,
147    /// The `devDependencies` map, checked by `unused-dev-dependencies` and
148    /// `dev-dependency-in-production`.
149    #[serde(default, rename = "devDependencies")]
150    pub dev_dependencies: Option<StdHashMap<String, String>>,
151    /// The `peerDependencies` map. Peers are provided by the consumer, so
152    /// they are exempt from unused-dependency reporting.
153    #[serde(default, rename = "peerDependencies")]
154    pub peer_dependencies: Option<StdHashMap<String, String>>,
155    /// The `peerDependenciesMeta` map, marking which peers are optional.
156    #[serde(default, rename = "peerDependenciesMeta")]
157    pub peer_dependencies_meta: Option<StdHashMap<String, PeerDependencyMeta>>,
158    /// The `optionalDependencies` map, checked by
159    /// `unused-optional-dependencies`.
160    #[serde(default, rename = "optionalDependencies")]
161    pub optional_dependencies: Option<StdHashMap<String, String>>,
162    /// The `scripts` map, scanned so packages invoked from scripts count as
163    /// used tooling dependencies.
164    #[serde(default)]
165    pub scripts: Option<StdHashMap<String, String>>,
166    /// The NAPI-RS `napi` block; see [`NapiConfig`]. Non-object values become
167    /// `None` rather than failing manifest parsing.
168    #[serde(default, deserialize_with = "deserialize_optional_napi_config")]
169    pub napi: Option<NapiConfig>,
170    /// The `workspaces` field, kept raw because it is either a pattern array
171    /// or an object with a `packages` array; drives workspace discovery.
172    #[serde(default)]
173    pub workspaces: Option<serde_json::Value>,
174    /// The `packageManager` field (e.g. `"pnpm@9.1.0"`), used to determine
175    /// the canonical package manager for the project.
176    #[serde(
177        default,
178        rename = "packageManager",
179        deserialize_with = "deserialize_optional_string_lenient"
180    )]
181    pub package_manager: Option<String>,
182}
183
184impl PackageJson {
185    /// Load from a package.json file.
186    ///
187    /// # Errors
188    ///
189    /// Returns an error string when the file cannot be read or parsed as JSON.
190    pub fn load(path: &std::path::Path) -> Result<Self, String> {
191        let content = std::fs::read_to_string(path)
192            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
193        let content = content.trim_start_matches('\u{FEFF}');
194        serde_json::from_str(content)
195            .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
196    }
197
198    /// Get all dependency names (production + dev + peer + optional).
199    #[must_use]
200    pub fn all_dependency_names(&self) -> Vec<String> {
201        let mut deps = Vec::new();
202        if let Some(d) = &self.dependencies {
203            deps.extend(d.keys().cloned());
204        }
205        if let Some(d) = &self.dev_dependencies {
206            deps.extend(d.keys().cloned());
207        }
208        if let Some(d) = &self.peer_dependencies {
209            deps.extend(d.keys().cloned());
210        }
211        if let Some(d) = &self.optional_dependencies {
212            deps.extend(d.keys().cloned());
213        }
214        deps
215    }
216
217    /// Get production dependency names only.
218    #[must_use]
219    pub fn production_dependency_names(&self) -> Vec<String> {
220        self.dependencies
221            .as_ref()
222            .map(|d| d.keys().cloned().collect())
223            .unwrap_or_default()
224    }
225
226    /// Get dev dependency names only.
227    #[must_use]
228    pub fn dev_dependency_names(&self) -> Vec<String> {
229        self.dev_dependencies
230            .as_ref()
231            .map(|d| d.keys().cloned().collect())
232            .unwrap_or_default()
233    }
234
235    /// Get optional dependency names only.
236    #[must_use]
237    pub fn optional_dependency_names(&self) -> Vec<String> {
238        self.optional_dependencies
239            .as_ref()
240            .map(|d| d.keys().cloned().collect())
241            .unwrap_or_default()
242    }
243
244    /// Get every peer dependency name, required or optional.
245    #[must_use]
246    pub fn peer_dependency_names(&self) -> Vec<String> {
247        self.peer_dependencies
248            .as_ref()
249            .map(|deps| deps.keys().cloned().collect())
250            .unwrap_or_default()
251    }
252
253    /// Whether `peerDependenciesMeta` marks the peer `dep` as optional.
254    #[must_use]
255    pub fn peer_dependency_is_optional(&self, dep: &str) -> bool {
256        self.peer_dependencies_meta
257            .as_ref()
258            .and_then(|meta| meta.get(dep))
259            .is_some_and(|meta| meta.optional)
260    }
261
262    /// Extract entry points from package.json fields.
263    #[must_use]
264    pub fn entry_points(&self) -> Vec<String> {
265        let mut entries = Vec::new();
266
267        if let Some(main) = &self.main {
268            entries.push(main.clone());
269        }
270        if let Some(module) = &self.module {
271            entries.push(module.clone());
272        }
273        if let Some(types) = &self.types {
274            entries.push(types.clone());
275        }
276        if let Some(typings) = &self.typings {
277            entries.push(typings.clone());
278        }
279        if let Some(source) = &self.source {
280            entries.push(source.clone());
281        }
282
283        if let Some(browser) = &self.browser {
284            match browser {
285                serde_json::Value::String(s) => entries.push(s.clone()),
286                serde_json::Value::Object(map) => {
287                    for v in map.values() {
288                        if let serde_json::Value::String(s) = v
289                            && (s.starts_with("./") || s.starts_with("../"))
290                        {
291                            entries.push(s.clone());
292                        }
293                    }
294                }
295                _ => {}
296            }
297        }
298
299        if let Some(bin) = &self.bin {
300            match bin {
301                serde_json::Value::String(s) => entries.push(s.clone()),
302                serde_json::Value::Object(map) => {
303                    for v in map.values() {
304                        if let serde_json::Value::String(s) = v {
305                            entries.push(s.clone());
306                        }
307                    }
308                }
309                _ => {}
310            }
311        }
312
313        if let Some(exports) = &self.exports {
314            extract_exports_entries(exports, &mut entries);
315        }
316
317        entries
318    }
319
320    /// Extract unique subdirectory names referenced by the `exports` field keys.
321    ///
322    /// For exports like `"./compat": { ... }`, `"./hooks/client": { ... }`,
323    /// this returns `["compat", "hooks"]`. Used to discover sub-packages in
324    /// projects that define their internal package structure via the exports map
325    /// (e.g., preact with `compat/`, `hooks/`, `debug/` sub-packages).
326    #[must_use]
327    pub fn exports_subdirectories(&self) -> Vec<String> {
328        self.exports
329            .as_ref()
330            .map_or_else(Vec::new, extract_exports_subdirectories)
331    }
332
333    /// Extract workspace patterns from package.json.
334    #[must_use]
335    pub fn workspace_patterns(&self) -> Vec<String> {
336        match &self.workspaces {
337            Some(serde_json::Value::Array(arr)) => arr
338                .iter()
339                .filter_map(|v| v.as_str().map(String::from))
340                .collect(),
341            Some(serde_json::Value::Object(obj)) => obj
342                .get("packages")
343                .and_then(|v| v.as_array())
344                .map(|arr| {
345                    arr.iter()
346                        .filter_map(|v| v.as_str().map(String::from))
347                        .collect()
348                })
349                .unwrap_or_default(),
350            _ => Vec::new(),
351        }
352    }
353
354    /// Dependencies with a `link:` or `file:` spec in `dependencies`,
355    /// `devDependencies` or `optionalDependencies`, as `(name, target)` pairs
356    /// sorted by name.
357    ///
358    /// The target is the path as written, relative to this manifest's
359    /// directory. The caller decides which targets are real package
360    /// directories.
361    #[must_use]
362    pub fn local_link_dependencies(&self) -> Vec<(String, String)> {
363        let mut links: Vec<(String, String)> = [
364            &self.dependencies,
365            &self.dev_dependencies,
366            &self.optional_dependencies,
367        ]
368        .into_iter()
369        .flatten()
370        .flatten()
371        .filter_map(|(name, spec)| {
372            let target = spec
373                .strip_prefix("link:")
374                .or_else(|| spec.strip_prefix("file:"))?
375                .trim();
376            (!target.is_empty()).then(|| (name.clone(), target.to_string()))
377        })
378        .collect();
379        links.sort_unstable();
380        links.dedup();
381        links
382    }
383}
384
385/// Extract unique subdirectory names referenced by the `exports` field keys.
386///
387/// For exports like `"./compat": { ... }`, `"./hooks/client": { ... }`,
388/// this returns `["compat", "hooks"]`. Used to discover sub-packages in
389/// projects that use the exports map to define their internal package structure
390/// (e.g., preact with `compat/`, `hooks/`, `debug/` sub-packages).
391fn extract_exports_subdirectories(exports: &serde_json::Value) -> Vec<String> {
392    let Some(map) = exports.as_object() else {
393        return Vec::new();
394    };
395
396    let skip_dirs = ["dist", "build", "out", "esm", "cjs", "lib", "node_modules"];
397    let mut dirs = rustc_hash::FxHashSet::default();
398
399    for key in map.keys() {
400        let stripped = key.strip_prefix("./").unwrap_or(key);
401        if let Some(first_segment) = stripped.split('/').next()
402            && !first_segment.is_empty()
403            && first_segment != "."
404            && first_segment != "package.json"
405            && !skip_dirs.contains(&first_segment)
406        {
407            dirs.insert(first_segment.to_owned());
408        }
409    }
410
411    dirs.into_iter().collect()
412}
413
414/// Recursively extract file paths from package.json exports field.
415fn extract_exports_entries(value: &serde_json::Value, entries: &mut Vec<String>) {
416    match value {
417        serde_json::Value::String(s) if s.starts_with("./") || s.starts_with("../") => {
418            entries.push(s.clone());
419        }
420        serde_json::Value::Object(map) => {
421            for v in map.values() {
422                extract_exports_entries(v, entries);
423            }
424        }
425        serde_json::Value::Array(arr) => {
426            for v in arr {
427                extract_exports_entries(v, entries);
428            }
429        }
430        _ => {}
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn package_json_workspace_patterns_array() {
440        let pkg: PackageJson =
441            serde_json::from_str(r#"{"workspaces": ["packages/*", "apps/*"]}"#).unwrap();
442        let patterns = pkg.workspace_patterns();
443        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
444    }
445
446    #[test]
447    fn package_json_workspace_patterns_object() {
448        let pkg: PackageJson =
449            serde_json::from_str(r#"{"workspaces": {"packages": ["packages/*"]}}"#).unwrap();
450        let patterns = pkg.workspace_patterns();
451        assert_eq!(patterns, vec!["packages/*"]);
452    }
453
454    #[test]
455    fn package_json_workspace_patterns_none() {
456        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
457        let patterns = pkg.workspace_patterns();
458        assert!(patterns.is_empty());
459    }
460
461    #[test]
462    fn package_json_workspace_patterns_empty_array() {
463        let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": []}"#).unwrap();
464        let patterns = pkg.workspace_patterns();
465        assert!(patterns.is_empty());
466    }
467
468    #[test]
469    fn package_json_load_valid() {
470        let temp_dir = std::env::temp_dir().join("fallow-test-pkg-json");
471        let _ = std::fs::create_dir_all(&temp_dir);
472        let pkg_path = temp_dir.join("package.json");
473        std::fs::write(&pkg_path, r#"{"name": "test", "main": "index.js"}"#).unwrap();
474
475        let pkg = PackageJson::load(&pkg_path).unwrap();
476        assert_eq!(pkg.name, Some("test".to_string()));
477        assert_eq!(pkg.main, Some("index.js".to_string()));
478
479        let _ = std::fs::remove_dir_all(&temp_dir);
480    }
481
482    #[test]
483    fn package_json_private_non_bool_values_are_ignored() {
484        for raw in [
485            r#"{"private": "true"}"#,
486            r#"{"private": 1}"#,
487            r#"{"private": null}"#,
488        ] {
489            let pkg: PackageJson = serde_json::from_str(raw).unwrap();
490            assert_eq!(pkg.private, None);
491        }
492
493        let pkg: PackageJson = serde_json::from_str(r#"{"private": true}"#).unwrap();
494        assert_eq!(pkg.private, Some(true));
495
496        let pkg: PackageJson = serde_json::from_str(r#"{"private": false}"#).unwrap();
497        assert_eq!(pkg.private, Some(false));
498    }
499
500    #[test]
501    fn package_json_files_preserves_string_array_values() {
502        let pkg: PackageJson =
503            serde_json::from_str(r#"{"files": ["index.js", "template-*", "dist"]}"#).unwrap();
504        assert_eq!(pkg.files, vec!["index.js", "template-*", "dist"]);
505    }
506
507    #[test]
508    fn package_json_files_missing_or_unexpected_shapes_are_ignored() {
509        let pkg: PackageJson = serde_json::from_str(r#"{"name": "pkg"}"#).unwrap();
510        assert!(pkg.files.is_empty());
511
512        let pkg: PackageJson = serde_json::from_str(r#"{"files": "dist"}"#).unwrap();
513        assert!(pkg.files.is_empty());
514
515        let pkg: PackageJson =
516            serde_json::from_str(r#"{"files": ["template-*", 42, false, null]}"#).unwrap();
517        assert_eq!(pkg.files, vec!["template-*"]);
518    }
519
520    #[test]
521    fn package_json_napi_config_preserves_current_string_fields() {
522        let pkg: PackageJson = serde_json::from_str(
523            r#"{
524                "napi": {
525                    "binaryName": "srcmap-codec",
526                    "packageName": "@srcmap/codec",
527                    "targets": [
528                        "aarch64-apple-darwin",
529                        "x86_64-unknown-linux-gnu"
530                    ]
531                }
532            }"#,
533        )
534        .unwrap();
535
536        let napi = pkg.napi.expect("napi config");
537        assert_eq!(napi.binary_name.as_deref(), Some("srcmap-codec"));
538        assert_eq!(napi.package_name.as_deref(), Some("@srcmap/codec"));
539        assert_eq!(
540            napi.targets,
541            vec![
542                "aarch64-apple-darwin".to_string(),
543                "x86_64-unknown-linux-gnu".to_string()
544            ]
545        );
546    }
547
548    #[test]
549    fn package_json_napi_config_ignores_unexpected_shapes() {
550        let pkg: PackageJson = serde_json::from_str(r#"{"napi": "enabled"}"#).unwrap();
551        assert!(pkg.napi.is_none());
552
553        let pkg: PackageJson = serde_json::from_str(
554            r#"{
555                "napi": {
556                    "binaryName": 42,
557                    "packageName": "",
558                    "targets": ["x86_64-apple-darwin", false, null]
559                }
560            }"#,
561        )
562        .unwrap();
563
564        let napi = pkg.napi.expect("napi config");
565        assert!(napi.binary_name.is_none());
566        assert!(napi.package_name.is_none());
567        assert_eq!(napi.targets, vec!["x86_64-apple-darwin"]);
568    }
569
570    #[test]
571    fn package_json_load_missing_file() {
572        let result = PackageJson::load(std::path::Path::new("/nonexistent/package.json"));
573        assert!(result.is_err());
574    }
575
576    #[test]
577    fn package_json_entry_points_combined() {
578        let pkg: PackageJson = serde_json::from_str(
579            r#"{
580            "main": "dist/index.js",
581            "module": "dist/index.mjs",
582            "types": "dist/index.d.ts",
583            "typings": "dist/types.d.ts"
584        }"#,
585        )
586        .unwrap();
587        let entries = pkg.entry_points();
588        assert_eq!(entries.len(), 4);
589        assert!(entries.contains(&"dist/index.js".to_string()));
590        assert!(entries.contains(&"dist/index.mjs".to_string()));
591        assert!(entries.contains(&"dist/index.d.ts".to_string()));
592        assert!(entries.contains(&"dist/types.d.ts".to_string()));
593    }
594
595    #[test]
596    fn package_json_exports_nested() {
597        let pkg: PackageJson = serde_json::from_str(
598            r#"{
599            "exports": {
600                ".": {
601                    "import": "./dist/index.mjs",
602                    "require": "./dist/index.cjs"
603                },
604                "./utils": {
605                    "import": "./dist/utils.mjs"
606                }
607            }
608        }"#,
609        )
610        .unwrap();
611        let entries = pkg.entry_points();
612        assert!(entries.contains(&"./dist/index.mjs".to_string()));
613        assert!(entries.contains(&"./dist/index.cjs".to_string()));
614        assert!(entries.contains(&"./dist/utils.mjs".to_string()));
615    }
616
617    #[test]
618    fn package_json_exports_array() {
619        let pkg: PackageJson = serde_json::from_str(
620            r#"{
621            "exports": {
622                ".": ["./dist/index.mjs", "./dist/index.cjs"]
623            }
624        }"#,
625        )
626        .unwrap();
627        let entries = pkg.entry_points();
628        assert!(entries.contains(&"./dist/index.mjs".to_string()));
629        assert!(entries.contains(&"./dist/index.cjs".to_string()));
630    }
631
632    #[test]
633    fn extract_exports_ignores_non_relative() {
634        let pkg: PackageJson = serde_json::from_str(
635            r#"{
636            "exports": {
637                ".": "not-a-relative-path"
638            }
639        }"#,
640        )
641        .unwrap();
642        let entries = pkg.entry_points();
643        assert!(entries.is_empty());
644    }
645
646    #[test]
647    fn package_json_source_field() {
648        let pkg: PackageJson = serde_json::from_str(
649            r#"{
650            "main": "dist/index.js",
651            "source": "src/index.ts"
652        }"#,
653        )
654        .unwrap();
655        let entries = pkg.entry_points();
656        assert!(entries.contains(&"src/index.ts".to_string()));
657        assert!(entries.contains(&"dist/index.js".to_string()));
658    }
659
660    #[test]
661    fn package_json_browser_field_string() {
662        let pkg: PackageJson = serde_json::from_str(
663            r#"{
664            "browser": "./dist/browser.js"
665        }"#,
666        )
667        .unwrap();
668        let entries = pkg.entry_points();
669        assert!(entries.contains(&"./dist/browser.js".to_string()));
670    }
671
672    #[test]
673    fn package_json_browser_field_object() {
674        let pkg: PackageJson = serde_json::from_str(
675            r#"{
676            "browser": {
677                "./server.js": "./browser.js",
678                "module-name": false
679            }
680        }"#,
681        )
682        .unwrap();
683        let entries = pkg.entry_points();
684        assert!(entries.contains(&"./browser.js".to_string()));
685        assert_eq!(entries.len(), 1);
686    }
687
688    #[test]
689    fn package_json_exports_string() {
690        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
691        let entries = pkg.entry_points();
692        assert_eq!(entries, vec!["./dist/index.js"]);
693    }
694
695    #[test]
696    fn package_json_workspace_patterns_object_with_nohoist() {
697        let pkg: PackageJson = serde_json::from_str(
698            r#"{
699            "workspaces": {
700                "packages": ["packages/*", "apps/*"],
701                "nohoist": ["**/react-native"]
702            }
703        }"#,
704        )
705        .unwrap();
706        let patterns = pkg.workspace_patterns();
707        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
708    }
709
710    #[test]
711    fn package_json_missing_optional_fields() {
712        let pkg: PackageJson = serde_json::from_str(r"{}").unwrap();
713        assert!(pkg.name.is_none());
714        assert!(pkg.main.is_none());
715        assert!(pkg.module.is_none());
716        assert!(pkg.types.is_none());
717        assert!(pkg.typings.is_none());
718        assert!(pkg.source.is_none());
719        assert!(pkg.browser.is_none());
720        assert!(pkg.bin.is_none());
721        assert!(pkg.exports.is_none());
722        assert!(pkg.dependencies.is_none());
723        assert!(pkg.dev_dependencies.is_none());
724        assert!(pkg.peer_dependencies.is_none());
725        assert!(pkg.peer_dependencies_meta.is_none());
726        assert!(pkg.optional_dependencies.is_none());
727        assert!(pkg.scripts.is_none());
728        assert!(pkg.workspaces.is_none());
729        assert!(pkg.entry_points().is_empty());
730        assert!(pkg.workspace_patterns().is_empty());
731        assert!(pkg.all_dependency_names().is_empty());
732    }
733
734    #[test]
735    fn package_json_all_dependency_names() {
736        let pkg: PackageJson = serde_json::from_str(
737            r#"{
738            "dependencies": {"react": "^18", "react-dom": "^18"},
739            "devDependencies": {"typescript": "^5"},
740            "peerDependencies": {"node": ">=18"},
741            "optionalDependencies": {"fsevents": "^2"}
742        }"#,
743        )
744        .unwrap();
745        let deps = pkg.all_dependency_names();
746        assert_eq!(deps.len(), 5);
747        assert!(deps.contains(&"react".to_string()));
748        assert!(deps.contains(&"react-dom".to_string()));
749        assert!(deps.contains(&"typescript".to_string()));
750        assert!(deps.contains(&"node".to_string()));
751        assert!(deps.contains(&"fsevents".to_string()));
752    }
753
754    #[test]
755    fn package_json_production_dependency_names() {
756        let pkg: PackageJson = serde_json::from_str(
757            r#"{
758            "dependencies": {"react": "^18"},
759            "devDependencies": {"typescript": "^5"}
760        }"#,
761        )
762        .unwrap();
763        let prod = pkg.production_dependency_names();
764        assert_eq!(prod, vec!["react"]);
765        let dev = pkg.dev_dependency_names();
766        assert_eq!(dev, vec!["typescript"]);
767    }
768
769    #[test]
770    fn package_json_bin_field_string() {
771        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "./cli.js"}"#).unwrap();
772        let entries = pkg.entry_points();
773        assert!(entries.contains(&"./cli.js".to_string()));
774    }
775
776    #[test]
777    fn package_json_bin_field_object() {
778        let pkg: PackageJson = serde_json::from_str(
779            r#"{"bin": {"my-cli": "./bin/cli.js", "my-tool": "./bin/tool.js"}}"#,
780        )
781        .unwrap();
782        let entries = pkg.entry_points();
783        assert!(entries.contains(&"./bin/cli.js".to_string()));
784        assert!(entries.contains(&"./bin/tool.js".to_string()));
785    }
786
787    #[test]
788    fn package_json_exports_deeply_nested() {
789        let pkg: PackageJson = serde_json::from_str(
790            r#"{
791            "exports": {
792                ".": {
793                    "node": {
794                        "import": "./dist/node.mjs",
795                        "require": "./dist/node.cjs"
796                    },
797                    "browser": {
798                        "import": "./dist/browser.mjs"
799                    }
800                }
801            }
802        }"#,
803        )
804        .unwrap();
805        let entries = pkg.entry_points();
806        assert_eq!(entries.len(), 3);
807        assert!(entries.contains(&"./dist/node.mjs".to_string()));
808        assert!(entries.contains(&"./dist/node.cjs".to_string()));
809        assert!(entries.contains(&"./dist/browser.mjs".to_string()));
810    }
811
812    #[test]
813    fn package_json_peer_deps_only() {
814        let pkg: PackageJson =
815            serde_json::from_str(r#"{"peerDependencies": {"react": "^18", "react-dom": "^18"}}"#)
816                .unwrap();
817        let all = pkg.all_dependency_names();
818        assert_eq!(all.len(), 2);
819        assert!(all.contains(&"react".to_string()));
820        assert!(all.contains(&"react-dom".to_string()));
821
822        assert!(pkg.production_dependency_names().is_empty());
823        assert!(pkg.dev_dependency_names().is_empty());
824    }
825
826    #[test]
827    fn package_json_peer_dependency_names_include_optional_peers() {
828        let pkg: PackageJson = serde_json::from_str(
829            r#"{
830            "peerDependencies": {"react": "^18", "typescript": "^5"},
831            "peerDependenciesMeta": {"typescript": {"optional": true}}
832        }"#,
833        )
834        .unwrap();
835        let mut names = pkg.peer_dependency_names();
836        names.sort_unstable();
837        assert_eq!(names, vec!["react", "typescript"]);
838    }
839
840    #[test]
841    fn package_json_optional_deps_in_all_names() {
842        let pkg: PackageJson =
843            serde_json::from_str(r#"{"optionalDependencies": {"fsevents": "^2"}}"#).unwrap();
844        let all = pkg.all_dependency_names();
845        assert!(all.contains(&"fsevents".to_string()));
846    }
847
848    #[test]
849    fn package_json_browser_array_ignored() {
850        let pkg: PackageJson =
851            serde_json::from_str(r#"{"browser": ["./a.js", "./b.js"]}"#).unwrap();
852        let entries = pkg.entry_points();
853        assert!(
854            entries.is_empty(),
855            "array browser field should not produce entries"
856        );
857    }
858
859    #[test]
860    fn package_json_browser_object_non_relative_skipped() {
861        let pkg: PackageJson = serde_json::from_str(
862            r#"{"browser": {"crypto": false, "./local.js": "./browser-local.js"}}"#,
863        )
864        .unwrap();
865        let entries = pkg.entry_points();
866        assert_eq!(entries.len(), 1);
867        assert!(entries.contains(&"./browser-local.js".to_string()));
868    }
869
870    #[test]
871    fn package_json_exports_null_value() {
872        let pkg: PackageJson =
873            serde_json::from_str(r#"{"exports": {".": "./dist/index.js", "./internal": null}}"#)
874                .unwrap();
875        let entries = pkg.entry_points();
876        assert_eq!(entries.len(), 1);
877        assert!(entries.contains(&"./dist/index.js".to_string()));
878    }
879
880    #[test]
881    fn package_json_exports_empty_object() {
882        let pkg: PackageJson = serde_json::from_str(r#"{"exports": {}}"#).unwrap();
883        let entries = pkg.entry_points();
884        assert!(entries.is_empty());
885    }
886
887    #[test]
888    fn package_json_workspace_patterns_string_value_ignored() {
889        let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": "packages/*"}"#).unwrap();
890        let patterns = pkg.workspace_patterns();
891        assert!(patterns.is_empty());
892    }
893
894    #[test]
895    fn package_json_workspace_patterns_object_missing_packages() {
896        let pkg: PackageJson =
897            serde_json::from_str(r#"{"workspaces": {"nohoist": ["**/react-native"]}}"#).unwrap();
898        let patterns = pkg.workspace_patterns();
899        assert!(patterns.is_empty());
900    }
901
902    #[test]
903    fn package_json_load_invalid_json() {
904        let temp_dir = std::env::temp_dir().join("fallow-test-invalid-pkg-json");
905        let _ = std::fs::create_dir_all(&temp_dir);
906        let pkg_path = temp_dir.join("package.json");
907        std::fs::write(&pkg_path, "{ not valid json }").unwrap();
908
909        let result = PackageJson::load(&pkg_path);
910        assert!(result.is_err());
911        let err = result.unwrap_err();
912        assert!(err.contains("Failed to parse"), "got: {err}");
913
914        let _ = std::fs::remove_dir_all(&temp_dir);
915    }
916
917    #[test]
918    fn package_json_bin_object_non_string_values_skipped() {
919        let pkg: PackageJson =
920            serde_json::from_str(r#"{"bin": {"cli": "./bin/cli.js", "bad": 42}}"#).unwrap();
921        let entries = pkg.entry_points();
922        assert_eq!(entries.len(), 1);
923        assert!(entries.contains(&"./bin/cli.js".to_string()));
924    }
925
926    #[test]
927    fn package_json_default() {
928        let pkg = PackageJson::default();
929        assert!(pkg.name.is_none());
930        assert!(pkg.main.is_none());
931        assert!(pkg.entry_points().is_empty());
932        assert!(pkg.all_dependency_names().is_empty());
933        assert!(pkg.workspace_patterns().is_empty());
934    }
935
936    #[test]
937    fn exports_subdirectories_preact_style() {
938        let pkg: PackageJson = serde_json::from_str(
939            r#"{
940            "exports": {
941                ".": "./dist/index.js",
942                "./compat": { "import": "./compat/dist/compat.mjs" },
943                "./hooks": { "import": "./hooks/dist/hooks.mjs" },
944                "./debug": { "import": "./debug/dist/debug.mjs" },
945                "./jsx-runtime": { "import": "./jsx-runtime/dist/jsx.mjs" },
946                "./package.json": "./package.json"
947            }
948        }"#,
949        )
950        .unwrap();
951        let mut dirs = pkg.exports_subdirectories();
952        dirs.sort();
953        assert_eq!(dirs, vec!["compat", "debug", "hooks", "jsx-runtime"]);
954    }
955
956    #[test]
957    fn exports_subdirectories_skips_dist_dirs() {
958        let pkg: PackageJson = serde_json::from_str(
959            r#"{
960            "exports": {
961                "./dist/index.js": "./dist/index.js",
962                "./build/bundle.js": "./build/bundle.js",
963                "./lib/utils": "./lib/utils.js",
964                "./compat": "./compat/index.js"
965            }
966        }"#,
967        )
968        .unwrap();
969        let dirs = pkg.exports_subdirectories();
970        assert_eq!(dirs, vec!["compat"]);
971    }
972
973    #[test]
974    fn exports_subdirectories_no_exports() {
975        let pkg: PackageJson = serde_json::from_str(r#"{"main": "index.js"}"#).unwrap();
976        assert!(pkg.exports_subdirectories().is_empty());
977    }
978
979    #[test]
980    fn exports_subdirectories_dot_only() {
981        let pkg: PackageJson =
982            serde_json::from_str(r#"{"exports": {".": "./dist/index.js"}}"#).unwrap();
983        assert!(pkg.exports_subdirectories().is_empty());
984    }
985}