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 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
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(String::from);
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(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
156/// Reads and parses a JSON file
157fn 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
162/// Extracts license statement from the license field.
163/// Can be a string or an array of strings.
164fn 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
228/// Extracts keywords from the keywords field.
229fn 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
241/// Extracts parties (authors) from the authors field.
242/// Authors can be strings or objects with name, email, and homepage fields.
243fn 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
257/// Extracts a single party from an author value (string or object).
258fn extract_party_from_author(author: &Value) -> Option<Party> {
259    match author {
260        Value::String(s) => {
261            // Parse "Name <email>" format
262            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            // Handle other types by converting to string representation
295            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
309/// Parses author string in "Name <email>" format.
310/// Returns (name, email) tuple with both as Option<String>.
311fn 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    // No email found, return entire string as name
334    let trimmed = author_str.trim();
335    if trimmed.is_empty() {
336        (None, None)
337    } else {
338        (Some(trimmed.to_string()), None)
339    }
340}
341
342/// Extracts VCS URL from the repository field.
343/// Repository can be an object with type and url fields.
344fn 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
362/// Extracts dependencies from a dependency field.
363fn 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);