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 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
43/// Bower package parser for bower.json manifests.
44///
45/// Supports legacy Bower JavaScript package manager format with all
46/// standard fields including dependencies, devDependencies, authors, and licenses.
47pub 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        // If name is missing, the package is considered private
67        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
150/// Reads and parses a JSON file
151fn 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
156/// Extracts license statement from the license field.
157/// Can be a string or an array of strings.
158fn 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
188/// Extracts keywords from the keywords field.
189fn 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
201/// Extracts parties (authors) from the authors field.
202/// Authors can be strings or objects with name, email, and homepage fields.
203fn 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
217/// Extracts a single party from an author value (string or object).
218fn extract_party_from_author(author: &Value) -> Option<Party> {
219    match author {
220        Value::String(s) => {
221            // Parse "Name <email>" format
222            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            // Handle other types by converting to string representation
255            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
269/// Parses author string in "Name <email>" format.
270/// Returns (name, email) tuple with both as Option<String>.
271fn 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    // No email found, return entire string as name
294    let trimmed = author_str.trim();
295    if trimmed.is_empty() {
296        (None, None)
297    } else {
298        (Some(trimmed.to_string()), None)
299    }
300}
301
302/// Extracts VCS URL from the repository field.
303/// Repository can be an object with type and url fields.
304fn 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
322/// Extracts dependencies from a dependency field.
323fn 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);