Skip to main content

provenant/parsers/
opam.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Parser for OCaml OPAM package manager manifests.
7//!
8//! Extracts package metadata and dependencies from OPAM files used by the
9//! OCaml ecosystem.
10//!
11//! # Supported Formats
12//! - *.opam files (OPAM package manifests)
13//! - opam files without extension
14//!
15//! # Key Features
16//! - Field-based parsing of OPAM's custom format (key: value)
17//! - Author and maintainer extraction with email parsing
18//! - URL extraction for source archives, homepage, repository
19//! - License statement extraction
20//! - Checksum extraction (sha1, md5, sha256, sha512)
21//!
22//! # Implementation Notes
23//! - OPAM format uses custom syntax, not JSON/YAML/TOML
24//! - Strings can be quoted or unquoted
25//! - Lists use bracket notation: [item1 item2]
26//! - Multi-line strings use three-quote notation: """..."""
27
28use 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
46/// Parser for OCaml OPAM package manifest files.
47///
48/// Handles the OPAM file format used by the OCaml package manager.
49/// Reference: <https://opam.ocaml.org/doc/Manual.html#Common-file-format>
50pub 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/// Parsed OPAM file data
83#[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)>, // (name, version_constraint)
101}
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
112/// Parse an OPAM file from text content
113fn 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
227/// Parse OPAM file text into structured data
228fn 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        // Parse key: value format
243        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
285/// Parse a key: value line
286fn 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
301/// Clean a value by removing quotes and brackets
302fn 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
317/// Parse an OPAM description field.
318///
319/// OPAM descriptions can be encoded as an inline quoted string, a quoted string
320/// on the following line, or a triple-quoted multiline string.
321fn 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
347/// Parse a multiline string enclosed in triple quotes.
348fn 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
398/// Parse a string array (single-line or multiline)
399fn 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
436/// Parse dependency array
437fn 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
464/// Parse a single dependency line: "name" {version_constraint}
465fn parse_dependency_line(line: &str) -> Option<(String, String)> {
466    let line = line.trim();
467    if line.is_empty() {
468        return None;
469    }
470
471    // Match: "name" {optional version}
472    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    // Extract the operator and version constraint
479    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
488/// Extract version constraint from {>= "1.0"} format
489fn 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    // If regex parsing fails, try to extract raw content
502    let content = version_part
503        .trim_matches('{')
504        .trim_matches('}')
505        .trim_matches('"')
506        .trim();
507
508    content.replace('"', "")
509}
510
511/// Parse checksums from checksum array
512fn 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
557/// Parse a single checksum line: algo=hash
558fn 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
570/// Split quoted strings like: "str1" "str2" "str3"
571fn 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
596/// Build description from synopsis and description
597fn 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
611/// Extract parties from authors and maintainers
612fn extract_parties(authors: &[String], maintainers: &[String]) -> Vec<Party> {
613    let mut parties = Vec::new();
614
615    // Add authors
616    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    // Add maintainers (as email)
630    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
646/// Extract dependencies into Dependency objects
647fn 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}