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