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