1use std::path::Path;
2use tokio::fs;
3
4use super::detect::{is_setup_configured_str, update_package_json_content, PackageManager};
5
6#[derive(Debug, Clone)]
8pub struct UpdateResult {
9 pub path: String,
10 pub status: UpdateStatus,
11 pub old_script: String,
12 pub new_script: String,
13 pub old_dependencies_script: String,
14 pub new_dependencies_script: String,
15 pub error: Option<String>,
16}
17
18#[derive(Debug, Clone, PartialEq)]
19pub enum UpdateStatus {
20 Updated,
21 AlreadyConfigured,
22 Error,
23}
24
25pub async fn update_package_json(
27 package_json_path: &Path,
28 dry_run: bool,
29 pm: PackageManager,
30) -> UpdateResult {
31 let path_str = package_json_path.display().to_string();
32
33 let content = match fs::read_to_string(package_json_path).await {
34 Ok(c) => c,
35 Err(e) => {
36 return UpdateResult {
37 path: path_str,
38 status: UpdateStatus::Error,
39 old_script: String::new(),
40 new_script: String::new(),
41 old_dependencies_script: String::new(),
42 new_dependencies_script: String::new(),
43 error: Some(e.to_string()),
44 };
45 }
46 };
47
48 let status = is_setup_configured_str(&content);
49 if !status.needs_update {
50 return UpdateResult {
51 path: path_str,
52 status: UpdateStatus::AlreadyConfigured,
53 old_script: status.postinstall_script.clone(),
54 new_script: status.postinstall_script,
55 old_dependencies_script: status.dependencies_script.clone(),
56 new_dependencies_script: status.dependencies_script,
57 error: None,
58 };
59 }
60
61 match update_package_json_content(&content, pm) {
62 Ok((modified, new_content, old_pi, new_pi, old_dep, new_dep)) => {
63 if !modified {
64 return UpdateResult {
65 path: path_str,
66 status: UpdateStatus::AlreadyConfigured,
67 old_script: old_pi,
68 new_script: new_pi,
69 old_dependencies_script: old_dep,
70 new_dependencies_script: new_dep,
71 error: None,
72 };
73 }
74
75 if !dry_run {
76 if let Err(e) = fs::write(package_json_path, &new_content).await {
77 return UpdateResult {
78 path: path_str,
79 status: UpdateStatus::Error,
80 old_script: old_pi,
81 new_script: new_pi,
82 old_dependencies_script: old_dep,
83 new_dependencies_script: new_dep,
84 error: Some(e.to_string()),
85 };
86 }
87 }
88
89 UpdateResult {
90 path: path_str,
91 status: UpdateStatus::Updated,
92 old_script: old_pi,
93 new_script: new_pi,
94 old_dependencies_script: old_dep,
95 new_dependencies_script: new_dep,
96 error: None,
97 }
98 }
99 Err(e) => UpdateResult {
100 path: path_str,
101 status: UpdateStatus::Error,
102 old_script: String::new(),
103 new_script: String::new(),
104 old_dependencies_script: String::new(),
105 new_dependencies_script: String::new(),
106 error: Some(e),
107 },
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[tokio::test]
116 async fn test_update_file_not_found() {
117 let dir = tempfile::tempdir().unwrap();
118 let missing = dir.path().join("nonexistent.json");
119 let result = update_package_json(&missing, false, PackageManager::Npm).await;
120 assert_eq!(result.status, UpdateStatus::Error);
121 assert!(result.error.is_some());
122 }
123
124 #[tokio::test]
125 async fn test_update_already_configured() {
126 let dir = tempfile::tempdir().unwrap();
127 let pkg = dir.path().join("package.json");
128 fs::write(
129 &pkg,
130 r#"{"name":"test","scripts":{"postinstall":"npx @socketsecurity/socket-patch apply --silent --ecosystems npm","dependencies":"npx @socketsecurity/socket-patch apply --silent --ecosystems npm"}}"#,
131 )
132 .await
133 .unwrap();
134 let result = update_package_json(&pkg, false, PackageManager::Npm).await;
135 assert_eq!(result.status, UpdateStatus::AlreadyConfigured);
136 }
137
138 #[tokio::test]
139 async fn test_update_dry_run_does_not_write() {
140 let dir = tempfile::tempdir().unwrap();
141 let pkg = dir.path().join("package.json");
142 let original = r#"{"name":"test","scripts":{"build":"tsc"}}"#;
143 fs::write(&pkg, original).await.unwrap();
144 let result = update_package_json(&pkg, true, PackageManager::Npm).await;
145 assert_eq!(result.status, UpdateStatus::Updated);
146 let content = fs::read_to_string(&pkg).await.unwrap();
148 assert_eq!(content, original);
149 }
150
151 #[tokio::test]
152 async fn test_update_writes_file() {
153 let dir = tempfile::tempdir().unwrap();
154 let pkg = dir.path().join("package.json");
155 fs::write(&pkg, r#"{"name":"test","scripts":{"build":"tsc"}}"#)
156 .await
157 .unwrap();
158 let result = update_package_json(&pkg, false, PackageManager::Npm).await;
159 assert_eq!(result.status, UpdateStatus::Updated);
160 let content = fs::read_to_string(&pkg).await.unwrap();
161 assert!(content.contains("npx @socketsecurity/socket-patch apply"));
162 assert!(content.contains("postinstall"));
163 assert!(content.contains("dependencies"));
164 }
165
166 #[tokio::test]
167 async fn test_update_invalid_json() {
168 let dir = tempfile::tempdir().unwrap();
169 let pkg = dir.path().join("package.json");
170 fs::write(&pkg, "not json!!!").await.unwrap();
171 let result = update_package_json(&pkg, false, PackageManager::Npm).await;
172 assert_eq!(result.status, UpdateStatus::Error);
173 assert!(result.error.is_some());
174 }
175
176 #[tokio::test]
177 async fn test_update_no_scripts_key() {
178 let dir = tempfile::tempdir().unwrap();
179 let pkg = dir.path().join("package.json");
180 fs::write(&pkg, r#"{"name":"x"}"#).await.unwrap();
181 let result = update_package_json(&pkg, false, PackageManager::Npm).await;
182 assert_eq!(result.status, UpdateStatus::Updated);
183 let content = fs::read_to_string(&pkg).await.unwrap();
184 assert!(content.contains("postinstall"));
185 assert!(content.contains("dependencies"));
186 assert!(content.contains("npx @socketsecurity/socket-patch apply"));
187 }
188
189 #[tokio::test]
190 async fn test_update_pnpm() {
191 let dir = tempfile::tempdir().unwrap();
192 let pkg = dir.path().join("package.json");
193 fs::write(&pkg, r#"{"name":"x"}"#).await.unwrap();
194 let result = update_package_json(&pkg, false, PackageManager::Pnpm).await;
195 assert_eq!(result.status, UpdateStatus::Updated);
196 let content = fs::read_to_string(&pkg).await.unwrap();
197 assert!(content.contains("pnpm dlx @socketsecurity/socket-patch apply"));
198 }
199
200 #[tokio::test]
201 async fn test_update_adds_dependencies_when_postinstall_exists() {
202 let dir = tempfile::tempdir().unwrap();
203 let pkg = dir.path().join("package.json");
204 fs::write(
205 &pkg,
206 r#"{"name":"test","scripts":{"postinstall":"npx @socketsecurity/socket-patch apply --silent --ecosystems npm"}}"#,
207 )
208 .await
209 .unwrap();
210 let result = update_package_json(&pkg, false, PackageManager::Npm).await;
211 assert_eq!(result.status, UpdateStatus::Updated);
212 let content = fs::read_to_string(&pkg).await.unwrap();
213 assert!(content.contains("dependencies"));
214 }
215
216 #[tokio::test]
222 async fn test_update_preserves_top_level_key_order() {
223 let dir = tempfile::tempdir().unwrap();
224 let pkg = dir.path().join("package.json");
225 fs::write(
227 &pkg,
228 r#"{"version":"1.0.0","name":"x","private":true,"scripts":{"build":"tsc"}}"#,
229 )
230 .await
231 .unwrap();
232 let result = update_package_json(&pkg, false, PackageManager::Npm).await;
233 assert_eq!(result.status, UpdateStatus::Updated);
234
235 let content = fs::read_to_string(&pkg).await.unwrap();
236 let pos_version = content.find("\"version\"").unwrap();
237 let pos_name = content.find("\"name\"").unwrap();
238 let pos_private = content.find("\"private\"").unwrap();
239 let pos_scripts = content.find("\"scripts\"").unwrap();
240 assert!(
241 pos_version < pos_name && pos_name < pos_private && pos_private < pos_scripts,
242 "original top-level key order must be preserved, got:\n{content}"
243 );
244 }
245
246 #[tokio::test]
249 async fn test_update_preserves_existing_scripts() {
250 let dir = tempfile::tempdir().unwrap();
251 let pkg = dir.path().join("package.json");
252 fs::write(
253 &pkg,
254 r#"{"name":"x","scripts":{"build":"tsc","test":"jest"}}"#,
255 )
256 .await
257 .unwrap();
258 let result = update_package_json(&pkg, false, PackageManager::Npm).await;
259 assert_eq!(result.status, UpdateStatus::Updated);
260
261 let parsed: serde_json::Value =
262 serde_json::from_str(&fs::read_to_string(&pkg).await.unwrap()).unwrap();
263 assert_eq!(parsed["scripts"]["build"], "tsc");
264 assert_eq!(parsed["scripts"]["test"], "jest");
265 assert!(parsed["scripts"]["postinstall"].is_string());
266 assert!(parsed["scripts"]["dependencies"].is_string());
267 }
268
269 #[tokio::test]
273 async fn test_update_is_idempotent() {
274 let dir = tempfile::tempdir().unwrap();
275 let pkg = dir.path().join("package.json");
276 fs::write(&pkg, r#"{"name":"x","scripts":{"build":"tsc"}}"#)
277 .await
278 .unwrap();
279
280 let r1 = update_package_json(&pkg, false, PackageManager::Npm).await;
281 assert_eq!(r1.status, UpdateStatus::Updated);
282 let after_first = fs::read_to_string(&pkg).await.unwrap();
283
284 let r2 = update_package_json(&pkg, false, PackageManager::Npm).await;
285 assert_eq!(r2.status, UpdateStatus::AlreadyConfigured);
286 let after_second = fs::read_to_string(&pkg).await.unwrap();
287
288 assert_eq!(after_first, after_second);
289 assert_eq!(after_first.matches("socket-patch apply").count(), 2);
290 }
291
292 #[tokio::test]
295 async fn test_update_non_object_root_errors() {
296 let dir = tempfile::tempdir().unwrap();
297 for (i, body) in ["[1,2,3]", "42", "\"hi\"", "true", "null"]
298 .iter()
299 .enumerate()
300 {
301 let pkg = dir.path().join(format!("pkg{i}.json"));
302 fs::write(&pkg, body).await.unwrap();
303 let result = update_package_json(&pkg, false, PackageManager::Npm).await;
304 assert_eq!(result.status, UpdateStatus::Error, "body={body}");
305 assert!(result.error.is_some(), "body={body}");
306 }
307 }
308
309 #[tokio::test]
311 async fn test_update_non_object_scripts_errors_and_leaves_file() {
312 let dir = tempfile::tempdir().unwrap();
313 let pkg = dir.path().join("package.json");
314 let original = r#"{"name":"x","scripts":"build"}"#;
315 fs::write(&pkg, original).await.unwrap();
316 let result = update_package_json(&pkg, false, PackageManager::Npm).await;
317 assert_eq!(result.status, UpdateStatus::Error);
318 assert_eq!(fs::read_to_string(&pkg).await.unwrap(), original);
320 }
321
322 #[tokio::test]
324 async fn test_update_empty_file_errors() {
325 let dir = tempfile::tempdir().unwrap();
326 let pkg = dir.path().join("package.json");
327 fs::write(&pkg, "").await.unwrap();
328 let result = update_package_json(&pkg, false, PackageManager::Npm).await;
329 assert_eq!(result.status, UpdateStatus::Error);
330 assert!(result.error.is_some());
331 }
332
333 #[tokio::test]
336 async fn test_update_dry_run_reports_updated_without_writing_scripts() {
337 let dir = tempfile::tempdir().unwrap();
338 let pkg = dir.path().join("package.json");
339 let original = r#"{"name":"x","scripts":{"postinstall":"echo hi"}}"#;
340 fs::write(&pkg, original).await.unwrap();
341 let result = update_package_json(&pkg, true, PackageManager::Npm).await;
342 assert_eq!(result.status, UpdateStatus::Updated);
343 assert_eq!(result.old_script, "echo hi");
345 assert!(result.new_script.contains("socket-patch apply"));
346 assert!(result.new_script.contains("echo hi"));
347 assert_eq!(fs::read_to_string(&pkg).await.unwrap(), original);
348 }
349}