Skip to main content

provenant/parsers/
opam.rs

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