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 = name
232 .as_deref()
233 .and_then(|n| crate::parsers::utils::simple_purl("opam", n, version.as_deref()));
234
235 (repository_homepage_url, api_data_url, purl)
236}
237
238fn parse_opam_data(text: &str) -> OpamData {
240 let mut data = OpamData::default();
241 let lines: Vec<&str> = text.lines().collect();
242 let mut i = 0;
243 let mut iteration_count: usize = 0;
244
245 while i < lines.len() {
246 iteration_count += 1;
247 if iteration_count > MAX_ITERATION_COUNT {
248 warn!("parse_opam_data: exceeded MAX_ITERATION_COUNT, breaking");
249 break;
250 }
251 let line = lines[i];
252
253 if let Some((key, value)) = parse_key_value(line) {
255 match key.as_str() {
256 "name" => data.name = clean_value(&value),
257 "version" => data.version = clean_value(&value),
258 "synopsis" => data.synopsis = clean_value(&value),
259 "description" => {
260 data.description = parse_description_field(&lines, &mut i, &value);
261 }
262 "homepage" => data.homepage = clean_value(&value),
263 "dev-repo" => data.dev_repo = clean_value(&value),
264 "bug-reports" => data.bug_reports = clean_value(&value),
265 "src" => {
266 if value.trim().is_empty() && i + 1 < lines.len() {
267 i += 1;
268 data.src = clean_value(lines[i]);
269 } else {
270 data.src = clean_value(&value);
271 }
272 }
273 "license" => data.license = clean_value(&value),
274 "authors" => {
275 data.authors = parse_string_array(&lines, &mut i, &value);
276 }
277 "maintainer" => {
278 data.maintainers = parse_string_array(&lines, &mut i, &value);
279 }
280 "depends" => {
281 data.dependencies = parse_dependency_array(&lines, &mut i);
282 }
283 "checksum" => {
284 parse_checksums(&lines, &mut i, &mut data);
285 }
286 _ => {}
287 }
288 }
289
290 i += 1;
291 }
292
293 data
294}
295
296fn parse_key_value(line: &str) -> Option<(String, String)> {
298 let line = line.trim();
299 if line.is_empty() || line.starts_with('#') {
300 return None;
301 }
302
303 if let Some(colon_pos) = line.find(':') {
304 let key = line[..colon_pos].trim().to_string();
305 let value = line[colon_pos + 1..].trim().to_string();
306 Some((key, value))
307 } else {
308 None
309 }
310}
311
312fn clean_value(value: &str) -> Option<String> {
314 let cleaned = value
315 .trim()
316 .trim_matches('"')
317 .trim_matches('[')
318 .trim_matches(']')
319 .trim();
320
321 if cleaned.is_empty() {
322 None
323 } else {
324 Some(truncate_field(cleaned.to_string()))
325 }
326}
327
328fn parse_description_field(lines: &[&str], i: &mut usize, first_value: &str) -> Option<String> {
333 let trimmed = first_value.trim();
334
335 if trimmed.is_empty() {
336 let next_trimmed = lines.get(*i + 1)?.trim();
337
338 if next_trimmed.starts_with("\"\"\"") {
339 *i += 1;
340 return parse_triple_quoted_string(lines, i, next_trimmed);
341 }
342
343 if next_trimmed.starts_with('"') {
344 *i += 1;
345 return clean_value(next_trimmed);
346 }
347
348 return None;
349 }
350
351 if trimmed.starts_with("\"\"\"") {
352 return parse_triple_quoted_string(lines, i, trimmed);
353 }
354
355 clean_value(trimmed)
356}
357
358fn parse_triple_quoted_string(lines: &[&str], i: &mut usize, first_value: &str) -> Option<String> {
360 let mut result = String::new();
361 let mut iteration_count: usize = 0;
362
363 let first_content = first_value.trim().trim_start_matches("\"\"\"");
364 if let Some(end_index) = first_content.find("\"\"\"") {
365 let cleaned = first_content[..end_index].trim();
366 return (!cleaned.is_empty()).then(|| truncate_field(cleaned.to_string()));
367 }
368
369 if !first_content.trim().is_empty() {
370 result.push_str(first_content.trim());
371 }
372
373 *i += 1;
374 while *i < lines.len() {
375 iteration_count += 1;
376 if iteration_count > MAX_ITERATION_COUNT {
377 warn!("parse_multiline_string: exceeded MAX_ITERATION_COUNT, breaking");
378 break;
379 }
380 let line = lines[*i].trim();
381
382 if let Some(end_index) = line.find("\"\"\"") {
383 let before_end = line[..end_index].trim();
384 if !before_end.is_empty() {
385 if !result.is_empty() {
386 result.push(' ');
387 }
388 result.push_str(before_end);
389 }
390 break;
391 }
392
393 let content = line.trim_matches('"').trim();
394 if !result.is_empty() {
395 result.push(' ');
396 }
397 result.push_str(content);
398 *i += 1;
399 }
400
401 let cleaned = result.trim().to_string();
402 if cleaned.is_empty() {
403 None
404 } else {
405 Some(truncate_field(cleaned))
406 }
407}
408
409fn parse_string_array(lines: &[&str], i: &mut usize, first_value: &str) -> Vec<String> {
411 let mut result = Vec::new();
412 let mut iteration_count: usize = 0;
413
414 let mut content = first_value.to_string();
415
416 if content.contains('[') && !content.contains(']') {
417 *i += 1;
418 while *i < lines.len() {
419 iteration_count += 1;
420 if iteration_count > MAX_ITERATION_COUNT {
421 warn!("parse_string_array: exceeded MAX_ITERATION_COUNT, breaking");
422 break;
423 }
424 let line = lines[*i];
425 content.push(' ');
426 content.push_str(line);
427
428 if line.contains(']') {
429 break;
430 }
431 *i += 1;
432 }
433 }
434
435 let cleaned = content.trim_matches('[').trim_matches(']').trim();
436
437 for part in split_quoted_strings(cleaned) {
438 let p = part.trim_matches('"').trim();
439 if !p.is_empty() {
440 result.push(truncate_field(p.to_string()));
441 }
442 }
443
444 result
445}
446
447fn parse_dependency_array(lines: &[&str], i: &mut usize) -> Vec<(String, String)> {
449 let mut result = Vec::new();
450 let mut iteration_count: usize = 0;
451
452 *i += 1;
453 while *i < lines.len() {
454 iteration_count += 1;
455 if iteration_count > MAX_ITERATION_COUNT {
456 warn!("parse_dependency_array: exceeded MAX_ITERATION_COUNT, breaking");
457 break;
458 }
459 let line = lines[*i];
460
461 if line.trim().contains(']') {
462 break;
463 }
464
465 if let Some((name, version)) = parse_dependency_line(line) {
466 result.push((name, version));
467 }
468
469 *i += 1;
470 }
471
472 result
473}
474
475fn parse_dependency_line(line: &str) -> Option<(String, String)> {
477 let line = line.trim();
478 if line.is_empty() {
479 return None;
480 }
481
482 let regex = Regex::new(r#""([^"]+)"\s*(.*)$"#).ok()?;
484 let caps = regex.captures(line)?;
485
486 let name = truncate_field(caps.get(1)?.as_str().to_string());
487 let version_part = caps.get(2)?.as_str().trim();
488
489 let constraint = if version_part.is_empty() {
491 String::new()
492 } else {
493 truncate_field(extract_version_constraint(version_part))
494 };
495
496 Some((name, constraint))
497}
498
499fn extract_version_constraint(version_part: &str) -> String {
501 let regex = Regex::new(r#"\{\s*([<>=!]+)\s*"([^"]*)"\s*\}"#);
502 if let Ok(re) = regex
503 && let Some(caps) = re.captures(version_part)
504 {
505 let op = caps.get(1).map(|m| m.as_str()).unwrap_or("");
506 let ver = caps.get(2).map(|m| m.as_str()).unwrap_or("");
507 if !op.is_empty() && !ver.is_empty() {
508 return format!("{} {}", op, ver);
509 }
510 }
511
512 let content = version_part
514 .trim_matches('{')
515 .trim_matches('}')
516 .trim_matches('"')
517 .trim();
518
519 content.replace('"', "")
520}
521
522fn parse_checksums(lines: &[&str], i: &mut usize, data: &mut OpamData) {
524 if let Some((_, first_value)) = parse_key_value(lines[*i]) {
525 let inline = first_value.trim();
526 if !inline.is_empty() && inline != "[" {
527 if let Some((key, value)) = parse_checksum_line(inline) {
528 match key.as_str() {
529 "sha1" => data.sha1 = Sha1Digest::from_hex(&value).ok(),
530 "md5" => data.md5 = Md5Digest::from_hex(&value).ok(),
531 "sha256" => data.sha256 = Sha256Digest::from_hex(&value).ok(),
532 "sha512" => data.sha512 = Sha512Digest::from_hex(&value).ok(),
533 _ => {}
534 }
535 }
536 return;
537 }
538 }
539
540 let mut iteration_count: usize = 0;
541 *i += 1;
542 while *i < lines.len() {
543 iteration_count += 1;
544 if iteration_count > MAX_ITERATION_COUNT {
545 warn!("parse_checksums: exceeded MAX_ITERATION_COUNT, breaking");
546 break;
547 }
548 let line = lines[*i];
549
550 if line.trim().contains(']') {
551 break;
552 }
553
554 if let Some((key, value)) = parse_checksum_line(line) {
555 match key.as_str() {
556 "sha1" => data.sha1 = Sha1Digest::from_hex(&value).ok(),
557 "md5" => data.md5 = Md5Digest::from_hex(&value).ok(),
558 "sha256" => data.sha256 = Sha256Digest::from_hex(&value).ok(),
559 "sha512" => data.sha512 = Sha512Digest::from_hex(&value).ok(),
560 _ => {}
561 }
562 }
563
564 *i += 1;
565 }
566}
567
568fn parse_checksum_line(line: &str) -> Option<(String, String)> {
570 let line = line.trim().trim_matches('"').trim();
571
572 let regex = Regex::new(r"^(\w+)\s*=\s*(.+)$").ok()?;
573 let caps = regex.captures(line)?;
574
575 let key = caps.get(1)?.as_str().to_string();
576 let value = caps.get(2)?.as_str().to_string();
577
578 Some((key, value))
579}
580
581fn split_quoted_strings(content: &str) -> Vec<String> {
583 let mut result = Vec::new();
584 let mut current = String::new();
585 let mut in_quotes = false;
586
587 for ch in content.chars() {
588 match ch {
589 '"' => in_quotes = !in_quotes,
590 ' ' if !in_quotes => {
591 if !current.is_empty() {
592 result.push(current.trim_matches('"').to_string());
593 current.clear();
594 }
595 }
596 _ => current.push(ch),
597 }
598 }
599
600 if !current.is_empty() {
601 result.push(current.trim_matches('"').to_string());
602 }
603
604 result
605}
606
607fn build_description(synopsis: &Option<String>, description: &Option<String>) -> Option<String> {
609 let parts: Vec<&str> = vec![synopsis.as_deref(), description.as_deref()]
610 .into_iter()
611 .filter(|p| p.is_some())
612 .flatten()
613 .collect();
614
615 if parts.is_empty() {
616 None
617 } else {
618 Some(parts.join("\n"))
619 }
620}
621
622fn extract_parties(authors: &[String], maintainers: &[String]) -> Vec<Party> {
624 let mut parties = Vec::new();
625
626 for author in authors {
628 parties.push(Party {
629 r#type: Some(PartyType::Person),
630 role: Some("author".to_string()),
631 name: Some(truncate_field(author.clone())),
632 email: None,
633 url: None,
634 organization: None,
635 organization_url: None,
636 timezone: None,
637 });
638 }
639
640 for maintainer in maintainers {
642 parties.push(Party {
643 r#type: Some(PartyType::Person),
644 role: Some("maintainer".to_string()),
645 name: None,
646 email: Some(truncate_field(maintainer.clone())),
647 url: None,
648 organization: None,
649 organization_url: None,
650 timezone: None,
651 });
652 }
653
654 parties
655}
656
657fn extract_dependencies(deps: &[(String, String)]) -> Vec<Dependency> {
659 deps.iter()
660 .map(|(name, version_constraint)| Dependency {
661 purl: crate::parsers::utils::simple_purl("opam", name, None),
662 extracted_requirement: Some(truncate_field(version_constraint.clone())),
663 scope: Some("dependency".to_string()),
664 is_runtime: Some(true),
665 is_optional: Some(false),
666 is_pinned: Some(false),
667 is_direct: Some(true),
668 resolved_package: None,
669 extra_data: None,
670 })
671 .collect()
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677 use crate::parsers::PackageParser;
678
679 #[test]
680 fn test_is_match_with_opam_extension() {
681 let path = Path::new("sample.opam");
682 assert!(OpamParser::is_match(path));
683 }
684
685 #[test]
686 fn test_is_match_with_opam_name() {
687 let path = Path::new("opam");
688 assert!(OpamParser::is_match(path));
689 }
690
691 #[test]
692 fn test_is_match_with_non_opam() {
693 let path = Path::new("sample.txt");
694 assert!(!OpamParser::is_match(path));
695 }
696
697 #[test]
698 fn test_opam_purls_are_encoded_rather_than_formatted() {
699 use std::str::FromStr;
704
705 for (name, version, expected) in [
706 ("conf gmp", None, "pkg:opam/conf%20gmp"),
707 ("ocaml/evil", None, "pkg:opam/ocaml%2Fevil"),
708 ("sharp#frag", None, "pkg:opam/sharp%23frag"),
709 ("pct%20", None, "pkg:opam/pct%2520"),
712 ("my pkg", Some("1.0 beta"), "pkg:opam/my%20pkg@1.0%20beta"),
713 ] {
714 let purl = crate::parsers::utils::simple_purl("opam", name, version)
715 .expect("a non-empty name should yield a purl");
716 assert_eq!(purl, expected);
717
718 let parsed = packageurl::PackageUrl::from_str(&purl).expect("purl should parse");
719 assert_eq!(parsed.name(), name);
720 assert_eq!(parsed.namespace(), None);
721 assert_eq!(parsed.subpath(), None);
722 assert_eq!(parsed.version(), version);
723 assert_eq!(parsed.to_string(), purl, "purl should round-trip");
724 }
725
726 assert_eq!(
727 crate::parsers::utils::simple_purl("opam", " ", None),
728 None
729 );
730 }
731
732 #[test]
733 fn test_parse_key_value() {
734 let (key, value) = parse_key_value("name: \"js_of_ocaml\"").unwrap();
735 assert_eq!(key, "name");
736 assert_eq!(value, "\"js_of_ocaml\"");
737 }
738
739 #[test]
740 fn test_clean_value() {
741 assert_eq!(
742 clean_value("\"js_of_ocaml\""),
743 Some("js_of_ocaml".to_string())
744 );
745 assert_eq!(clean_value("\"\""), None);
746 }
747
748 #[test]
749 fn test_extract_version_constraint() {
750 let result = extract_version_constraint(r#"{>= "4.02.0"}"#);
751 assert_eq!(result, ">= 4.02.0");
752 }
753
754 #[test]
755 fn test_parse_dependency_line() {
756 let (name, version) = parse_dependency_line(r#""ocaml" {>= "4.02.0"}"#).unwrap();
757 assert_eq!(name, "ocaml");
758 assert_eq!(version, ">= 4.02.0");
759 }
760
761 #[test]
762 fn test_parse_dependency_line_without_version() {
763 let (name, version) = parse_dependency_line(r#""uchar""#).unwrap();
764 assert_eq!(name, "uchar");
765 assert_eq!(version, "");
766 }
767
768 #[test]
769 fn test_split_quoted_strings() {
770 let parts = split_quoted_strings(r#""str1" "str2""#);
771 assert_eq!(parts, vec!["str1", "str2"]);
772 }
773
774 #[test]
775 fn test_build_description() {
776 let synopsis = Some("Short description".to_string());
777 let description = Some("Long description".to_string());
778 let result = build_description(&synopsis, &description);
779 assert_eq!(
780 result,
781 Some("Short description\nLong description".to_string())
782 );
783 }
784
785 #[test]
786 fn test_parse_opam_keeps_fields_after_single_line_description() {
787 let package = parse_opam(
788 r#"opam-version: "2.0"
789name: "dune-rpc"
790version: "3.23.0"
791description: "Library to connect and control a running dune instance"
792maintainer: ["Jane Street Group, LLC <opensource@janestreet.com>"]
793authors: ["Jane Street Group, LLC <opensource@janestreet.com>"]
794license: "MIT"
795homepage: "https://github.com/ocaml/dune"
796bug-reports: "https://github.com/ocaml/dune/issues"
797depends: [
798 "dune" {>= "3.23"}
799 "ocamlc-loc"
800 "stdune" {= version}
801 "odoc" {with-doc}
802]
803dev-repo: "git+https://github.com/ocaml/dune.git"
804"#,
805 None,
806 );
807
808 assert_eq!(package.name.as_deref(), Some("dune-rpc"));
809 assert_eq!(package.version.as_deref(), Some("3.23.0"));
810 assert_eq!(
811 package.description.as_deref(),
812 Some("Library to connect and control a running dune instance")
813 );
814 assert_eq!(
815 package.homepage_url.as_deref(),
816 Some("https://github.com/ocaml/dune")
817 );
818 assert_eq!(
819 package.bug_tracking_url.as_deref(),
820 Some("https://github.com/ocaml/dune/issues")
821 );
822 assert_eq!(
823 package.vcs_url.as_deref(),
824 Some("git+https://github.com/ocaml/dune.git")
825 );
826 assert_eq!(
827 package.declared_license_expression_spdx.as_deref(),
828 Some("MIT")
829 );
830 assert_eq!(package.dependencies.len(), 4);
831 assert_eq!(
832 package.dependencies[0].purl.as_deref(),
833 Some("pkg:opam/dune")
834 );
835 assert_eq!(
836 package.dependencies[0].extracted_requirement.as_deref(),
837 Some(">= 3.23")
838 );
839 assert_eq!(
840 package.dependencies[2].extracted_requirement.as_deref(),
841 Some("= version")
842 );
843 assert_eq!(
844 package.dependencies[3].extracted_requirement.as_deref(),
845 Some("with-doc")
846 );
847 }
848
849 #[test]
850 fn test_parse_opam_keeps_fields_after_next_line_description() {
851 let package = parse_opam(
852 r#"opam-version: "2.0"
853name: "chrome-trace"
854version: "3.23.0"
855description:
856 "This library offers no backwards compatibility guarantees. Use at your own risk."
857maintainer: ["Jane Street Group, LLC <opensource@janestreet.com>"]
858license: "MIT"
859depends: [
860 "dune" {>= "3.23"}
861 "ocaml" {>= "4.14"}
862 "odoc" {with-doc}
863]
864dev-repo: "git+https://github.com/ocaml/dune.git"
865"#,
866 None,
867 );
868
869 assert_eq!(package.name.as_deref(), Some("chrome-trace"));
870 assert_eq!(
871 package.description.as_deref(),
872 Some(
873 "This library offers no backwards compatibility guarantees. Use at your own risk."
874 )
875 );
876 assert_eq!(
877 package.vcs_url.as_deref(),
878 Some("git+https://github.com/ocaml/dune.git")
879 );
880 assert_eq!(package.dependencies.len(), 3);
881 assert_eq!(
882 package.dependencies[1].purl.as_deref(),
883 Some("pkg:opam/ocaml")
884 );
885 assert_eq!(
886 package.dependencies[1].extracted_requirement.as_deref(),
887 Some(">= 4.14")
888 );
889 assert_eq!(
890 package.dependencies[2].extracted_requirement.as_deref(),
891 Some("with-doc")
892 );
893 }
894
895 #[test]
896 fn test_extract_parties() {
897 let authors = vec!["Author One".to_string()];
898 let maintainers = vec!["maintainer@example.com".to_string()];
899 let parties = extract_parties(&authors, &maintainers);
900
901 assert_eq!(parties.len(), 2);
902 assert_eq!(parties[0].name, Some("Author One".to_string()));
903 assert_eq!(parties[0].role, Some("author".to_string()));
904 assert_eq!(parties[1].email, Some("maintainer@example.com".to_string()));
905 assert_eq!(parties[1].role, Some("maintainer".to_string()));
906 }
907
908 #[test]
909 fn test_normalize_opam_declared_license_preserves_scancode_style_expression() {
910 let (declared, declared_spdx, detections) = normalize_opam_declared_license(Some(
911 "LGPL-3.0-only with OCaml-LGPL-linking-exception",
912 ));
913
914 assert_eq!(
915 declared.as_deref(),
916 Some("lgpl-3.0 WITH ocaml-lgpl-linking-exception")
917 );
918 assert_eq!(
919 declared_spdx.as_deref(),
920 Some("LGPL-3.0-only WITH OCaml-LGPL-linking-exception")
921 );
922 assert_eq!(detections.len(), 1);
923 assert_eq!(
924 detections[0].license_expression,
925 "lgpl-3.0 WITH ocaml-lgpl-linking-exception"
926 );
927 }
928}