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, Runtime, Result};
56    /// # use std::path::PathBuf;
57    /// # async fn example<R: Runtime>(runtime: &R) -> Result<()> {
58    /// let pkg = PackageJson::from_path(runtime, &PathBuf::from("./package.json")).await?;
59    /// println!("Package: {:?}", pkg.name);
60    /// # Ok(())
61    /// # }
62    /// ```
63    pub async fn from_path<R: Runtime>(runtime: &R, path: &Path) -> Result<Self> {
64        // Validate path to prevent directory traversal
65        Self::validate_path(path)?;
66
67        // Check file size before reading
68        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        // Read and parse the file
80        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    /// Load package.json from a specific path (native builds only).
97    ///
98    /// # Deprecated
99    ///
100    /// This method is provided for backward compatibility on native builds.
101    /// For new code, use `from_path` with an explicit runtime parameter.
102    ///
103    /// # Example
104    ///
105    /// ```no_run
106    /// # use fob_graph::{PackageJson, Result};
107    /// # use std::path::PathBuf;
108    /// # async fn example() -> Result<()> {
109    /// let pkg = PackageJson::from_path_native(&PathBuf::from("./package.json")).await?;
110    /// println!("Package: {:?}", pkg.name);
111    /// # Ok(())
112    /// # }
113    /// ```
114    #[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    /// Find and load package.json starting from a directory using the provided runtime.
125    ///
126    /// Searches upward through parent directories until package.json is found
127    /// or the filesystem root is reached.
128    ///
129    /// # Example
130    ///
131    /// ```no_run
132    /// # use fob_graph::{PackageJson, Runtime, Result};
133    /// # use std::path::PathBuf;
134    /// # async fn example<R: Runtime>(runtime: &R) -> Result<()> {
135    /// let pkg = PackageJson::find_from_dir(runtime, &PathBuf::from("./src")).await?;
136    /// println!("Package: {:?}", pkg.name);
137    /// # Ok(())
138    /// # }
139    /// ```
140    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            // Try parent directory
151            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    /// Find and load package.json starting from a directory (native builds only).
162    ///
163    /// # Deprecated
164    ///
165    /// This method is provided for backward compatibility on native builds.
166    /// For new code, use `find_from_dir` with an explicit runtime parameter.
167    ///
168    /// # Example
169    ///
170    /// ```no_run
171    /// # use fob_graph::{PackageJson, Result};
172    /// # use std::path::PathBuf;
173    /// # async fn example() -> Result<()> {
174    /// let pkg = PackageJson::find_from_dir_native(&PathBuf::from("./src")).await?;
175    /// println!("Package: {:?}", pkg.name);
176    /// # Ok(())
177    /// # }
178    /// ```
179    #[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    /// Get all dependencies of a specific type.
190    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    /// Get all dependency names across all types.
200    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    /// Validate a path to prevent directory traversal attacks.
221    fn validate_path(path: &Path) -> Result<()> {
222        // Convert to canonical path if possible
223        let path_str = path.to_string_lossy();
224
225        // Reject paths with suspicious patterns
226        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/// Type of dependency in package.json.
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
238pub enum DependencyType {
239    /// Regular dependencies
240    Production,
241    /// Development dependencies
242    Development,
243    /// Peer dependencies
244    Peer,
245    /// Optional dependencies
246    Optional,
247}
248
249impl DependencyType {
250    /// Human-readable name for the dependency type.
251    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/// An npm dependency that is declared but never imported.
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct UnusedDependency {
264    /// Package name
265    pub package: String,
266    /// Version specifier from package.json
267    pub version: String,
268    /// Type of dependency
269    pub dep_type: DependencyType,
270}
271
272/// Coverage statistics for dependencies.
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct DependencyCoverage {
275    /// Total dependencies declared
276    pub total_declared: usize,
277    /// Dependencies actually imported
278    pub total_used: usize,
279    /// Dependencies never imported
280    pub total_unused: usize,
281    /// Breakdown by dependency type
282    pub by_type: HashMap<DependencyType, TypeCoverage>,
283}
284
285impl DependencyCoverage {
286    /// Calculate coverage percentage.
287    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/// Coverage for a specific dependency type.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct TypeCoverage {
299    pub declared: usize,
300    pub used: usize,
301    pub unused: usize,
302}
303
304/// Extract the base package name from an npm import specifier.
305///
306/// This handles scoped packages correctly:
307/// - `@foo/bar` -> `@foo/bar`
308/// - `@foo/bar/baz` -> `@foo/bar`
309/// - `lodash` -> `lodash`
310/// - `lodash/fp` -> `lodash`
311///
312/// # Example
313///
314/// ```
315/// # use fob_graph::extract_package_name;
316/// assert_eq!(extract_package_name("@babel/core"), "@babel/core");
317/// assert_eq!(extract_package_name("@babel/core/lib/index"), "@babel/core");
318/// assert_eq!(extract_package_name("lodash"), "lodash");
319/// assert_eq!(extract_package_name("lodash/fp"), "lodash");
320/// ```
321pub fn extract_package_name(specifier: &str) -> &str {
322    if specifier.is_empty() {
323        return specifier;
324    }
325
326    // Handle scoped packages (@org/package)
327    if specifier.starts_with('@') {
328        // Find the second slash (after @org/)
329        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 entire string if no second slash
335        return specifier;
336    }
337
338    // Non-scoped packages - take up to first slash
339    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        // Scoped packages
353        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        // Regular packages
359        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        // Edge cases
365        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        // Production only
440        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        // Include dev
446        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        // Include all
451        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}