1use std::path::Path;
30
31use crate::parser_warn as warn;
32use regex::Regex;
33
34use super::metadata::ParserMetadata;
35use crate::models::{
36 DatasourceId, Dependency, Md5Digest, PackageData, PackageType, Party, PartyType, Sha1Digest,
37 Sha256Digest, Sha512Digest,
38};
39use crate::parsers::PackageParser;
40use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
41
42use super::license_normalization::{
43 DeclaredLicenseMatchMetadata, build_declared_license_data_from_pair,
44 normalize_spdx_declared_license,
45};
46
47pub struct OpamParser;
52
53impl PackageParser for OpamParser {
54 const PACKAGE_TYPE: PackageType = PackageType::Opam;
55
56 fn metadata() -> Vec<ParserMetadata> {
57 vec![ParserMetadata {
58 description: "OCaml OPAM package manifest",
59 file_patterns: &["**/*.opam", "**/opam"],
60 package_type: "opam",
61 primary_language: "OCaml",
62 documentation_url: Some("https://opam.ocaml.org/doc/Manual.html"),
63 }]
64 }
65
66 fn is_match(path: &Path) -> bool {
67 path.file_name().is_some_and(|name| {
68 name.to_string_lossy().ends_with(".opam") || name.to_string_lossy() == "opam"
69 })
70 }
71
72 fn extract_packages(path: &Path) -> Vec<PackageData> {
73 vec![match read_file_to_string(path, None) {
74 Ok(text) => parse_opam(&text),
75 Err(e) => {
76 warn!("Failed to read OPAM file {:?}: {}", path, e);
77 default_package_data()
78 }
79 }]
80 }
81}
82
83#[derive(Debug, Default)]
85struct OpamData {
86 name: Option<String>,
87 version: Option<String>,
88 synopsis: Option<String>,
89 description: Option<String>,
90 homepage: Option<String>,
91 dev_repo: Option<String>,
92 bug_reports: Option<String>,
93 src: Option<String>,
94 authors: Vec<String>,
95 maintainers: Vec<String>,
96 license: Option<String>,
97 sha1: Option<Sha1Digest>,
98 md5: Option<Md5Digest>,
99 sha256: Option<Sha256Digest>,
100 sha512: Option<Sha512Digest>,
101 dependencies: Vec<(String, String)>, }
103
104fn default_package_data() -> PackageData {
105 PackageData {
106 package_type: Some(OpamParser::PACKAGE_TYPE),
107 primary_language: Some("Ocaml".to_string()),
108 datasource_id: Some(DatasourceId::OpamFile),
109 ..Default::default()
110 }
111}
112
113fn parse_opam(text: &str) -> PackageData {
115 let opam_data = parse_opam_data(text);
116
117 let description = build_description(&opam_data.synopsis, &opam_data.description);
118 let parties = extract_parties(&opam_data.authors, &opam_data.maintainers);
119 let dependencies = extract_dependencies(&opam_data.dependencies);
120
121 let (repository_homepage_url, api_data_url, purl) =
122 build_opam_urls(&opam_data.name, &opam_data.version);
123 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
124 normalize_opam_declared_license(opam_data.license.as_deref());
125
126 PackageData {
127 package_type: Some(OpamParser::PACKAGE_TYPE),
128 namespace: None,
129 name: opam_data.name,
130 version: opam_data.version,
131 qualifiers: None,
132 subpath: None,
133 primary_language: Some("Ocaml".to_string()),
134 description,
135 release_date: None,
136 parties,
137 keywords: Vec::new(),
138 homepage_url: opam_data.homepage,
139 download_url: opam_data.src,
140 size: None,
141 sha1: opam_data.sha1,
142 md5: opam_data.md5,
143 sha256: opam_data.sha256,
144 sha512: opam_data.sha512,
145 bug_tracking_url: opam_data.bug_reports,
146 code_view_url: None,
147 vcs_url: opam_data.dev_repo,
148 copyright: None,
149 holder: None,
150 declared_license_expression,
151 declared_license_expression_spdx,
152 license_detections,
153 other_license_expression: None,
154 other_license_expression_spdx: None,
155 other_license_detections: Vec::new(),
156 extracted_license_statement: opam_data.license,
157 notice_text: None,
158 source_packages: Vec::new(),
159 file_references: Vec::new(),
160 is_private: false,
161 is_virtual: false,
162 extra_data: None,
163 dependencies,
164 repository_homepage_url,
165 repository_download_url: None,
166 api_data_url,
167 datasource_id: Some(DatasourceId::OpamFile),
168 purl,
169 }
170}
171
172fn normalize_opam_declared_license(
173 statement: Option<&str>,
174) -> (
175 Option<String>,
176 Option<String>,
177 Vec<crate::models::LicenseDetection>,
178) {
179 let Some(statement) = statement.map(str::trim).filter(|value| !value.is_empty()) else {
180 return super::license_normalization::empty_declared_license_data();
181 };
182
183 match statement {
184 "GPL-2.0-only" => build_declared_license_data_from_pair(
185 "gpl-2.0",
186 "GPL-2.0-only",
187 DeclaredLicenseMatchMetadata::single_line(statement),
188 ),
189 "GPL-3.0-only" => build_declared_license_data_from_pair(
190 "gpl-3.0",
191 "GPL-3.0-only",
192 DeclaredLicenseMatchMetadata::single_line(statement),
193 ),
194 "LGPL-3.0-only with OCaml-LGPL-linking-exception" => build_declared_license_data_from_pair(
195 "lgpl-3.0 WITH ocaml-lgpl-linking-exception",
196 "LGPL-3.0-only WITH OCaml-LGPL-linking-exception",
197 DeclaredLicenseMatchMetadata::single_line(statement),
198 ),
199 _ => normalize_spdx_declared_license(Some(statement)),
200 }
201}
202
203fn build_opam_urls(
204 name: &Option<String>,
205 version: &Option<String>,
206) -> (Option<String>, Option<String>, Option<String>) {
207 let repository_homepage_url = name
208 .as_ref()
209 .map(|n| format!("https://opam.ocaml.org/packages/{}", n));
210
211 let api_data_url = match (name, version) {
212 (Some(n), Some(v)) => Some(format!(
213 "https://github.com/ocaml/opam-repository/blob/master/packages/{}/{}.{}/opam",
214 n, n, v
215 )),
216 _ => None,
217 };
218
219 let purl = match (name, version) {
220 (Some(n), Some(v)) => Some(format!("pkg:opam/{}@{}", n, v)),
221 (Some(n), None) => Some(format!("pkg:opam/{}", n)),
222 _ => None,
223 };
224
225 (repository_homepage_url, api_data_url, purl)
226}
227
228fn parse_opam_data(text: &str) -> OpamData {
230 let mut data = OpamData::default();
231 let lines: Vec<&str> = text.lines().collect();
232 let mut i = 0;
233 let mut iteration_count: usize = 0;
234
235 while i < lines.len() {
236 iteration_count += 1;
237 if iteration_count > MAX_ITERATION_COUNT {
238 warn!("parse_opam_data: exceeded MAX_ITERATION_COUNT, breaking");
239 break;
240 }
241 let line = lines[i];
242
243 if let Some((key, value)) = parse_key_value(line) {
245 match key.as_str() {
246 "name" => data.name = clean_value(&value),
247 "version" => data.version = clean_value(&value),
248 "synopsis" => data.synopsis = clean_value(&value),
249 "description" => {
250 data.description = parse_description_field(&lines, &mut i, &value);
251 }
252 "homepage" => data.homepage = clean_value(&value),
253 "dev-repo" => data.dev_repo = clean_value(&value),
254 "bug-reports" => data.bug_reports = clean_value(&value),
255 "src" => {
256 if value.trim().is_empty() && i + 1 < lines.len() {
257 i += 1;
258 data.src = clean_value(lines[i]);
259 } else {
260 data.src = clean_value(&value);
261 }
262 }
263 "license" => data.license = clean_value(&value),
264 "authors" => {
265 data.authors = parse_string_array(&lines, &mut i, &value);
266 }
267 "maintainer" => {
268 data.maintainers = parse_string_array(&lines, &mut i, &value);
269 }
270 "depends" => {
271 data.dependencies = parse_dependency_array(&lines, &mut i);
272 }
273 "checksum" => {
274 parse_checksums(&lines, &mut i, &mut data);
275 }
276 _ => {}
277 }
278 }
279
280 i += 1;
281 }
282
283 data
284}
285
286fn parse_key_value(line: &str) -> Option<(String, String)> {
288 let line = line.trim();
289 if line.is_empty() || line.starts_with('#') {
290 return None;
291 }
292
293 if let Some(colon_pos) = line.find(':') {
294 let key = line[..colon_pos].trim().to_string();
295 let value = line[colon_pos + 1..].trim().to_string();
296 Some((key, value))
297 } else {
298 None
299 }
300}
301
302fn clean_value(value: &str) -> Option<String> {
304 let cleaned = value
305 .trim()
306 .trim_matches('"')
307 .trim_matches('[')
308 .trim_matches(']')
309 .trim();
310
311 if cleaned.is_empty() {
312 None
313 } else {
314 Some(truncate_field(cleaned.to_string()))
315 }
316}
317
318fn parse_description_field(lines: &[&str], i: &mut usize, first_value: &str) -> Option<String> {
323 let trimmed = first_value.trim();
324
325 if trimmed.is_empty() {
326 let next_trimmed = lines.get(*i + 1)?.trim();
327
328 if next_trimmed.starts_with("\"\"\"") {
329 *i += 1;
330 return parse_triple_quoted_string(lines, i, next_trimmed);
331 }
332
333 if next_trimmed.starts_with('"') {
334 *i += 1;
335 return clean_value(next_trimmed);
336 }
337
338 return None;
339 }
340
341 if trimmed.starts_with("\"\"\"") {
342 return parse_triple_quoted_string(lines, i, trimmed);
343 }
344
345 clean_value(trimmed)
346}
347
348fn parse_triple_quoted_string(lines: &[&str], i: &mut usize, first_value: &str) -> Option<String> {
350 let mut result = String::new();
351 let mut iteration_count: usize = 0;
352
353 let first_content = first_value.trim().trim_start_matches("\"\"\"");
354 if let Some(end_index) = first_content.find("\"\"\"") {
355 let cleaned = first_content[..end_index].trim();
356 return (!cleaned.is_empty()).then(|| truncate_field(cleaned.to_string()));
357 }
358
359 if !first_content.trim().is_empty() {
360 result.push_str(first_content.trim());
361 }
362
363 *i += 1;
364 while *i < lines.len() {
365 iteration_count += 1;
366 if iteration_count > MAX_ITERATION_COUNT {
367 warn!("parse_multiline_string: exceeded MAX_ITERATION_COUNT, breaking");
368 break;
369 }
370 let line = lines[*i].trim();
371
372 if let Some(end_index) = line.find("\"\"\"") {
373 let before_end = line[..end_index].trim();
374 if !before_end.is_empty() {
375 if !result.is_empty() {
376 result.push(' ');
377 }
378 result.push_str(before_end);
379 }
380 break;
381 }
382
383 let content = line.trim_matches('"').trim();
384 if !result.is_empty() {
385 result.push(' ');
386 }
387 result.push_str(content);
388 *i += 1;
389 }
390
391 let cleaned = result.trim().to_string();
392 if cleaned.is_empty() {
393 None
394 } else {
395 Some(truncate_field(cleaned))
396 }
397}
398
399fn parse_string_array(lines: &[&str], i: &mut usize, first_value: &str) -> Vec<String> {
401 let mut result = Vec::new();
402 let mut iteration_count: usize = 0;
403
404 let mut content = first_value.to_string();
405
406 if content.contains('[') && !content.contains(']') {
407 *i += 1;
408 while *i < lines.len() {
409 iteration_count += 1;
410 if iteration_count > MAX_ITERATION_COUNT {
411 warn!("parse_string_array: exceeded MAX_ITERATION_COUNT, breaking");
412 break;
413 }
414 let line = lines[*i];
415 content.push(' ');
416 content.push_str(line);
417
418 if line.contains(']') {
419 break;
420 }
421 *i += 1;
422 }
423 }
424
425 let cleaned = content.trim_matches('[').trim_matches(']').trim();
426
427 for part in split_quoted_strings(cleaned) {
428 let p = part.trim_matches('"').trim();
429 if !p.is_empty() {
430 result.push(truncate_field(p.to_string()));
431 }
432 }
433
434 result
435}
436
437fn parse_dependency_array(lines: &[&str], i: &mut usize) -> Vec<(String, String)> {
439 let mut result = Vec::new();
440 let mut iteration_count: usize = 0;
441
442 *i += 1;
443 while *i < lines.len() {
444 iteration_count += 1;
445 if iteration_count > MAX_ITERATION_COUNT {
446 warn!("parse_dependency_array: exceeded MAX_ITERATION_COUNT, breaking");
447 break;
448 }
449 let line = lines[*i];
450
451 if line.trim().contains(']') {
452 break;
453 }
454
455 if let Some((name, version)) = parse_dependency_line(line) {
456 result.push((name, version));
457 }
458
459 *i += 1;
460 }
461
462 result
463}
464
465fn parse_dependency_line(line: &str) -> Option<(String, String)> {
467 let line = line.trim();
468 if line.is_empty() {
469 return None;
470 }
471
472 let regex = Regex::new(r#""([^"]+)"\s*(.*)$"#).ok()?;
474 let caps = regex.captures(line)?;
475
476 let name = truncate_field(caps.get(1)?.as_str().to_string());
477 let version_part = caps.get(2)?.as_str().trim();
478
479 let constraint = if version_part.is_empty() {
481 String::new()
482 } else {
483 truncate_field(extract_version_constraint(version_part))
484 };
485
486 Some((name, constraint))
487}
488
489fn extract_version_constraint(version_part: &str) -> String {
491 let regex = Regex::new(r#"\{\s*([<>=!]+)\s*"([^"]*)"\s*\}"#);
492 if let Ok(re) = regex
493 && let Some(caps) = re.captures(version_part)
494 {
495 let op = caps.get(1).map(|m| m.as_str()).unwrap_or("");
496 let ver = caps.get(2).map(|m| m.as_str()).unwrap_or("");
497 if !op.is_empty() && !ver.is_empty() {
498 return format!("{} {}", op, ver);
499 }
500 }
501
502 let content = version_part
504 .trim_matches('{')
505 .trim_matches('}')
506 .trim_matches('"')
507 .trim();
508
509 content.replace('"', "")
510}
511
512fn parse_checksums(lines: &[&str], i: &mut usize, data: &mut OpamData) {
514 if let Some((_, first_value)) = parse_key_value(lines[*i]) {
515 let inline = first_value.trim();
516 if !inline.is_empty() && inline != "[" {
517 if let Some((key, value)) = parse_checksum_line(inline) {
518 match key.as_str() {
519 "sha1" => data.sha1 = Sha1Digest::from_hex(&value).ok(),
520 "md5" => data.md5 = Md5Digest::from_hex(&value).ok(),
521 "sha256" => data.sha256 = Sha256Digest::from_hex(&value).ok(),
522 "sha512" => data.sha512 = Sha512Digest::from_hex(&value).ok(),
523 _ => {}
524 }
525 }
526 return;
527 }
528 }
529
530 let mut iteration_count: usize = 0;
531 *i += 1;
532 while *i < lines.len() {
533 iteration_count += 1;
534 if iteration_count > MAX_ITERATION_COUNT {
535 warn!("parse_checksums: exceeded MAX_ITERATION_COUNT, breaking");
536 break;
537 }
538 let line = lines[*i];
539
540 if line.trim().contains(']') {
541 break;
542 }
543
544 if let Some((key, value)) = parse_checksum_line(line) {
545 match key.as_str() {
546 "sha1" => data.sha1 = Sha1Digest::from_hex(&value).ok(),
547 "md5" => data.md5 = Md5Digest::from_hex(&value).ok(),
548 "sha256" => data.sha256 = Sha256Digest::from_hex(&value).ok(),
549 "sha512" => data.sha512 = Sha512Digest::from_hex(&value).ok(),
550 _ => {}
551 }
552 }
553
554 *i += 1;
555 }
556}
557
558fn parse_checksum_line(line: &str) -> Option<(String, String)> {
560 let line = line.trim().trim_matches('"').trim();
561
562 let regex = Regex::new(r"^(\w+)\s*=\s*(.+)$").ok()?;
563 let caps = regex.captures(line)?;
564
565 let key = caps.get(1)?.as_str().to_string();
566 let value = caps.get(2)?.as_str().to_string();
567
568 Some((key, value))
569}
570
571fn split_quoted_strings(content: &str) -> Vec<String> {
573 let mut result = Vec::new();
574 let mut current = String::new();
575 let mut in_quotes = false;
576
577 for ch in content.chars() {
578 match ch {
579 '"' => in_quotes = !in_quotes,
580 ' ' if !in_quotes => {
581 if !current.is_empty() {
582 result.push(current.trim_matches('"').to_string());
583 current.clear();
584 }
585 }
586 _ => current.push(ch),
587 }
588 }
589
590 if !current.is_empty() {
591 result.push(current.trim_matches('"').to_string());
592 }
593
594 result
595}
596
597fn build_description(synopsis: &Option<String>, description: &Option<String>) -> Option<String> {
599 let parts: Vec<&str> = vec![synopsis.as_deref(), description.as_deref()]
600 .into_iter()
601 .filter(|p| p.is_some())
602 .flatten()
603 .collect();
604
605 if parts.is_empty() {
606 None
607 } else {
608 Some(parts.join("\n"))
609 }
610}
611
612fn extract_parties(authors: &[String], maintainers: &[String]) -> Vec<Party> {
614 let mut parties = Vec::new();
615
616 for author in authors {
618 parties.push(Party {
619 r#type: Some(PartyType::Person),
620 role: Some("author".to_string()),
621 name: Some(truncate_field(author.clone())),
622 email: None,
623 url: None,
624 organization: None,
625 organization_url: None,
626 timezone: None,
627 });
628 }
629
630 for maintainer in maintainers {
632 parties.push(Party {
633 r#type: Some(PartyType::Person),
634 role: Some("maintainer".to_string()),
635 name: None,
636 email: Some(truncate_field(maintainer.clone())),
637 url: None,
638 organization: None,
639 organization_url: None,
640 timezone: None,
641 });
642 }
643
644 parties
645}
646
647fn extract_dependencies(deps: &[(String, String)]) -> Vec<Dependency> {
649 deps.iter()
650 .map(|(name, version_constraint)| Dependency {
651 purl: Some(truncate_field(format!("pkg:opam/{}", name))),
652 extracted_requirement: Some(truncate_field(version_constraint.clone())),
653 scope: Some("dependency".to_string()),
654 is_runtime: Some(true),
655 is_optional: Some(false),
656 is_pinned: Some(false),
657 is_direct: Some(true),
658 resolved_package: None,
659 extra_data: None,
660 })
661 .collect()
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667 use crate::parsers::PackageParser;
668
669 #[test]
670 fn test_is_match_with_opam_extension() {
671 let path = Path::new("sample.opam");
672 assert!(OpamParser::is_match(path));
673 }
674
675 #[test]
676 fn test_is_match_with_opam_name() {
677 let path = Path::new("opam");
678 assert!(OpamParser::is_match(path));
679 }
680
681 #[test]
682 fn test_is_match_with_non_opam() {
683 let path = Path::new("sample.txt");
684 assert!(!OpamParser::is_match(path));
685 }
686
687 #[test]
688 fn test_parse_key_value() {
689 let (key, value) = parse_key_value("name: \"js_of_ocaml\"").unwrap();
690 assert_eq!(key, "name");
691 assert_eq!(value, "\"js_of_ocaml\"");
692 }
693
694 #[test]
695 fn test_clean_value() {
696 assert_eq!(
697 clean_value("\"js_of_ocaml\""),
698 Some("js_of_ocaml".to_string())
699 );
700 assert_eq!(clean_value("\"\""), None);
701 }
702
703 #[test]
704 fn test_extract_version_constraint() {
705 let result = extract_version_constraint(r#"{>= "4.02.0"}"#);
706 assert_eq!(result, ">= 4.02.0");
707 }
708
709 #[test]
710 fn test_parse_dependency_line() {
711 let (name, version) = parse_dependency_line(r#""ocaml" {>= "4.02.0"}"#).unwrap();
712 assert_eq!(name, "ocaml");
713 assert_eq!(version, ">= 4.02.0");
714 }
715
716 #[test]
717 fn test_parse_dependency_line_without_version() {
718 let (name, version) = parse_dependency_line(r#""uchar""#).unwrap();
719 assert_eq!(name, "uchar");
720 assert_eq!(version, "");
721 }
722
723 #[test]
724 fn test_split_quoted_strings() {
725 let parts = split_quoted_strings(r#""str1" "str2""#);
726 assert_eq!(parts, vec!["str1", "str2"]);
727 }
728
729 #[test]
730 fn test_build_description() {
731 let synopsis = Some("Short description".to_string());
732 let description = Some("Long description".to_string());
733 let result = build_description(&synopsis, &description);
734 assert_eq!(
735 result,
736 Some("Short description\nLong description".to_string())
737 );
738 }
739
740 #[test]
741 fn test_parse_opam_keeps_fields_after_single_line_description() {
742 let package = parse_opam(
743 r#"opam-version: "2.0"
744name: "dune-rpc"
745version: "3.23.0"
746description: "Library to connect and control a running dune instance"
747maintainer: ["Jane Street Group, LLC <opensource@janestreet.com>"]
748authors: ["Jane Street Group, LLC <opensource@janestreet.com>"]
749license: "MIT"
750homepage: "https://github.com/ocaml/dune"
751bug-reports: "https://github.com/ocaml/dune/issues"
752depends: [
753 "dune" {>= "3.23"}
754 "ocamlc-loc"
755 "stdune" {= version}
756 "odoc" {with-doc}
757]
758dev-repo: "git+https://github.com/ocaml/dune.git"
759"#,
760 );
761
762 assert_eq!(package.name.as_deref(), Some("dune-rpc"));
763 assert_eq!(package.version.as_deref(), Some("3.23.0"));
764 assert_eq!(
765 package.description.as_deref(),
766 Some("Library to connect and control a running dune instance")
767 );
768 assert_eq!(
769 package.homepage_url.as_deref(),
770 Some("https://github.com/ocaml/dune")
771 );
772 assert_eq!(
773 package.bug_tracking_url.as_deref(),
774 Some("https://github.com/ocaml/dune/issues")
775 );
776 assert_eq!(
777 package.vcs_url.as_deref(),
778 Some("git+https://github.com/ocaml/dune.git")
779 );
780 assert_eq!(
781 package.declared_license_expression_spdx.as_deref(),
782 Some("MIT")
783 );
784 assert_eq!(package.dependencies.len(), 4);
785 assert_eq!(
786 package.dependencies[0].purl.as_deref(),
787 Some("pkg:opam/dune")
788 );
789 assert_eq!(
790 package.dependencies[0].extracted_requirement.as_deref(),
791 Some(">= 3.23")
792 );
793 assert_eq!(
794 package.dependencies[2].extracted_requirement.as_deref(),
795 Some("= version")
796 );
797 assert_eq!(
798 package.dependencies[3].extracted_requirement.as_deref(),
799 Some("with-doc")
800 );
801 }
802
803 #[test]
804 fn test_parse_opam_keeps_fields_after_next_line_description() {
805 let package = parse_opam(
806 r#"opam-version: "2.0"
807name: "chrome-trace"
808version: "3.23.0"
809description:
810 "This library offers no backwards compatibility guarantees. Use at your own risk."
811maintainer: ["Jane Street Group, LLC <opensource@janestreet.com>"]
812license: "MIT"
813depends: [
814 "dune" {>= "3.23"}
815 "ocaml" {>= "4.14"}
816 "odoc" {with-doc}
817]
818dev-repo: "git+https://github.com/ocaml/dune.git"
819"#,
820 );
821
822 assert_eq!(package.name.as_deref(), Some("chrome-trace"));
823 assert_eq!(
824 package.description.as_deref(),
825 Some(
826 "This library offers no backwards compatibility guarantees. Use at your own risk."
827 )
828 );
829 assert_eq!(
830 package.vcs_url.as_deref(),
831 Some("git+https://github.com/ocaml/dune.git")
832 );
833 assert_eq!(package.dependencies.len(), 3);
834 assert_eq!(
835 package.dependencies[1].purl.as_deref(),
836 Some("pkg:opam/ocaml")
837 );
838 assert_eq!(
839 package.dependencies[1].extracted_requirement.as_deref(),
840 Some(">= 4.14")
841 );
842 assert_eq!(
843 package.dependencies[2].extracted_requirement.as_deref(),
844 Some("with-doc")
845 );
846 }
847
848 #[test]
849 fn test_extract_parties() {
850 let authors = vec!["Author One".to_string()];
851 let maintainers = vec!["maintainer@example.com".to_string()];
852 let parties = extract_parties(&authors, &maintainers);
853
854 assert_eq!(parties.len(), 2);
855 assert_eq!(parties[0].name, Some("Author One".to_string()));
856 assert_eq!(parties[0].role, Some("author".to_string()));
857 assert_eq!(parties[1].email, Some("maintainer@example.com".to_string()));
858 assert_eq!(parties[1].role, Some("maintainer".to_string()));
859 }
860
861 #[test]
862 fn test_normalize_opam_declared_license_preserves_scancode_style_expression() {
863 let (declared, declared_spdx, detections) = normalize_opam_declared_license(Some(
864 "LGPL-3.0-only with OCaml-LGPL-linking-exception",
865 ));
866
867 assert_eq!(
868 declared.as_deref(),
869 Some("lgpl-3.0 WITH ocaml-lgpl-linking-exception")
870 );
871 assert_eq!(
872 declared_spdx.as_deref(),
873 Some("LGPL-3.0-only WITH OCaml-LGPL-linking-exception")
874 );
875 assert_eq!(detections.len(), 1);
876 assert_eq!(
877 detections[0].license_expression,
878 "lgpl-3.0 WITH ocaml-lgpl-linking-exception"
879 );
880 }
881}