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