1use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
23use log::warn;
24use packageurl::PackageUrl;
25use serde_json::Value;
26use std::fs;
27use std::path::Path;
28
29use super::PackageParser;
30
31const FIELD_NAME: &str = "name";
32const FIELD_VERSION: &str = "version";
33const FIELD_DESCRIPTION: &str = "description";
34const FIELD_LICENSE: &str = "license";
35const FIELD_KEYWORDS: &str = "keywords";
36const FIELD_AUTHORS: &str = "authors";
37const FIELD_HOMEPAGE: &str = "homepage";
38const FIELD_REPOSITORY: &str = "repository";
39const FIELD_DEPENDENCIES: &str = "dependencies";
40const FIELD_DEV_DEPENDENCIES: &str = "devDependencies";
41const FIELD_PRIVATE: &str = "private";
42
43pub struct BowerJsonParser;
48
49impl PackageParser for BowerJsonParser {
50 const PACKAGE_TYPE: PackageType = PackageType::Bower;
51
52 fn extract_packages(path: &Path) -> Vec<PackageData> {
53 let json = match read_and_parse_json(path) {
54 Ok(json) => json,
55 Err(e) => {
56 warn!("Failed to read or parse bower.json at {:?}: {}", path, e);
57 return vec![default_package_data()];
58 }
59 };
60
61 let name = json
62 .get(FIELD_NAME)
63 .and_then(|v| v.as_str())
64 .map(String::from);
65
66 let is_private = if name.is_none() {
68 true
69 } else {
70 json.get(FIELD_PRIVATE)
71 .and_then(|v| v.as_bool())
72 .unwrap_or(false)
73 };
74
75 let version = json
76 .get(FIELD_VERSION)
77 .and_then(|v| v.as_str())
78 .map(String::from);
79
80 let description = json
81 .get(FIELD_DESCRIPTION)
82 .and_then(|v| v.as_str())
83 .map(String::from);
84
85 let extracted_license_statement = extract_license_statement(&json);
86 let keywords = extract_keywords(&json);
87 let parties = extract_parties(&json);
88 let homepage_url = json
89 .get(FIELD_HOMEPAGE)
90 .and_then(|v| v.as_str())
91 .map(String::from);
92
93 let vcs_url = extract_vcs_url(&json);
94 let dependencies = extract_dependencies(&json, FIELD_DEPENDENCIES, "dependencies", true);
95 let dev_dependencies =
96 extract_dependencies(&json, FIELD_DEV_DEPENDENCIES, "devDependencies", false);
97
98 vec![PackageData {
99 package_type: Some(Self::PACKAGE_TYPE),
100 namespace: None,
101 name,
102 version,
103 qualifiers: None,
104 subpath: None,
105 primary_language: Some("JavaScript".to_string()),
106 description,
107 release_date: None,
108 parties,
109 keywords,
110 homepage_url,
111 download_url: None,
112 size: None,
113 sha1: None,
114 md5: None,
115 sha256: None,
116 sha512: None,
117 bug_tracking_url: None,
118 code_view_url: None,
119 vcs_url,
120 copyright: None,
121 holder: None,
122 declared_license_expression: None,
123 declared_license_expression_spdx: None,
124 license_detections: Vec::new(),
125 other_license_expression: None,
126 other_license_expression_spdx: None,
127 other_license_detections: Vec::new(),
128 extracted_license_statement,
129 notice_text: None,
130 source_packages: Vec::new(),
131 file_references: Vec::new(),
132 is_private,
133 is_virtual: false,
134 extra_data: None,
135 dependencies: [dependencies, dev_dependencies].concat(),
136 repository_homepage_url: None,
137 repository_download_url: None,
138 api_data_url: None,
139 datasource_id: Some(DatasourceId::BowerJson),
140 purl: None,
141 }]
142 }
143
144 fn is_match(path: &Path) -> bool {
145 path.file_name()
146 .is_some_and(|name| name == "bower.json" || name == ".bower.json")
147 }
148}
149
150fn read_and_parse_json(path: &Path) -> Result<Value, String> {
152 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
153 serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))
154}
155
156fn extract_license_statement(json: &Value) -> Option<String> {
159 json.get(FIELD_LICENSE)
160 .and_then(|license_value| match license_value {
161 Value::String(s) => {
162 let trimmed = s.trim();
163 if trimmed.is_empty() {
164 None
165 } else {
166 Some(trimmed.to_string())
167 }
168 }
169 Value::Array(licenses) => {
170 let license_strings: Vec<String> = licenses
171 .iter()
172 .filter_map(|v| v.as_str())
173 .map(|s| s.trim())
174 .filter(|s| !s.is_empty())
175 .map(String::from)
176 .collect();
177
178 if license_strings.is_empty() {
179 None
180 } else {
181 Some(license_strings.join(" AND "))
182 }
183 }
184 _ => None,
185 })
186}
187
188fn extract_keywords(json: &Value) -> Vec<String> {
190 json.get(FIELD_KEYWORDS)
191 .and_then(|v| v.as_array())
192 .map(|arr| {
193 arr.iter()
194 .filter_map(|v| v.as_str())
195 .map(String::from)
196 .collect()
197 })
198 .unwrap_or_default()
199}
200
201fn extract_parties(json: &Value) -> Vec<Party> {
204 let mut parties = Vec::new();
205
206 if let Some(authors) = json.get(FIELD_AUTHORS).and_then(|v| v.as_array()) {
207 for author in authors {
208 if let Some(party) = extract_party_from_author(author) {
209 parties.push(party);
210 }
211 }
212 }
213
214 parties
215}
216
217fn extract_party_from_author(author: &Value) -> Option<Party> {
219 match author {
220 Value::String(s) => {
221 let (name, email) = parse_author_string(s);
223 Some(Party {
224 r#type: Some("person".to_string()),
225 role: Some("author".to_string()),
226 name,
227 email,
228 url: None,
229 organization: None,
230 organization_url: None,
231 timezone: None,
232 })
233 }
234 Value::Object(obj) => {
235 let name = obj.get("name").and_then(|v| v.as_str()).map(String::from);
236 let email = obj.get("email").and_then(|v| v.as_str()).map(String::from);
237 let url = obj
238 .get("homepage")
239 .and_then(|v| v.as_str())
240 .map(String::from);
241
242 Some(Party {
243 r#type: Some("person".to_string()),
244 role: Some("author".to_string()),
245 name,
246 email,
247 url,
248 organization: None,
249 organization_url: None,
250 timezone: None,
251 })
252 }
253 _ => {
254 Some(Party {
256 r#type: Some("person".to_string()),
257 role: Some("author".to_string()),
258 name: Some(format!("{:?}", author)),
259 email: None,
260 url: None,
261 organization: None,
262 organization_url: None,
263 timezone: None,
264 })
265 }
266 }
267}
268
269fn parse_author_string(author_str: &str) -> (Option<String>, Option<String>) {
272 if let Some(email_start) = author_str.find('<')
273 && let Some(email_end) = author_str.find('>')
274 && email_start < email_end
275 {
276 let name = author_str[..email_start].trim();
277 let email = author_str[email_start + 1..email_end].trim();
278
279 let name = if name.is_empty() {
280 None
281 } else {
282 Some(name.to_string())
283 };
284 let email = if email.is_empty() {
285 None
286 } else {
287 Some(email.to_string())
288 };
289
290 return (name, email);
291 }
292
293 let trimmed = author_str.trim();
295 if trimmed.is_empty() {
296 (None, None)
297 } else {
298 (Some(trimmed.to_string()), None)
299 }
300}
301
302fn extract_vcs_url(json: &Value) -> Option<String> {
305 json.get(FIELD_REPOSITORY).and_then(|repo| {
306 if let Some(repo_obj) = repo.as_object() {
307 let repo_type = repo_obj.get("type").and_then(|v| v.as_str());
308 let repo_url = repo_obj.get("url").and_then(|v| v.as_str());
309
310 match (repo_type, repo_url) {
311 (Some(t), Some(u)) if !t.is_empty() && !u.is_empty() => {
312 Some(format!("{}+{}", t, u))
313 }
314 _ => None,
315 }
316 } else {
317 None
318 }
319 })
320}
321
322fn extract_dependencies(
324 json: &Value,
325 field: &str,
326 scope: &str,
327 is_runtime: bool,
328) -> Vec<Dependency> {
329 json.get(field)
330 .and_then(|deps| deps.as_object())
331 .map_or_else(Vec::new, |deps| {
332 deps.iter()
333 .filter_map(|(name, requirement)| {
334 let requirement_str = requirement.as_str()?;
335 let package_url =
336 PackageUrl::new(BowerJsonParser::PACKAGE_TYPE.as_str(), name).ok()?;
337
338 Some(Dependency {
339 purl: Some(package_url.to_string()),
340 extracted_requirement: Some(requirement_str.to_string()),
341 scope: Some(scope.to_string()),
342 is_runtime: Some(is_runtime),
343 is_optional: Some(!is_runtime),
344 is_pinned: None,
345 is_direct: Some(true),
346 resolved_package: None,
347 extra_data: None,
348 })
349 })
350 .collect()
351 })
352}
353
354fn default_package_data() -> PackageData {
355 PackageData {
356 primary_language: Some("JavaScript".to_string()),
357 ..Default::default()
358 }
359}
360
361crate::register_parser!(
362 "Bower package manifest",
363 &["**/bower.json", "**/.bower.json"],
364 "bower",
365 "JavaScript",
366 Some("https://bower.io"),
367);