1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use crate::Result;
11use crate::runtime::Runtime;
12
13const MAX_PACKAGE_JSON_SIZE: u64 = 10 * 1024 * 1024;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PackageJson {
22 pub name: Option<String>,
24 pub version: Option<String>,
26 #[serde(default)]
28 pub dependencies: HashMap<String, String>,
29 #[serde(default, rename = "devDependencies")]
31 pub dev_dependencies: HashMap<String, String>,
32 #[serde(default, rename = "peerDependencies")]
34 pub peer_dependencies: HashMap<String, String>,
35 #[serde(default, rename = "optionalDependencies")]
37 pub optional_dependencies: HashMap<String, String>,
38 #[serde(skip)]
40 pub path: PathBuf,
41}
42
43impl PackageJson {
44 pub async fn from_path<R: Runtime>(runtime: &R, path: &Path) -> Result<Self> {
65 Self::validate_path(path)?;
67
68 let metadata = runtime.metadata(path).await.map_err(|e| {
70 crate::Error::InvalidConfig(format!("Cannot read package.json metadata: {e}"))
71 })?;
72
73 if metadata.size > MAX_PACKAGE_JSON_SIZE {
74 return Err(crate::Error::InvalidConfig(format!(
75 "package.json exceeds maximum size of {}MB",
76 MAX_PACKAGE_JSON_SIZE / 1024 / 1024
77 )));
78 }
79
80 let content_bytes = runtime.read_file(path).await.map_err(|e| {
82 crate::Error::InvalidConfig(format!("Failed to read package.json: {e}"))
83 })?;
84
85 let content = String::from_utf8(content_bytes).map_err(|e| {
86 crate::Error::InvalidConfig(format!("package.json contains invalid UTF-8: {e}"))
87 })?;
88
89 let mut pkg: PackageJson = serde_json::from_str(&content).map_err(|e| {
90 crate::Error::InvalidConfig(format!("Invalid package.json format: {e}"))
91 })?;
92
93 pkg.path = path.to_path_buf();
94 Ok(pkg)
95 }
96
97 #[cfg(not(target_family = "wasm"))]
116 #[deprecated(
117 note = "Use from_path with explicit runtime parameter for better platform compatibility"
118 )]
119 pub async fn from_path_native(path: &Path) -> Result<Self> {
120 use crate::NativeRuntime;
121 let runtime = NativeRuntime::new();
122 Self::from_path(&runtime, path).await
123 }
124
125 pub async fn find_from_dir<R: Runtime>(runtime: &R, start_dir: &Path) -> Result<Self> {
143 let mut current = start_dir.to_path_buf();
144
145 loop {
146 let package_json_path = current.join("package.json");
147
148 if runtime.exists(&package_json_path) {
149 return Self::from_path(runtime, &package_json_path).await;
150 }
151
152 if let Some(parent) = current.parent() {
154 current = parent.to_path_buf();
155 } else {
156 return Err(crate::Error::InvalidConfig(
157 "No package.json found in directory tree".to_string(),
158 ));
159 }
160 }
161 }
162
163 #[cfg(not(target_family = "wasm"))]
182 #[deprecated(
183 note = "Use find_from_dir with explicit runtime parameter for better platform compatibility"
184 )]
185 pub async fn find_from_dir_native(start_dir: &Path) -> Result<Self> {
186 use crate::NativeRuntime;
187 let runtime = NativeRuntime::new();
188 Self::find_from_dir(&runtime, start_dir).await
189 }
190
191 pub fn get_dependencies(&self, dep_type: DependencyType) -> &HashMap<String, String> {
193 match dep_type {
194 DependencyType::Production => &self.dependencies,
195 DependencyType::Development => &self.dev_dependencies,
196 DependencyType::Peer => &self.peer_dependencies,
197 DependencyType::Optional => &self.optional_dependencies,
198 }
199 }
200
201 pub fn all_dependency_names(&self, include_dev: bool, include_peer: bool) -> Vec<String> {
203 let mut names = Vec::new();
204
205 names.extend(self.dependencies.keys().cloned());
206
207 if include_dev {
208 names.extend(self.dev_dependencies.keys().cloned());
209 }
210
211 if include_peer {
212 names.extend(self.peer_dependencies.keys().cloned());
213 }
214
215 names.extend(self.optional_dependencies.keys().cloned());
216
217 names.sort();
218 names.dedup();
219 names
220 }
221
222 fn validate_path(path: &Path) -> Result<()> {
224 let path_str = path.to_string_lossy();
226
227 if path_str.contains("..") {
229 return Err(crate::Error::InvalidConfig(
230 "Path contains '..' (potential directory traversal)".to_string(),
231 ));
232 }
233
234 Ok(())
235 }
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
240pub enum DependencyType {
241 Production,
243 Development,
245 Peer,
247 Optional,
249}
250
251impl DependencyType {
252 pub fn as_str(&self) -> &'static str {
254 match self {
255 Self::Production => "dependencies",
256 Self::Development => "devDependencies",
257 Self::Peer => "peerDependencies",
258 Self::Optional => "optionalDependencies",
259 }
260 }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct UnusedDependency {
266 pub package: String,
268 pub version: String,
270 pub dep_type: DependencyType,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct DependencyCoverage {
277 pub total_declared: usize,
279 pub total_used: usize,
281 pub total_unused: usize,
283 pub by_type: HashMap<DependencyType, TypeCoverage>,
285}
286
287impl DependencyCoverage {
288 pub fn coverage_percentage(&self) -> f64 {
290 if self.total_declared == 0 {
291 100.0
292 } else {
293 (self.total_used as f64 / self.total_declared as f64) * 100.0
294 }
295 }
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct TypeCoverage {
301 pub declared: usize,
302 pub used: usize,
303 pub unused: usize,
304}
305
306pub fn extract_package_name(specifier: &str) -> &str {
324 if specifier.is_empty() {
325 return specifier;
326 }
327
328 if specifier.starts_with('@') {
330 if let Some(first_slash) = specifier.find('/') {
332 if let Some(second_slash) = specifier[first_slash + 1..].find('/') {
333 return &specifier[..first_slash + 1 + second_slash];
334 }
335 }
336 return specifier;
338 }
339
340 if let Some(slash_idx) = specifier.find('/') {
342 &specifier[..slash_idx]
343 } else {
344 specifier
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 #[test]
353 fn test_extract_package_name() {
354 assert_eq!(extract_package_name("@babel/core"), "@babel/core");
356 assert_eq!(extract_package_name("@babel/core/lib/index"), "@babel/core");
357 assert_eq!(extract_package_name("@types/node"), "@types/node");
358 assert_eq!(extract_package_name("@types/node/fs"), "@types/node");
359
360 assert_eq!(extract_package_name("lodash"), "lodash");
362 assert_eq!(extract_package_name("lodash/fp"), "lodash");
363 assert_eq!(extract_package_name("react"), "react");
364 assert_eq!(extract_package_name("react/jsx-runtime"), "react");
365
366 assert_eq!(extract_package_name(""), "");
368 assert_eq!(extract_package_name("@org"), "@org");
369 }
370
371 #[test]
372 fn test_dependency_type_as_str() {
373 assert_eq!(DependencyType::Production.as_str(), "dependencies");
374 assert_eq!(DependencyType::Development.as_str(), "devDependencies");
375 assert_eq!(DependencyType::Peer.as_str(), "peerDependencies");
376 assert_eq!(DependencyType::Optional.as_str(), "optionalDependencies");
377 }
378
379 #[test]
380 fn test_coverage_percentage() {
381 let coverage = DependencyCoverage {
382 total_declared: 10,
383 total_used: 7,
384 total_unused: 3,
385 by_type: HashMap::new(),
386 };
387
388 assert_eq!(coverage.coverage_percentage(), 70.0);
389
390 let empty_coverage = DependencyCoverage {
391 total_declared: 0,
392 total_used: 0,
393 total_unused: 0,
394 by_type: HashMap::new(),
395 };
396
397 assert_eq!(empty_coverage.coverage_percentage(), 100.0);
398 }
399
400 #[tokio::test]
401 async fn test_package_json_parse() {
402 let json = r#"{
403 "name": "test-package",
404 "version": "1.0.0",
405 "dependencies": {
406 "react": "^18.0.0",
407 "lodash": "^4.17.21"
408 },
409 "devDependencies": {
410 "@types/node": "^20.0.0"
411 }
412 }"#;
413
414 let pkg: PackageJson = serde_json::from_str(json).unwrap();
415
416 assert_eq!(pkg.name, Some("test-package".to_string()));
417 assert_eq!(pkg.version, Some("1.0.0".to_string()));
418 assert_eq!(pkg.dependencies.len(), 2);
419 assert_eq!(pkg.dev_dependencies.len(), 1);
420 assert_eq!(pkg.dependencies.get("react"), Some(&"^18.0.0".to_string()));
421 }
422
423 #[tokio::test]
424 async fn test_all_dependency_names() {
425 let json = r#"{
426 "dependencies": {
427 "react": "^18.0.0",
428 "lodash": "^4.17.21"
429 },
430 "devDependencies": {
431 "@types/node": "^20.0.0",
432 "typescript": "^5.0.0"
433 },
434 "peerDependencies": {
435 "react-dom": "^18.0.0"
436 }
437 }"#;
438
439 let pkg: PackageJson = serde_json::from_str(json).unwrap();
440
441 let names = pkg.all_dependency_names(false, false);
443 assert_eq!(names.len(), 2);
444 assert!(names.contains(&"react".to_string()));
445 assert!(names.contains(&"lodash".to_string()));
446
447 let names_with_dev = pkg.all_dependency_names(true, false);
449 assert_eq!(names_with_dev.len(), 4);
450 assert!(names_with_dev.contains(&"@types/node".to_string()));
451
452 let all_names = pkg.all_dependency_names(true, true);
454 assert_eq!(all_names.len(), 5);
455 assert!(all_names.contains(&"react-dom".to_string()));
456 }
457
458 #[test]
459 fn test_validate_path_rejects_traversal() {
460 assert!(PackageJson::validate_path(Path::new("../etc/passwd")).is_err());
461 assert!(PackageJson::validate_path(Path::new("foo/../bar/../baz")).is_err());
462 assert!(PackageJson::validate_path(Path::new("./package.json")).is_ok());
463 assert!(PackageJson::validate_path(Path::new("/absolute/path/package.json")).is_ok());
464 }
465}