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 = 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
238/// Parse OPAM file text into structured data
239fn 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        // Parse key: value format
254        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
296/// Parse a key: value line
297fn 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
312/// Clean a value by removing quotes and brackets
313fn 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
328/// Parse an OPAM description field.
329///
330/// OPAM descriptions can be encoded as an inline quoted string, a quoted string
331/// on the following line, or a triple-quoted multiline string.
332fn 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
358/// Parse a multiline string enclosed in triple quotes.
359fn 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
409/// Parse a string array (single-line or multiline)
410fn 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
447/// Parse dependency array
448fn 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
475/// Parse a single dependency line: "name" {version_constraint}
476fn parse_dependency_line(line: &str) -> Option<(String, String)> {
477    let line = line.trim();
478    if line.is_empty() {
479        return None;
480    }
481
482    // Match: "name" {optional version}
483    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    // Extract the operator and version constraint
490    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
499/// Extract version constraint from {>= "1.0"} format
500fn 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    // If regex parsing fails, try to extract raw content
513    let content = version_part
514        .trim_matches('{')
515        .trim_matches('}')
516        .trim_matches('"')
517        .trim();
518
519    content.replace('"', "")
520}
521
522/// Parse checksums from checksum array
523fn 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
568/// Parse a single checksum line: algo=hash
569fn 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
581/// Split quoted strings like: "str1" "str2" "str3"
582fn 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
607/// Build description from synopsis and description
608fn 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
622/// Extract parties from authors and maintainers
623fn extract_parties(authors: &[String], maintainers: &[String]) -> Vec<Party> {
624    let mut parties = Vec::new();
625
626    // Add authors
627    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    // Add maintainers (as email)
641    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
657/// Extract dependencies into Dependency objects
658fn 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        // Names come from a quoted opam field, so anything but a quote reaches the
700        // PURL. Splicing them in unencoded produced strings that either failed to
701        // parse or silently changed meaning: a `/` became a namespace separator
702        // and text after a `#` became a subpath.
703        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            // Already-percent-encoded text is data, not encoding: the real name is
710            // the literal six characters, so it must survive a round trip.
711            ("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}