fob_graph/
package_json.rs

1//! Package.json parsing and dependency analysis.
2//!
3//! This module provides functionality to parse package.json files and analyze
4//! npm dependencies against actual module imports in the codebase.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use crate::Result;
11use crate::runtime::Runtime;
12
13/// Maximum allowed size for package.json files (10MB)
14const MAX_PACKAGE_JSON_SIZE: u64 = 10 * 1024 * 1024;
15
16/// Parsed package.json structure.
17///
18/// This focuses on dependency-related fields and omits other metadata
19/// like scripts, engines, etc. for simplicity.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PackageJson {
22    /// Package name
23    pub name: Option<String>,
24    /// Package version
25    pub version: Option<String>,
26    /// Production dependencies
27    #[serde(default)]
28    pub dependencies: HashMap<String, String>,
29    /// Development dependencies
30    #[serde(default, rename = "devDependencies")]
31    pub dev_dependencies: HashMap<String, String>,
32    /// Peer dependencies
33    #[serde(default, rename = "peerDependencies")]
34    pub peer_dependencies: HashMap<String, String>,
35    /// Optional dependencies
36    #[serde(default, rename = "optionalDependencies")]
37    pub optional_dependencies: HashMap<String, String>,
38    /// File path this was loaded from
39    #[serde(skip)]
40    pub path: PathBuf,
41}
42
43impl PackageJson {
44    /// Load package.json from a specific path using the provided runtime.
45    ///
46    /// # Security
47    ///
48    /// - Validates the path is within allowed boundaries (prevents path traversal)
49    /// - Limits file size to 10MB to prevent DoS
50    /// - Uses safe JSON parsing
51    ///
52    /// # Example
53    ///
54    /// ```no_run
55    /// # use fob_graph::PackageJson;
56    /// # use crate::runtime::Runtime;
57    /// # use std::path::PathBuf;
58    /// # async fn example<R: Runtime>(runtime: &R) -> crate::Result<()> {
59    /// let pkg = PackageJson::from_path(runtime, &PathBuf::from("./package.json")).await?;
60    /// println!("Package: {:?}", pkg.name);
61    /// # Ok(())
62    /// # }
63    /// ```
64    pub async fn from_path<R: Runtime>(runtime: &R, path: &Path) -> Result<Self> {
65        // Validate path to prevent directory traversal
66        Self::validate_path(path)?;
67
68        // Check file size before reading
69        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        // Read and parse the file
81        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    /// Load package.json from a specific path (native builds only).
98    ///
99    /// # Deprecated
100    ///
101    /// This method is provided for backward compatibility on native builds.
102    /// For new code, use `from_path` with an explicit runtime parameter.
103    ///
104    /// # Example
105    ///
106    /// ```no_run
107    /// # use fob_graph::PackageJson;
108    /// # use std::path::PathBuf;
109    /// # async fn example() -> crate::Result<()> {
110    /// let pkg = PackageJson::from_path_native(&PathBuf::from("./package.json")).await?;
111    /// println!("Package: {:?}", pkg.name);
112    /// # Ok(())
113    /// # }
114    /// ```
115    #[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    /// Find and load package.json starting from a directory using the provided runtime.
126    ///
127    /// Searches upward through parent directories until package.json is found
128    /// or the filesystem root is reached.
129    ///
130    /// # Example
131    ///
132    /// ```no_run
133    /// # use fob_graph::PackageJson;
134    /// # use crate::runtime::Runtime;
135    /// # use std::path::PathBuf;
136    /// # async fn example<R: Runtime>(runtime: &R) -> crate::Result<()> {
137    /// let pkg = PackageJson::find_from_dir(runtime, &PathBuf::from("./src")).await?;
138    /// println!("Package: {:?}", pkg.name);
139    /// # Ok(())
140    /// # }
141    /// ```
142    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            // Try parent directory
153            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    /// Find and load package.json starting from a directory (native builds only).
164    ///
165    /// # Deprecated
166    ///
167    /// This method is provided for backward compatibility on native builds.
168    /// For new code, use `find_from_dir` with an explicit runtime parameter.
169    ///
170    /// # Example
171    ///
172    /// ```no_run
173    /// # use fob_graph::PackageJson;
174    /// # use std::path::PathBuf;
175    /// # async fn example() -> crate::Result<()> {
176    /// let pkg = PackageJson::find_from_dir_native(&PathBuf::from("./src")).await?;
177    /// println!("Package: {:?}", pkg.name);
178    /// # Ok(())
179    /// # }
180    /// ```
181    #[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    /// Get all dependencies of a specific type.
192    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    /// Get all dependency names across all types.
202    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    /// Validate a path to prevent directory traversal attacks.
223    fn validate_path(path: &Path) -> Result<()> {
224        // Convert to canonical path if possible
225        let path_str = path.to_string_lossy();
226
227        // Reject paths with suspicious patterns
228        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/// Type of dependency in package.json.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
240pub enum DependencyType {
241    /// Regular dependencies
242    Production,
243    /// Development dependencies
244    Development,
245    /// Peer dependencies
246    Peer,
247    /// Optional dependencies
248    Optional,
249}
250
251impl DependencyType {
252    /// Human-readable name for the dependency type.
253    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/// An npm dependency that is declared but never imported.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct UnusedDependency {
266    /// Package name
267    pub package: String,
268    /// Version specifier from package.json
269    pub version: String,
270    /// Type of dependency
271    pub dep_type: DependencyType,
272}
273
274/// Coverage statistics for dependencies.
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct DependencyCoverage {
277    /// Total dependencies declared
278    pub total_declared: usize,
279    /// Dependencies actually imported
280    pub total_used: usize,
281    /// Dependencies never imported
282    pub total_unused: usize,
283    /// Breakdown by dependency type
284    pub by_type: HashMap<DependencyType, TypeCoverage>,
285}
286
287impl DependencyCoverage {
288    /// Calculate coverage percentage.
289    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/// Coverage for a specific dependency type.
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct TypeCoverage {
301    pub declared: usize,
302    pub used: usize,
303    pub unused: usize,
304}
305
306/// Extract the base package name from an npm import specifier.
307///
308/// This handles scoped packages correctly:
309/// - `@foo/bar` -> `@foo/bar`
310/// - `@foo/bar/baz` -> `@foo/bar`
311/// - `lodash` -> `lodash`
312/// - `lodash/fp` -> `lodash`
313///
314/// # Example
315///
316/// ```
317/// # use fob_graph::extract_package_name;
318/// assert_eq!(extract_package_name("@babel/core"), "@babel/core");
319/// assert_eq!(extract_package_name("@babel/core/lib/index"), "@babel/core");
320/// assert_eq!(extract_package_name("lodash"), "lodash");
321/// assert_eq!(extract_package_name("lodash/fp"), "lodash");
322/// ```
323pub fn extract_package_name(specifier: &str) -> &str {
324    if specifier.is_empty() {
325        return specifier;
326    }
327
328    // Handle scoped packages (@org/package)
329    if specifier.starts_with('@') {
330        // Find the second slash (after @org/)
331        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 entire string if no second slash
337        return specifier;
338    }
339
340    // Non-scoped packages - take up to first slash
341    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        // Scoped packages
355        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        // Regular packages
361        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        // Edge cases
367        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        // Production only
442        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        // Include dev
448        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        // Include all
453        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}