provenant/parsers/
readme.rs1use crate::models::PackageData;
31use crate::models::{DatasourceId, PackageType};
32use crate::parser_warn as warn;
33use crate::parsers::utils::{CappedIterExt, read_file_to_string, truncate_field};
34use std::path::Path;
35
36use super::PackageParser;
37use super::metadata::ParserMetadata;
38
39pub struct ReadmeParser;
44
45impl PackageParser for ReadmeParser {
46 const PACKAGE_TYPE: PackageType = PackageType::Readme;
47
48 fn metadata() -> Vec<ParserMetadata> {
49 vec![ParserMetadata {
50 description: "Third-party attribution README files",
51 file_patterns: &[
52 "**/README.android",
53 "**/README.chromium",
54 "**/README.facebook",
55 "**/README.google",
56 "**/README.thirdparty",
57 ],
58 package_type: "readme",
59 primary_language: "",
60 documentation_url: Some(
61 "https://github.com/chromium/chromium/blob/main/docs/contributing.md#third_party-components",
62 ),
63 }]
64 }
65
66 fn is_match(path: &Path) -> bool {
67 path.file_name().is_some_and(|name| {
68 let name = name.to_string_lossy().to_lowercase();
69 matches!(
70 name.as_str(),
71 "readme.android"
72 | "readme.chromium"
73 | "readme.facebook"
74 | "readme.google"
75 | "readme.thirdparty"
76 )
77 })
78 }
79
80 fn extract_packages(path: &Path) -> Vec<PackageData> {
81 let content = match read_file_to_string(path, None) {
82 Ok(content) => content,
83 Err(e) => {
84 warn!("Failed to read README file at {:?}: {}", path, e);
85 return vec![default_package_data()];
86 }
87 };
88
89 let mut pkg = default_package_data();
90
91 for line in content.lines().capped("readme key:value lines") {
93 let line = line.trim();
94 if line.is_empty() {
95 continue;
96 }
97
98 let split_colon = line.split_once(':');
99 let split_equals = line.split_once('=');
100
101 let (key, value) = match (split_colon, split_equals) {
102 (Some((ck, cv)), Some((ek, _))) if ck.len() <= ek.len() => (ck.trim(), cv.trim()),
103 (_, Some((ek, ev))) => (ek.trim(), ev.trim()),
104 (Some((ck, cv)), None) => (ck.trim(), cv.trim()),
105 (None, None) => continue,
106 };
107
108 if key.is_empty() || value.is_empty() {
109 continue;
110 }
111
112 let key_lower = key.to_lowercase();
114 match key_lower.as_str() {
115 "name" | "project" => {
116 pkg.name = Some(truncate_field(value.to_string()));
117 }
118 "version" => {
119 pkg.version = Some(truncate_field(value.to_string()));
120 }
121 "copyright" => {
122 pkg.copyright = Some(truncate_field(value.to_string()));
123 }
124 "download link" | "downloaded from" => {
125 pkg.download_url = Some(truncate_field(value.to_string()));
126 }
127 "homepage" | "website" | "repo" | "source" | "upstream" | "url" | "project url" => {
128 pkg.homepage_url = Some(truncate_field(value.to_string()));
129 }
130 "licence" | "license" => {
131 pkg.extracted_license_statement = Some(truncate_field(value.to_string()));
132 }
133 _ => {
134 }
136 }
137 }
138
139 if pkg.name.is_none()
141 && let Some(parent) = path.parent()
142 && let Some(parent_name) = parent.file_name()
143 {
144 pkg.name = Some(truncate_field(parent_name.to_string_lossy().to_string()));
145 }
146
147 vec![pkg]
148 }
149}
150
151fn default_package_data() -> PackageData {
152 PackageData {
153 package_type: Some(ReadmeParser::PACKAGE_TYPE),
154 datasource_id: Some(DatasourceId::Readme),
155 ..Default::default()
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use std::path::PathBuf;
163
164 #[test]
165 fn test_is_match_android() {
166 let valid = PathBuf::from("/some/path/README.android");
167 assert!(ReadmeParser::is_match(&valid));
168 }
169
170 #[test]
171 fn test_is_match_chromium() {
172 let valid = PathBuf::from("/some/path/README.chromium");
173 assert!(ReadmeParser::is_match(&valid));
174 }
175
176 #[test]
177 fn test_is_match_facebook() {
178 let valid = PathBuf::from("/some/path/README.facebook");
179 assert!(ReadmeParser::is_match(&valid));
180 }
181
182 #[test]
183 fn test_is_match_google() {
184 let valid = PathBuf::from("/some/path/README.google");
185 assert!(ReadmeParser::is_match(&valid));
186 }
187
188 #[test]
189 fn test_is_match_thirdparty() {
190 let valid = PathBuf::from("/some/path/README.thirdparty");
191 assert!(ReadmeParser::is_match(&valid));
192 }
193
194 #[test]
195 fn test_is_match_case_insensitive() {
196 let upper = PathBuf::from("/some/path/README.CHROMIUM");
197 let mixed = PathBuf::from("/some/path/README.ChRoMiUm");
198 assert!(ReadmeParser::is_match(&upper));
199 assert!(ReadmeParser::is_match(&mixed));
200 }
201
202 #[test]
203 fn test_is_match_negative_cases() {
204 let readme_md = PathBuf::from("/some/path/README.md");
205 let readme_txt = PathBuf::from("/some/path/README.txt");
206 let readme = PathBuf::from("/some/path/README");
207 let other = PathBuf::from("/some/path/INSTALL.txt");
208
209 assert!(!ReadmeParser::is_match(&readme_md));
210 assert!(!ReadmeParser::is_match(&readme_txt));
211 assert!(!ReadmeParser::is_match(&readme));
212 assert!(!ReadmeParser::is_match(&other));
213 }
214
215 #[test]
216 fn test_extract_chromium_format() {
217 let path = PathBuf::from("testdata/readme/chromium/third_party/example/README.chromium");
218 let pkg = ReadmeParser::extract_first_package(&path);
219
220 assert_eq!(pkg.package_type, Some(PackageType::Readme));
221 assert_eq!(pkg.name, Some("Example Library".to_string()));
222 assert_eq!(pkg.version, Some("2.1.0".to_string()));
223 assert_eq!(pkg.homepage_url, Some("https://example.com".to_string()));
224 assert_eq!(pkg.extracted_license_statement, Some("MIT".to_string()));
225 assert_eq!(pkg.datasource_id, Some(DatasourceId::Readme));
226 }
227
228 #[test]
229 fn test_extract_android_format() {
230 let path = PathBuf::from("testdata/readme/android/third_party/example/README.android");
231 let pkg = ReadmeParser::extract_first_package(&path);
232
233 assert_eq!(pkg.name, Some("Android Example".to_string()));
234 assert_eq!(pkg.version, Some("1.0".to_string()));
235 assert_eq!(
236 pkg.homepage_url,
237 Some("https://android.example.com".to_string())
238 );
239 assert_eq!(pkg.copyright, Some("2024 Google Inc.".to_string()));
240 }
241
242 #[test]
243 fn test_extract_facebook_format() {
244 let path = PathBuf::from("testdata/readme/facebook/third_party/example/README.facebook");
245 let pkg = ReadmeParser::extract_first_package(&path);
246
247 assert_eq!(pkg.name, Some("FB Library".to_string()));
248 assert_eq!(
249 pkg.download_url,
250 Some("https://github.com/example/fb-lib".to_string())
251 );
252 assert_eq!(
253 pkg.extracted_license_statement,
254 Some("BSD-3-Clause".to_string())
255 );
256 }
257
258 #[test]
259 fn test_extract_parent_dir_fallback() {
260 let path = PathBuf::from("testdata/readme/no-name/third_party/mylib/README.thirdparty");
261 let pkg = ReadmeParser::extract_first_package(&path);
262
263 assert_eq!(pkg.name, Some("mylib".to_string()));
265 assert_eq!(pkg.homepage_url, Some("https://example.com".to_string()));
266 assert_eq!(pkg.version, Some("3.0".to_string()));
267 }
268
269 #[test]
270 fn test_extract_equals_separator() {
271 let path =
272 PathBuf::from("testdata/readme/equals-separator/third_party/eqlib/README.google");
273 let pkg = ReadmeParser::extract_first_package(&path);
274
275 assert_eq!(pkg.name, Some("Google Lib".to_string()));
276 assert_eq!(
277 pkg.homepage_url,
278 Some("https://google.example.com".to_string())
279 );
280 assert_eq!(
281 pkg.extracted_license_statement,
282 Some("Apache-2.0".to_string())
283 );
284 }
285
286 #[test]
287 fn test_case_insensitive_field_names() {
288 let path = PathBuf::from("testdata/readme/chromium/third_party/example/README.chromium");
289 let pkg = ReadmeParser::extract_first_package(&path);
290
291 assert!(pkg.name.is_some());
294 assert!(pkg.version.is_some());
295 assert!(pkg.homepage_url.is_some());
296 assert!(pkg.extracted_license_statement.is_some());
297 }
298
299 #[test]
300 fn test_invalid_file() {
301 let nonexistent = PathBuf::from("testdata/readme/nonexistent/README.chromium");
302 let pkg = ReadmeParser::extract_first_package(&nonexistent);
303
304 assert_eq!(pkg.package_type, Some(PackageType::Readme));
306 assert_eq!(pkg.datasource_id, Some(DatasourceId::Readme));
307 }
308}