Skip to content

Commit b9b29f5

Browse files
committed
fix: depulicate fields
1 parent d299986 commit b9b29f5

2 files changed

Lines changed: 57 additions & 6 deletions

File tree

crates/pm/src/util/json.rs

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,51 @@
1+
use std::path::Path;
2+
13
use anyhow::{Context, Result};
24
use serde::de::DeserializeOwned;
3-
use std::path::Path;
45

56
/// Read and parse a JSON file into the specified type
67
pub async fn read_json_file<T: DeserializeOwned>(path: &Path) -> Result<T> {
78
let content = crate::fs::read_to_string(path)
89
.await
9-
.with_context(|| format!("Failed to read file {}", path.display()))?;
10+
.with_context(|| format!("Failed to read file {path:?}"))?;
1011

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:?}"))
1313
}
1414

1515
/// Load package.json from a directory path and deserialize into the caller's
1616
/// chosen view type `T`. Use a full `PackageJson` for root projects, or a
1717
/// minimal view (e.g. `ScriptsView`) for node_modules to avoid parsing
1818
/// 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.
1927
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+
}
2149
}
2250

2351
pub async fn load_package_lock_json_from_path(
@@ -84,6 +112,29 @@ mod tests {
84112
assert_eq!(pkg.version, "1.0.0");
85113
}
86114

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+
87138
#[tokio::test]
88139
async fn test_error_handling() {
89140
let non_existent_path = Path::new("non_existent.json");

0 commit comments

Comments
 (0)