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        // opam convention: a `<name>.opam` file names its package after the file
74        // stem when the manifest body omits an explicit `name:` field.
75        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/// Parsed OPAM file data
91#[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)>, // (name, version_constraint)
109}
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
120/// Parse an OPAM file from text content
121fn parse_opam(text: &str, name_fallback: Option<&str>) -> PackageData {
122    let opam_data = parse_opam_data(text);
123
124    // Most opam manifests omit `name:` and rely on the `<name>.opam` filename.
125    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
240/// Parse OPAM file text into structured data
241fn 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        // Parse key: value format
256        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
298/// Parse a key: value line
299fn 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
314/// Clean a value by removing quotes and brackets
315fn 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
330/// Parse an OPAM description field.
331///
332/// OPAM descriptions can be encoded as an inline quoted string, a quoted string
333/// on the following line, or a triple-quoted multiline string.
334fn 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
360/// Parse a multiline string enclosed in triple quotes.
361fn 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
411/// Parse a string array (single-line or multiline)
412fn 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
449/// Parse dependency array
450fn 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
477/// Parse a single dependency line: "name" {version_constraint}
478fn parse_dependency_line(line: &str) -> Option<(String, String)> {
479    let line = line.trim();
480    if line.is_empty() {
481        return None;
482    }
483
484    // Match: "name" {optional version}
485    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    // Extract the operator and version constraint
492    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
501/// Extract version constraint from {>= "1.0"} format
502fn 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    // If regex parsing fails, try to extract raw content
515    let content = version_part
516        .trim_matches('{')
517        .trim_matches('}')
518        .trim_matches('"')
519        .trim();
520
521    content.replace('"', "")
522}
523
524/// Parse checksums from checksum array
525fn 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
570/// Parse a single checksum line: algo=hash
571fn 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
583/// Split quoted strings like: "str1" "str2" "str3"
584fn 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
609/// Build description from synopsis and description
610fn 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
624/// Extract parties from authors and maintainers
625fn extract_parties(authors: &[String], maintainers: &[String]) -> Vec<Party> {
626    let mut parties = Vec::new();
627
628    // Add authors
629    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    // Add maintainers (as email)
643    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
659/// Extract dependencies into Dependency objects
660fn 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}