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