spinne_core/
package_json.rs

1use serde_json::Value;
2use spinne_logger::Logger;
3use std::collections::HashSet;
4use std::fs;
5use std::path::PathBuf;
6
7/// Handles interactions with package.json
8#[derive(Debug, Clone, Default)]
9pub struct PackageJson {
10    /// Path to `package.json`. Contains the `package.json` filename.
11    pub path: PathBuf,
12    /// The name of the project.
13    pub name: Option<String>,
14    /// The workspaces of the project.
15    pub workspaces: Option<Vec<String>>,
16    /// The dependencies of the project.
17    pub dependencies: Option<HashSet<String>>,
18    /// The dev dependencies of the project.
19    pub dev_dependencies: Option<HashSet<String>>,
20    /// The peer dependencies of the project.
21    pub peer_dependencies: Option<HashSet<String>>,
22}
23
24impl PackageJson {
25    /// Creates a new PackageJson instance by reading package.json from the given path
26    ///
27    /// # Arguments
28    ///
29    /// * `path` - The path to the package.json file.
30    /// * `with_dependencies` - Whether to parse dependencies in the package.json. This will increase usage of memory.
31    pub fn read(path: &PathBuf, with_dependencies: bool) -> Option<Self> {
32        if !path.exists() {
33            Logger::error(&format!("No package.json found at {}", path.display()));
34            return None;
35        }
36
37        let mut package_json = Self::default();
38        package_json.path = path.clone();
39
40        match fs::read_to_string(&path) {
41            Ok(content) => match serde_json::from_str::<Value>(&content) {
42                Ok(mut parsed) => {
43                    if let Some(json_object) = parsed.as_object_mut() {
44                        // remove large fields that we don't need
45                        json_object.remove("scripts");
46                        json_object.remove("optionalDependencies");
47                        json_object.remove("resolutions");
48                        json_object.remove("overrides");
49                        json_object.remove("packageManager");
50                        json_object.remove("engines");
51
52                        // Add name
53                        package_json.name = json_object
54                            .get("name")
55                            .and_then(|field| field.as_str())
56                            .map(ToString::to_string);
57
58                        // Add workspaces
59                        package_json.workspaces =
60                            Self::get_workspaces(json_object.get("workspaces"));
61
62                        if with_dependencies {
63                            // Add dependencies
64                            package_json.dependencies =
65                                Self::get_dependencies(json_object.get("dependencies"));
66                            package_json.dev_dependencies =
67                                Self::get_dependencies(json_object.get("devDependencies"));
68                            package_json.peer_dependencies =
69                                Self::get_dependencies(json_object.get("peerDependencies"));
70                        }
71                    }
72
73                    Some(package_json)
74                }
75                Err(e) => {
76                    Logger::error(&format!("Failed to parse package.json: {}", e));
77                    None
78                }
79            },
80            Err(e) => {
81                Logger::error(&format!("Failed to read package.json: {}", e));
82                None
83            }
84        }
85    }
86
87    /// Gets all dependencies (both regular and dev dependencies)
88    pub fn get_all_dependencies(&self) -> Option<HashSet<String>> {
89        let mut all_deps = HashSet::new();
90
91        if let Some(deps) = &self.dependencies {
92            all_deps.extend(deps.iter().cloned());
93        }
94
95        if let Some(dev_deps) = &self.dev_dependencies {
96            all_deps.extend(dev_deps.iter().cloned());
97        }
98
99        if let Some(peer_deps) = &self.peer_dependencies {
100            all_deps.extend(peer_deps.iter().cloned());
101        }
102
103        if all_deps.is_empty() {
104            None
105        } else {
106            Some(all_deps)
107        }
108    }
109
110    /// Finds a dependency by name in dependencies, devDependencies, or peerDependencies
111    ///
112    /// # Arguments
113    ///
114    /// * `name` - The name of the dependency to find.
115    pub fn find_dependency(&self, name: &str) -> Option<String> {
116        if let Some(deps) = &self.dependencies {
117            if deps.contains(name) {
118                return Some(name.to_string());
119            }
120        }
121
122        if let Some(dev_deps) = &self.dev_dependencies {
123            if dev_deps.contains(name) {
124                return Some(name.to_string());
125            }
126        }
127
128        if let Some(peer_deps) = &self.peer_dependencies {
129            if peer_deps.contains(name) {
130                return Some(name.to_string());
131            }
132        }
133
134        None
135    }
136
137    fn get_dependencies(deps_value: Option<&Value>) -> Option<HashSet<String>> {
138        deps_value.and_then(|deps| {
139            if let Some(obj) = deps.as_object() {
140                let deps: HashSet<String> = obj.keys().cloned().collect();
141                if deps.is_empty() {
142                    None
143                } else {
144                    Some(deps)
145                }
146            } else {
147                None
148            }
149        })
150    }
151
152    // TODO: resolve workspaces with blob support
153    fn get_workspaces(json: Option<&Value>) -> Option<Vec<String>> {
154        let workspaces = json.and_then(|field| field.as_array());
155
156        match workspaces {
157            Some(workspaces) => Some(
158                workspaces
159                    .iter()
160                    .map(|item| item.as_str().unwrap().to_string())
161                    .collect(),
162            ),
163            None => None,
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use crate::util::test_utils::create_mock_project;
171
172    use super::*;
173
174    #[test]
175    fn test_read_package_json() {
176        let temp_dir = create_mock_project(&vec![(
177            "package.json",
178            r#"
179            {
180                "name": "test-project",
181                "version": "1.0.0",
182                "workspaces": ["packages/*"]
183            }
184            "#,
185        )]);
186
187        let package_json =
188            PackageJson::read(&PathBuf::from(temp_dir.path().join("package.json")), true)
189                .expect("Failed to read package.json");
190        assert_eq!(package_json.name, Some("test-project".to_string()));
191    }
192
193    #[test]
194    fn test_read_package_json_with_invalid_name() {
195        let temp_dir = create_mock_project(&vec![(
196            "package.json",
197            r#"
198            {
199                "name": 123,
200                "version": "1.0.0",
201                "workspaces": ["packages/*"]
202            }
203            "#,
204        )]);
205
206        let package_json =
207            PackageJson::read(&PathBuf::from(temp_dir.path().join("package.json")), true)
208                .expect("Failed to read package.json");
209        assert_eq!(package_json.name, None);
210    }
211
212    #[test]
213    fn test_missing_package_json() {
214        assert!(PackageJson::read(&PathBuf::from("package.json"), true).is_none());
215    }
216
217    #[test]
218    fn test_resolves_workspaces() {
219        let temp_dir = create_mock_project(&vec![(
220            "package.json",
221            r#"
222        {
223            "name": "test-project",
224            "version": "1.0.0",
225            "workspaces": ["packages/*"]
226        }"#,
227        )]);
228
229        let package_json =
230            PackageJson::read(&PathBuf::from(temp_dir.path().join("package.json")), true).unwrap();
231
232        assert_eq!(
233            package_json.workspaces,
234            Some(vec!["packages/*".to_string()])
235        );
236    }
237
238    #[test]
239    fn test_get_all_dependencies() {
240        let temp_dir = create_mock_project(&vec![(
241            "package.json",
242            r#"
243            {
244                "name": "test-project",
245                "version": "1.0.0",
246                "dependencies": { "react": "18.3.1" },
247                "devDependencies": { "typescript": "5.0.0" },
248                "peerDependencies": { "react-dom": "18.3.1" }
249            }
250            "#,
251        )]);
252
253        let package_json =
254            PackageJson::read(&PathBuf::from(temp_dir.path().join("package.json")), true).unwrap();
255
256        assert_eq!(
257            package_json.dependencies,
258            Some(HashSet::from(["react".to_string()]))
259        );
260        assert_eq!(
261            package_json.dev_dependencies,
262            Some(HashSet::from(["typescript".to_string()]))
263        );
264        assert_eq!(
265            package_json.peer_dependencies,
266            Some(HashSet::from(["react-dom".to_string()]))
267        );
268
269        assert_eq!(
270            package_json.get_all_dependencies(),
271            Some(HashSet::from([
272                "react".to_string(),
273                "typescript".to_string(),
274                "react-dom".to_string(),
275            ]))
276        );
277    }
278
279    #[test]
280    fn test_find_dependency() {
281        let temp_dir = create_mock_project(&vec![(
282            "package.json",
283            r#"
284            {
285                "name": "test-project",
286                "version": "1.0.0",
287                "dependencies": { "react": "18.3.1" },
288                "devDependencies": { "typescript": "5.0.0" },
289                "peerDependencies": { "react-dom": "18.3.1" }
290            }
291            "#,
292        )]);
293
294        let package_json =
295            PackageJson::read(&PathBuf::from(temp_dir.path().join("package.json")), true).unwrap();
296
297        assert_eq!(
298            package_json.find_dependency("react"),
299            Some("react".to_string())
300        );
301        assert_eq!(
302            package_json.find_dependency("typescript"),
303            Some("typescript".to_string())
304        );
305        assert_eq!(
306            package_json.find_dependency("react-dom"),
307            Some("react-dom".to_string())
308        );
309        assert_eq!(package_json.find_dependency("react-router"), None);
310    }
311}