1use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
20use log::warn;
21use packageurl::PackageUrl;
22use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use std::fs::File;
25use std::io::Read;
26use std::path::Path;
27
28use super::PackageParser;
29
30pub struct HaxeParser;
35
36impl PackageParser for HaxeParser {
37 const PACKAGE_TYPE: PackageType = PackageType::Haxe;
38
39 fn is_match(path: &Path) -> bool {
40 path.file_name().is_some_and(|name| name == "haxelib.json")
41 }
42
43 fn extract_packages(path: &Path) -> Vec<PackageData> {
44 let json_content = match read_haxelib_json(path) {
45 Ok(content) => content,
46 Err(e) => {
47 warn!("Failed to read or parse haxelib.json at {:?}: {}", path, e);
48 return vec![default_package_data()];
49 }
50 };
51
52 let name = json_content.name;
53 let version = json_content.version;
54
55 let purl = create_package_url(&name, &version);
57
58 let (repository_homepage_url, download_url, repository_download_url) =
60 if let Some(ref n) = name {
61 let home = format!("https://lib.haxe.org/p/{}", n);
62 if let Some(ref v) = version {
63 let dl = format!("https://lib.haxe.org/p/{}/{}/download/", n, v);
64 (Some(home), Some(dl.clone()), Some(dl))
65 } else {
66 (Some(home), None, None)
67 }
68 } else {
69 (None, None, None)
70 };
71
72 let mut dependencies = Vec::new();
74 let mut deps_list: Vec<_> = json_content.dependencies.into_iter().collect();
75 deps_list.sort_by(|a, b| a.0.cmp(&b.0));
76
77 for (dep_name, dep_version) in deps_list {
78 let is_pinned = !dep_version.is_empty();
79 let dep_purl = create_dep_package_url(&dep_name, &dep_version, is_pinned);
80
81 dependencies.push(Dependency {
82 purl: dep_purl,
83 extracted_requirement: None,
84 scope: None,
85 is_runtime: Some(true),
86 is_optional: Some(false),
87 is_pinned: Some(is_pinned),
88 is_direct: Some(true),
89 resolved_package: None,
90 extra_data: None,
91 });
92 }
93
94 let mut parties = Vec::new();
96 for contrib in json_content.contributors {
97 parties.push(Party {
98 r#type: Some("person".to_string()),
99 role: Some("contributor".to_string()),
100 name: Some(contrib.clone()),
101 email: None,
102 url: Some(format!("https://lib.haxe.org/u/{}", contrib)),
103 organization: None,
104 organization_url: None,
105 timezone: None,
106 });
107 }
108
109 vec![PackageData {
110 package_type: Some(Self::PACKAGE_TYPE),
111 namespace: None,
112 name,
113 version,
114 qualifiers: None,
115 subpath: None,
116 primary_language: Some("Haxe".to_string()),
117 description: json_content.description,
118 release_date: None,
119 parties,
120 keywords: json_content.tags,
121 homepage_url: json_content.url,
122 download_url,
123 size: None,
124 sha1: None,
125 md5: None,
126 sha256: None,
127 sha512: None,
128 bug_tracking_url: None,
129 code_view_url: None,
130 vcs_url: None,
131 copyright: None,
132 holder: None,
133 declared_license_expression: None,
134 declared_license_expression_spdx: None,
135 license_detections: Vec::new(),
136 other_license_expression: None,
137 other_license_expression_spdx: None,
138 other_license_detections: Vec::new(),
139 extracted_license_statement: json_content.license,
140 notice_text: None,
141 source_packages: Vec::new(),
142 file_references: Vec::new(),
143 is_private: false,
144 is_virtual: false,
145 extra_data: None,
146 dependencies,
147 repository_homepage_url,
148 repository_download_url,
149 api_data_url: None,
150 datasource_id: Some(DatasourceId::HaxelibJson),
151 purl,
152 }]
153 }
154}
155
156#[derive(Debug, Deserialize, Serialize)]
158struct HaxelibJson {
159 #[serde(default)]
160 name: Option<String>,
161 #[serde(default)]
162 version: Option<String>,
163 #[serde(default)]
164 license: Option<String>,
165 #[serde(default)]
166 url: Option<String>,
167 #[serde(default)]
168 description: Option<String>,
169 #[serde(default)]
170 tags: Vec<String>,
171 #[serde(default)]
172 contributors: Vec<String>,
173 #[serde(default)]
174 dependencies: HashMap<String, String>,
175}
176
177fn read_haxelib_json(path: &Path) -> Result<HaxelibJson, String> {
179 let mut file = File::open(path).map_err(|e| format!("Failed to open file: {}", e))?;
180
181 let mut content = String::new();
182 file.read_to_string(&mut content)
183 .map_err(|e| format!("Failed to read file: {}", e))?;
184
185 serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))
186}
187
188fn create_package_url(name: &Option<String>, version: &Option<String>) -> Option<String> {
190 name.as_ref().and_then(|name| {
191 let mut package_url = match PackageUrl::new("haxe", name) {
192 Ok(p) => p,
193 Err(e) => {
194 warn!(
195 "Failed to create PackageUrl for haxe package '{}': {}",
196 name, e
197 );
198 return None;
199 }
200 };
201
202 if let Some(v) = version
203 && let Err(e) = package_url.with_version(v)
204 {
205 warn!(
206 "Failed to set version '{}' for haxe package '{}': {}",
207 v, name, e
208 );
209 return None;
210 }
211
212 Some(package_url.to_string())
213 })
214}
215
216fn create_dep_package_url(name: &str, version: &str, is_pinned: bool) -> Option<String> {
218 let mut package_url = match PackageUrl::new("haxe", name) {
219 Ok(p) => p,
220 Err(e) => {
221 warn!(
222 "Failed to create PackageUrl for haxe dependency '{}': {}",
223 name, e
224 );
225 return None;
226 }
227 };
228
229 if is_pinned && let Err(e) = package_url.with_version(version) {
230 warn!(
231 "Failed to set version '{}' for haxe dependency '{}': {}",
232 version, name, e
233 );
234 return None;
235 }
236
237 Some(package_url.to_string())
238}
239
240fn default_package_data() -> PackageData {
241 PackageData {
242 package_type: Some(HaxeParser::PACKAGE_TYPE),
243 primary_language: Some("Haxe".to_string()),
244 datasource_id: Some(DatasourceId::HaxelibJson),
245 ..Default::default()
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::models::DatasourceId;
253 use std::path::PathBuf;
254
255 #[test]
256 fn test_is_match() {
257 let valid_path = PathBuf::from("/some/path/haxelib.json");
258 let invalid_path = PathBuf::from("/some/path/not_haxelib.json");
259
260 assert!(HaxeParser::is_match(&valid_path));
261 assert!(!HaxeParser::is_match(&invalid_path));
262 }
263
264 #[test]
265 fn test_extract_from_testdata_basic() {
266 let haxelib_path = PathBuf::from("testdata/haxe/basic/haxelib.json");
267 let package_data = HaxeParser::extract_first_package(&haxelib_path);
268
269 assert_eq!(package_data.package_type, Some(PackageType::Haxe));
270 assert_eq!(package_data.name, Some("haxelib".to_string()));
271 assert_eq!(package_data.version, Some("3.4.0".to_string()));
272 assert_eq!(
273 package_data.homepage_url,
274 Some("https://lib.haxe.org/documentation/".to_string())
275 );
276 assert_eq!(
277 package_data.download_url,
278 Some("https://lib.haxe.org/p/haxelib/3.4.0/download/".to_string())
279 );
280 assert_eq!(
281 package_data.repository_homepage_url,
282 Some("https://lib.haxe.org/p/haxelib".to_string())
283 );
284 assert_eq!(
285 package_data.extracted_license_statement,
286 Some("GPL".to_string())
287 );
288
289 assert_eq!(
291 package_data.purl,
292 Some("pkg:haxe/haxelib@3.4.0".to_string())
293 );
294
295 assert_eq!(package_data.parties.len(), 6);
297 let names: Vec<&str> = package_data
298 .parties
299 .iter()
300 .filter_map(|p| p.name.as_deref())
301 .collect();
302 assert!(names.contains(&"back2dos"));
303 assert!(names.contains(&"ncannasse"));
304 }
305
306 #[test]
307 fn test_extract_with_dependencies() {
308 let haxelib_path = PathBuf::from("testdata/haxe/deps/haxelib.json");
309 let package_data = HaxeParser::extract_first_package(&haxelib_path);
310
311 assert_eq!(package_data.name, Some("selecthxml".to_string()));
312 assert_eq!(package_data.version, Some("0.5.1".to_string()));
313
314 assert_eq!(package_data.dependencies.len(), 2);
316
317 let pinned_deps: Vec<_> = package_data
318 .dependencies
319 .iter()
320 .filter(|d| d.is_pinned == Some(true))
321 .collect();
322 assert_eq!(pinned_deps.len(), 1);
323 assert!(pinned_deps[0].purl.as_ref().unwrap().contains("@3.23"));
324
325 let unpinned_deps: Vec<_> = package_data
326 .dependencies
327 .iter()
328 .filter(|d| d.is_pinned == Some(false))
329 .collect();
330 assert_eq!(unpinned_deps.len(), 1);
331 }
332
333 #[test]
334 fn test_extract_with_tags() {
335 let haxelib_path = PathBuf::from("testdata/haxe/tags/haxelib.json");
336 let package_data = HaxeParser::extract_first_package(&haxelib_path);
337
338 assert_eq!(package_data.name, Some("tink_core".to_string()));
339 assert_eq!(package_data.version, Some("1.18.0".to_string()));
340 assert_eq!(
341 package_data.extracted_license_statement,
342 Some("MIT".to_string())
343 );
344
345 assert_eq!(
347 package_data.keywords,
348 vec![
349 "tink".to_string(),
350 "cross".to_string(),
351 "utility".to_string(),
352 "reactive".to_string(),
353 "functional".to_string(),
354 "async".to_string(),
355 "lazy".to_string(),
356 "signal".to_string(),
357 "event".to_string(),
358 ]
359 );
360 }
361
362 #[test]
363 fn test_invalid_file() {
364 let nonexistent_path = PathBuf::from("testdata/haxe/nonexistent/haxelib.json");
365 let package_data = HaxeParser::extract_first_package(&nonexistent_path);
366
367 assert_eq!(package_data.package_type, Some(PackageType::Haxe));
369 assert_eq!(package_data.datasource_id, Some(DatasourceId::HaxelibJson));
370 assert!(package_data.name.is_none());
371 }
372}
373
374crate::register_parser!(
375 "Haxe haxelib.json package manifest",
376 &["**/haxelib.json"],
377 "haxe",
378 "Haxe",
379 Some("https://lib.haxe.org/documentation/creating-a-haxelib-package/"),
380);