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