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