|
| 1 | +use std::path::Path; |
| 2 | + |
1 | 3 | use anyhow::{Context, Result}; |
2 | 4 | use serde::de::DeserializeOwned; |
3 | | -use std::path::Path; |
4 | 5 |
|
5 | 6 | /// Read and parse a JSON file into the specified type |
6 | 7 | pub async fn read_json_file<T: DeserializeOwned>(path: &Path) -> Result<T> { |
7 | 8 | let content = crate::fs::read_to_string(path) |
8 | 9 | .await |
9 | | - .with_context(|| format!("Failed to read file {}", path.display()))?; |
| 10 | + .with_context(|| format!("Failed to read file {path:?}"))?; |
10 | 11 |
|
11 | | - serde_json::from_str(&content) |
12 | | - .with_context(|| format!("Failed to parse JSON from {}", path.display())) |
| 12 | + serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from {path:?}")) |
13 | 13 | } |
14 | 14 |
|
15 | 15 | /// Load package.json from a directory path and deserialize into the caller's |
16 | 16 | /// chosen view type `T`. Use a full `PackageJson` for root projects, or a |
17 | 17 | /// minimal view (e.g. `ScriptsView`) for node_modules to avoid parsing |
18 | 18 | /// unnecessary / non-standard fields. |
| 19 | +/// |
| 20 | +/// Some published npm packages ship package.json with duplicate keys |
| 21 | +/// (e.g. `mime@1.3.4` has two `scripts` entries). serde's derived |
| 22 | +/// `Deserialize` for structs rejects duplicate fields, but |
| 23 | +/// `serde_json::Value` (backed by a Map) silently keeps the last value. |
| 24 | +/// So when direct struct deserialization fails while the JSON itself is |
| 25 | +/// valid, we retry through a Value intermediary to collapse duplicates. |
| 26 | +/// The normal (no-duplicate) path has zero extra overhead. |
19 | 27 | pub async fn load_package_json<T: DeserializeOwned>(path: &Path) -> Result<T> { |
20 | | - read_json_file(&path.join("package.json")).await |
| 28 | + let pkg_path = path.join("package.json"); |
| 29 | + let content = crate::fs::read_to_string(&pkg_path) |
| 30 | + .await |
| 31 | + .with_context(|| format!("Failed to read file {pkg_path:?}"))?; |
| 32 | + |
| 33 | + match serde_json::from_str(&content) { |
| 34 | + Ok(v) => Ok(v), |
| 35 | + Err(original_err) => match serde_json::from_str::<serde_json::Value>(&content) { |
| 36 | + Ok(value) => { |
| 37 | + tracing::warn!( |
| 38 | + "package.json has non-standard structure (e.g. duplicate keys), \ |
| 39 | + retrying with lenient parser: {pkg_path:?}" |
| 40 | + ); |
| 41 | + serde_json::from_value(value) |
| 42 | + .with_context(|| format!("Failed to deserialize {pkg_path:?}")) |
| 43 | + } |
| 44 | + Err(_) => { |
| 45 | + Err(original_err).with_context(|| format!("Failed to parse JSON from {pkg_path:?}")) |
| 46 | + } |
| 47 | + }, |
| 48 | + } |
21 | 49 | } |
22 | 50 |
|
23 | 51 | pub async fn load_package_lock_json_from_path( |
@@ -84,6 +112,29 @@ mod tests { |
84 | 112 | assert_eq!(pkg.version, "1.0.0"); |
85 | 113 | } |
86 | 114 |
|
| 115 | + /// Regression test: mime@1.3.4 has two "scripts" keys in its package.json. |
| 116 | + /// We must tolerate this and keep the last value (matching JSON.parse). |
| 117 | + #[tokio::test] |
| 118 | + async fn test_load_package_json_duplicate_fields() { |
| 119 | + let dir = tempdir().unwrap(); |
| 120 | + fs::write( |
| 121 | + dir.path().join("package.json"), |
| 122 | + r#"{ |
| 123 | + "name": "mime", |
| 124 | + "version": "1.3.4", |
| 125 | + "scripts": { "test": "node test.js" }, |
| 126 | + "scripts": { "prepublish": "node build.js", "test": "node build/test.js" } |
| 127 | + }"#, |
| 128 | + ) |
| 129 | + .unwrap(); |
| 130 | + |
| 131 | + use utoo_ruborist::manifest::ScriptsView; |
| 132 | + let view: ScriptsView = load_package_json(dir.path()).await.unwrap(); |
| 133 | + // Last value wins, matching JSON.parse semantics |
| 134 | + assert_eq!(view.scripts.get("prepublish").unwrap(), "node build.js"); |
| 135 | + assert_eq!(view.scripts.get("test").unwrap(), "node build/test.js"); |
| 136 | + } |
| 137 | + |
87 | 138 | #[tokio::test] |
88 | 139 | async fn test_error_handling() { |
89 | 140 | let non_existent_path = Path::new("non_existent.json"); |
|
0 commit comments