Skip to main content

provenant/parsers/
bower.rs

1//! Parser for Bower package manifests (bower.json).
2//!
3//! Extracts package metadata, dependencies, and license information from
4//! bower.json files used by the legacy Bower JavaScript package manager.
5//!
6//! # Supported Formats
7//! - bower.json (manifest)
8//! - .bower.json (alternative manifest)
9//!
10//! # Key Features
11//! - Dependency extraction (dependencies, devDependencies)
12//! - License extraction (string or array format)
13//! - Author parsing (string or object format)
14//! - VCS repository URL extraction
15//! - Private package detection
16//!
17//! # Implementation Notes
18//! - Uses serde_json for JSON parsing
19//! - Graceful error handling: logs warnings and returns default on parse failure
20//! - Authors field can be string, object, or array of either
21
22use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
23use crate::parser_warn as warn;
24use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
25use packageurl::PackageUrl;
26use serde_json::Value;
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
47/// Bower package parser for bower.json manifests.
48///
49/// Supports legacy Bower JavaScript package manager format with all
50/// standard fields including dependencies, devDependencies, authors, and licenses.
51pub 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(|s| truncate_field(s.to_string()));
69
70        // If name is missing, the package is considered private
71        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(|s| truncate_field(s.to_string()));
83
84        let description = json
85            .get(FIELD_DESCRIPTION)
86            .and_then(|v| v.as_str())
87            .map(|s| truncate_field(s.to_string()));
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 declared_license_expression = declared_license_expression.map(truncate_field);
93        let declared_license_expression_spdx = declared_license_expression_spdx.map(truncate_field);
94        let keywords = extract_keywords(&json);
95        let parties = extract_parties(&json);
96        let homepage_url = json
97            .get(FIELD_HOMEPAGE)
98            .and_then(|v| v.as_str())
99            .map(|s| truncate_field(s.to_string()));
100
101        let vcs_url = extract_vcs_url(&json);
102        let dependencies = extract_dependencies(&json, FIELD_DEPENDENCIES, "dependencies", true);
103        let dev_dependencies =
104            extract_dependencies(&json, FIELD_DEV_DEPENDENCIES, "devDependencies", false);
105
106        vec![PackageData {
107            package_type: Some(Self::PACKAGE_TYPE),
108            namespace: None,
109            name,
110            version,
111            qualifiers: None,
112            subpath: None,
113            primary_language: Some("JavaScript".to_string()),
114            description,
115            release_date: None,
116            parties,
117            keywords,
118            homepage_url,
119            download_url: None,
120            size: None,
121            sha1: None,
122            md5: None,
123            sha256: None,
124            sha512: None,
125            bug_tracking_url: None,
126            code_view_url: None,
127            vcs_url,
128            copyright: None,
129            holder: None,
130            declared_license_expression,
131            declared_license_expression_spdx,
132            license_detections,
133            other_license_expression: None,
134            other_license_expression_spdx: None,
135            other_license_detections: Vec::new(),
136            extracted_license_statement,
137            notice_text: None,
138            source_packages: Vec::new(),
139            file_references: Vec::new(),
140            is_private,
141            is_virtual: false,
142            extra_data: None,
143            dependencies: [dependencies, dev_dependencies].concat(),
144            repository_homepage_url: None,
145            repository_download_url: None,
146            api_data_url: None,
147            datasource_id: Some(DatasourceId::BowerJson),
148            purl: None,
149        }]
150    }
151
152    fn is_match(path: &Path) -> bool {
153        path.file_name()
154            .is_some_and(|name| name == "bower.json" || name == ".bower.json")
155    }
156}
157
158/// Reads and parses a JSON file
159fn read_and_parse_json(path: &Path) -> Result<Value, String> {
160    let content =
161        read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
162    serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))
163}
164
165/// Extracts license statement from the license field.
166/// Can be a string or an array of strings.
167fn extract_license_statement(json: &Value) -> Option<String> {
168    json.get(FIELD_LICENSE)
169        .and_then(|license_value| match license_value {
170            Value::String(s) => {
171                let trimmed = s.trim();
172                if trimmed.is_empty() {
173                    None
174                } else {
175                    Some(truncate_field(trimmed.to_string()))
176                }
177            }
178            Value::Array(licenses) => {
179                let license_strings: Vec<String> = licenses
180                    .iter()
181                    .take(MAX_ITERATION_COUNT)
182                    .filter_map(|v| v.as_str())
183                    .map(|s| s.trim())
184                    .filter(|s| !s.is_empty())
185                    .map(String::from)
186                    .collect();
187
188                if license_strings.is_empty() {
189                    None
190                } else {
191                    Some(truncate_field(license_strings.join(" AND ")))
192                }
193            }
194            _ => None,
195        })
196}
197
198fn normalize_bower_declared_license(
199    json: &Value,
200    extracted_license_statement: Option<&str>,
201) -> (
202    Option<String>,
203    Option<String>,
204    Vec<crate::models::LicenseDetection>,
205) {
206    match json.get(FIELD_LICENSE) {
207        Some(Value::Array(licenses)) => {
208            let normalized = licenses
209                .iter()
210                .take(MAX_ITERATION_COUNT)
211                .filter_map(|value| value.as_str().map(str::trim))
212                .filter(|value| !value.is_empty())
213                .map(normalize_declared_license_key)
214                .collect::<Option<Vec<_>>>();
215
216            if let Some(normalized) = normalized
217                && let Some(combined) = combine_normalized_licenses(normalized, " AND ")
218            {
219                return build_declared_license_data(
220                    combined,
221                    DeclaredLicenseMatchMetadata::single_line(
222                        extracted_license_statement.unwrap_or_default(),
223                    ),
224                );
225            }
226
227            empty_declared_license_data()
228        }
229        _ => normalize_spdx_declared_license(extracted_license_statement),
230    }
231}
232
233/// Extracts keywords from the keywords field.
234fn extract_keywords(json: &Value) -> Vec<String> {
235    json.get(FIELD_KEYWORDS)
236        .and_then(|v| v.as_array())
237        .map(|arr| {
238            arr.iter()
239                .take(MAX_ITERATION_COUNT)
240                .filter_map(|v| v.as_str())
241                .map(|s| truncate_field(s.to_string()))
242                .collect()
243        })
244        .unwrap_or_default()
245}
246
247/// Extracts parties (authors) from the authors field.
248/// Authors can be strings or objects with name, email, and homepage fields.
249fn extract_parties(json: &Value) -> Vec<Party> {
250    let mut parties = Vec::new();
251
252    if let Some(authors) = json.get(FIELD_AUTHORS).and_then(|v| v.as_array()) {
253        for author in authors.iter().take(MAX_ITERATION_COUNT) {
254            if let Some(party) = extract_party_from_author(author) {
255                parties.push(party);
256            }
257        }
258    }
259
260    parties
261}
262
263/// Extracts a single party from an author value (string or object).
264fn extract_party_from_author(author: &Value) -> Option<Party> {
265    match author {
266        Value::String(s) => {
267            let (name, email) = parse_author_string(s);
268            Some(Party {
269                r#type: Some("person".to_string()),
270                role: Some("author".to_string()),
271                name: name.map(truncate_field),
272                email: email.map(truncate_field),
273                url: None,
274                organization: None,
275                organization_url: None,
276                timezone: None,
277            })
278        }
279        Value::Object(obj) => {
280            let name = obj
281                .get("name")
282                .and_then(|v| v.as_str())
283                .map(|s| truncate_field(s.to_string()));
284            let email = obj
285                .get("email")
286                .and_then(|v| v.as_str())
287                .map(|s| truncate_field(s.to_string()));
288            let url = obj
289                .get("homepage")
290                .and_then(|v| v.as_str())
291                .map(|s| truncate_field(s.to_string()));
292
293            Some(Party {
294                r#type: Some("person".to_string()),
295                role: Some("author".to_string()),
296                name,
297                email,
298                url,
299                organization: None,
300                organization_url: None,
301                timezone: None,
302            })
303        }
304        _ => Some(Party {
305            r#type: Some("person".to_string()),
306            role: Some("author".to_string()),
307            name: Some(truncate_field(format!("{:?}", author))),
308            email: None,
309            url: None,
310            organization: None,
311            organization_url: None,
312            timezone: None,
313        }),
314    }
315}
316
317/// Parses author string in "Name <email>" format.
318/// Returns (name, email) tuple with both as Option<String>.
319fn parse_author_string(author_str: &str) -> (Option<String>, Option<String>) {
320    if let Some(email_start) = author_str.find('<')
321        && let Some(email_end) = author_str.find('>')
322        && email_start < email_end
323    {
324        let name = author_str[..email_start].trim();
325        let email = author_str[email_start + 1..email_end].trim();
326
327        let name = if name.is_empty() {
328            None
329        } else {
330            Some(truncate_field(name.to_string()))
331        };
332        let email = if email.is_empty() {
333            None
334        } else {
335            Some(truncate_field(email.to_string()))
336        };
337
338        return (name, email);
339    }
340
341    let trimmed = author_str.trim();
342    if trimmed.is_empty() {
343        (None, None)
344    } else {
345        (Some(truncate_field(trimmed.to_string())), None)
346    }
347}
348
349/// Extracts VCS URL from the repository field.
350/// Repository can be an object with type and url fields.
351fn extract_vcs_url(json: &Value) -> Option<String> {
352    json.get(FIELD_REPOSITORY).and_then(|repo| {
353        if let Some(repo_obj) = repo.as_object() {
354            let repo_type = repo_obj.get("type").and_then(|v| v.as_str());
355            let repo_url = repo_obj.get("url").and_then(|v| v.as_str());
356
357            match (repo_type, repo_url) {
358                (Some(t), Some(u)) if !t.is_empty() && !u.is_empty() => {
359                    Some(truncate_field(format!("{}+{}", t, u)))
360                }
361                _ => None,
362            }
363        } else {
364            None
365        }
366    })
367}
368
369/// Extracts dependencies from a dependency field.
370fn extract_dependencies(
371    json: &Value,
372    field: &str,
373    scope: &str,
374    is_runtime: bool,
375) -> Vec<Dependency> {
376    json.get(field)
377        .and_then(|deps| deps.as_object())
378        .map_or_else(Vec::new, |deps| {
379            deps.iter()
380                .take(MAX_ITERATION_COUNT)
381                .filter_map(|(name, requirement)| {
382                    let requirement_str = requirement.as_str()?;
383                    let package_url =
384                        PackageUrl::new(BowerJsonParser::PACKAGE_TYPE.as_str(), name).ok()?;
385
386                    Some(Dependency {
387                        purl: Some(truncate_field(package_url.to_string())),
388                        extracted_requirement: Some(truncate_field(requirement_str.to_string())),
389                        scope: Some(scope.to_string()),
390                        is_runtime: Some(is_runtime),
391                        is_optional: Some(!is_runtime),
392                        is_pinned: None,
393                        is_direct: Some(true),
394                        resolved_package: None,
395                        extra_data: None,
396                    })
397                })
398                .collect()
399        })
400}
401
402fn default_package_data() -> PackageData {
403    PackageData {
404        package_type: Some(BowerJsonParser::PACKAGE_TYPE),
405        primary_language: Some("JavaScript".to_string()),
406        datasource_id: Some(DatasourceId::BowerJson),
407        ..Default::default()
408    }
409}
410
411crate::register_parser!(
412    "Bower package manifest",
413    &["**/bower.json", "**/.bower.json"],
414    "bower",
415    "JavaScript",
416    Some("https://bower.io"),
417);