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