Skip to main content

provenant/models/
file_info.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
7use derive_builder::Builder;
8use packageurl::PackageUrl;
9use serde::{Deserialize, Serialize};
10use sha1::{Digest, Sha1};
11use std::collections::HashMap;
12use std::fmt;
13use std::str::FromStr;
14
15use super::DatasourceId;
16use super::DependencyUid;
17use super::DiagnosticSeverity;
18use super::GitSha1;
19use super::LineNumber;
20use super::MatchScore;
21use super::Md5Digest;
22use super::PackageType;
23use super::PackageUid;
24use super::ScanDiagnostic;
25use super::Sha1Digest;
26use super::Sha256Digest;
27use super::Sha512Digest;
28
29use crate::license_detection::MatcherKind;
30use crate::license_detection::tokenize::tokenize_without_stopwords;
31use crate::models::output::Tallies;
32use crate::utils::spdx::combine_license_expressions;
33
34#[derive(Debug, Builder, Serialize, Deserialize, Clone)]
35#[builder(build_fn(skip))]
36/// File-level scan result containing metadata and detected findings.
37pub struct FileInfo {
38    pub name: String,
39    pub base_name: String,
40    pub extension: String,
41    pub path: String,
42    pub file_type: FileType,
43    #[builder(default)]
44    #[serde(default)]
45    pub mime_type: Option<String>,
46    #[builder(default)]
47    #[serde(default)]
48    pub file_type_label: Option<String>,
49    pub size: u64,
50    #[builder(default)]
51    #[serde(default)]
52    pub date: Option<String>,
53    #[builder(default)]
54    #[serde(default)]
55    pub sha1: Option<Sha1Digest>,
56    #[builder(default)]
57    #[serde(default)]
58    pub md5: Option<Md5Digest>,
59    #[builder(default)]
60    #[serde(default)]
61    pub sha256: Option<Sha256Digest>,
62    #[builder(default)]
63    #[serde(default)]
64    pub sha1_git: Option<GitSha1>,
65    #[builder(default)]
66    #[serde(default)]
67    pub programming_language: Option<String>,
68    #[builder(default)]
69    #[serde(default)]
70    pub package_data: Vec<PackageData>,
71    #[builder(default)]
72    #[serde(default)]
73    pub detected_license_expression: Option<String>,
74    /// SPDX-form counterpart of [`Self::detected_license_expression`]. Both carry
75    /// the same operator structure; this one spells license operands as SPDX ids
76    /// (e.g. `MPL-2.0 AND BUSL-1.1`) while `detected_license_expression` uses
77    /// ScanCode keys (e.g. `mpl-2.0 AND bsl-1.1`).
78    #[builder(default)]
79    #[serde(default)]
80    pub detected_license_expression_spdx: Option<String>,
81    #[builder(default)]
82    #[serde(default)]
83    pub license_detections: Vec<LicenseDetection>,
84    #[builder(default)]
85    #[serde(default)]
86    pub license_clues: Vec<Match>,
87    #[builder(default)]
88    #[serde(default)]
89    pub percentage_of_license_text: Option<f64>,
90    #[builder(default)]
91    #[serde(default)]
92    pub copyrights: Vec<Copyright>,
93    #[builder(default)]
94    #[serde(default)]
95    pub holders: Vec<Holder>,
96    #[builder(default)]
97    #[serde(default)]
98    pub authors: Vec<Author>,
99    #[builder(default)]
100    #[serde(default)]
101    pub emails: Vec<OutputEmail>,
102    #[builder(default)]
103    #[serde(default)]
104    pub urls: Vec<OutputURL>,
105    #[builder(default)]
106    #[serde(default)]
107    pub for_packages: Vec<PackageUid>,
108    #[builder(default)]
109    #[serde(default)]
110    pub scan_diagnostics: Vec<ScanDiagnostic>,
111    #[builder(default)]
112    #[serde(default)]
113    pub license_policy: Option<Vec<LicensePolicyEntry>>,
114    #[builder(default)]
115    #[serde(default)]
116    pub is_generated: Option<bool>,
117    #[builder(default)]
118    #[serde(default)]
119    pub is_binary: Option<bool>,
120    #[builder(default)]
121    #[serde(default)]
122    pub is_text: Option<bool>,
123    #[builder(default)]
124    #[serde(default)]
125    pub is_archive: Option<bool>,
126    #[builder(default)]
127    #[serde(default)]
128    pub is_media: Option<bool>,
129    #[builder(default)]
130    #[serde(default)]
131    pub is_source: Option<bool>,
132    #[builder(default)]
133    #[serde(default)]
134    pub is_script: Option<bool>,
135    #[builder(default)]
136    #[serde(default)]
137    pub files_count: Option<usize>,
138    #[builder(default)]
139    #[serde(default)]
140    pub dirs_count: Option<usize>,
141    #[builder(default)]
142    #[serde(default)]
143    pub size_count: Option<u64>,
144    #[builder(default)]
145    #[serde(default)]
146    pub source_count: Option<usize>,
147    #[builder(default)]
148    #[serde(default)]
149    pub is_legal: bool,
150    #[builder(default)]
151    #[serde(default)]
152    pub is_manifest: bool,
153    #[builder(default)]
154    #[serde(default)]
155    pub is_readme: bool,
156    #[builder(default)]
157    #[serde(default)]
158    pub is_top_level: bool,
159    #[builder(default)]
160    #[serde(default)]
161    pub is_key_file: bool,
162    #[builder(default)]
163    #[serde(default)]
164    pub is_referenced: bool,
165    #[builder(default)]
166    #[serde(default)]
167    pub is_community: bool,
168    #[builder(default)]
169    #[serde(default)]
170    pub facets: Vec<String>,
171    #[builder(default)]
172    #[serde(default)]
173    pub tallies: Option<Tallies>,
174}
175
176impl FileInfoBuilder {
177    /// Build a [`FileInfo`] from the current builder state.
178    pub fn build(&self) -> Result<FileInfo, String> {
179        let mut file_info = FileInfo::new(
180            self.name.clone().ok_or("Missing field: name")?,
181            self.base_name.clone().ok_or("Missing field: base_name")?,
182            self.extension.clone().ok_or("Missing field: extension")?,
183            self.path.clone().ok_or("Missing field: path")?,
184            self.file_type.clone().ok_or("Missing field: file_type")?,
185            self.mime_type.clone().flatten(),
186            self.file_type_label.clone().flatten(),
187            self.size.ok_or("Missing field: size")?,
188            self.date.clone().flatten(),
189            self.sha1.flatten(),
190            self.md5.flatten(),
191            self.sha256.flatten(),
192            self.programming_language.clone().flatten(),
193            self.package_data.clone().unwrap_or_default(),
194            self.detected_license_expression.clone().flatten(),
195            self.license_detections.clone().unwrap_or_default(),
196            self.license_clues.clone().unwrap_or_default(),
197            self.copyrights.clone().unwrap_or_default(),
198            self.holders.clone().unwrap_or_default(),
199            self.authors.clone().unwrap_or_default(),
200            self.emails.clone().unwrap_or_default(),
201            self.urls.clone().unwrap_or_default(),
202            self.for_packages.clone().unwrap_or_default(),
203            self.scan_diagnostics.clone().unwrap_or_default(),
204        );
205        file_info.license_policy = self.license_policy.clone().flatten();
206        file_info.sha1_git = self.sha1_git.flatten();
207        file_info.is_binary = self.is_binary.flatten();
208        file_info.is_text = self.is_text.flatten();
209        file_info.is_archive = self.is_archive.flatten();
210        file_info.is_media = self.is_media.flatten();
211        file_info.is_script = self.is_script.flatten();
212        file_info.files_count = self.files_count.flatten();
213        file_info.dirs_count = self.dirs_count.flatten();
214        file_info.size_count = self.size_count.flatten();
215        file_info.is_referenced = self.is_referenced.unwrap_or_default();
216        Ok(file_info)
217    }
218}
219
220impl FileInfo {
221    /// Construct a [`FileInfo`] from fully resolved scanner fields.
222    // Arguments mirror the resolved scanner fields that make up a [`FileInfo`].
223    #[allow(clippy::too_many_arguments)]
224    pub fn new(
225        name: String,
226        base_name: String,
227        extension: String,
228        path: String,
229        file_type: FileType,
230        mime_type: Option<String>,
231        file_type_label: Option<String>,
232        size: u64,
233        date: Option<String>,
234        sha1: Option<Sha1Digest>,
235        md5: Option<Md5Digest>,
236        sha256: Option<Sha256Digest>,
237        programming_language: Option<String>,
238        package_data: Vec<PackageData>,
239        mut detected_license_expression: Option<String>,
240        mut license_detections: Vec<LicenseDetection>,
241        license_clues: Vec<Match>,
242        copyrights: Vec<Copyright>,
243        holders: Vec<Holder>,
244        authors: Vec<Author>,
245        emails: Vec<OutputEmail>,
246        urls: Vec<OutputURL>,
247        for_packages: Vec<PackageUid>,
248        scan_diagnostics: Vec<ScanDiagnostic>,
249    ) -> Self {
250        let mut package_data = package_data;
251        for package in &mut package_data {
252            enrich_package_data_license_provenance(package, &path);
253        }
254
255        // Combine license expressions from package data if detected_license_expression is None
256        detected_license_expression = detected_license_expression.or_else(|| {
257            let expressions = package_data
258                .iter()
259                .filter_map(|pkg| pkg.get_license_expression());
260            combine_license_expressions(expressions)
261        });
262
263        // Combine license detections from package data if none are provided
264        if license_detections.is_empty() {
265            for pkg in &package_data {
266                license_detections.extend(pkg.license_detections.clone());
267            }
268        }
269
270        // Combine license expressions from license detections if detected_license_expression is still None
271        if detected_license_expression.is_none() {
272            detected_license_expression =
273                detected_license_expression_from_detections(&license_detections);
274        }
275
276        // The SPDX-form expression mirrors the key form's grouping but spells operands
277        // as SPDX ids, derived from the same detections via the strict combiner. This
278        // keeps `detected_license_expression_spdx` in lockstep with the key field for
279        // every path that resolves it from detections.
280        let detected_license_expression_spdx =
281            detected_license_expression_spdx_from_detections(&license_detections);
282
283        let mut file_info = FileInfo {
284            name,
285            base_name,
286            extension,
287            path,
288            file_type,
289            mime_type,
290            file_type_label,
291            size,
292            date,
293            sha1,
294            md5,
295            sha256,
296            sha1_git: None,
297            programming_language,
298            package_data,
299            detected_license_expression,
300            detected_license_expression_spdx,
301            license_detections,
302            license_clues,
303            percentage_of_license_text: None,
304            copyrights,
305            holders,
306            authors,
307            emails,
308            urls,
309            for_packages,
310            scan_diagnostics,
311            license_policy: None,
312            is_generated: None,
313            is_binary: None,
314            is_text: None,
315            is_archive: None,
316            is_media: None,
317            is_source: None,
318            is_script: None,
319            files_count: None,
320            dirs_count: None,
321            size_count: None,
322            source_count: None,
323            is_legal: false,
324            is_manifest: false,
325            is_readme: false,
326            is_top_level: false,
327            is_key_file: false,
328            is_referenced: false,
329            is_community: false,
330            facets: vec![],
331            tallies: None,
332        };
333
334        file_info.backfill_license_provenance();
335        file_info
336    }
337
338    pub fn backfill_license_provenance(&mut self) {
339        for detection in &mut self.license_detections {
340            enrich_license_detection_provenance(detection, &self.path);
341        }
342
343        for package in &mut self.package_data {
344            enrich_package_data_license_provenance(package, &self.path);
345        }
346    }
347}
348
349impl FileInfo {
350    pub fn warning_diagnostics(&self) -> impl Iterator<Item = &ScanDiagnostic> {
351        self.scan_diagnostics
352            .iter()
353            .filter(|diagnostic| diagnostic.severity == DiagnosticSeverity::Warning)
354    }
355
356    pub fn error_diagnostics(&self) -> impl Iterator<Item = &ScanDiagnostic> {
357        self.scan_diagnostics.iter().filter(|diagnostic| {
358            diagnostic.severity == DiagnosticSeverity::Error
359                || diagnostic.severity == DiagnosticSeverity::Timeout
360        })
361    }
362}
363
364/// Derive the key-form file-level expression from a file's license detections,
365/// using the same selection/combination the scanner and `FileInfo::new` apply to
366/// each detection's `license_expression`. Returns `None` when there are no
367/// detections — the key form cannot be recovered from SPDX-only carried text.
368pub(crate) fn detected_license_expression_from_detections(
369    detections: &[LicenseDetection],
370) -> Option<String> {
371    if detections.is_empty() {
372        return None;
373    }
374    let expressions: Vec<String> = detections
375        .iter()
376        .map(|detection| detection.license_expression.clone())
377        .collect();
378    crate::utils::spdx::select_primary_license_expression(expressions.clone()).or_else(|| {
379        crate::utils::spdx::combine_license_expressions_preserving_structure(expressions)
380    })
381}
382
383/// Derive the SPDX-form file-level expression from a file's license detections,
384/// combining each detection's `license_expression_spdx` with the strict combiner.
385/// Returns `None` when no detection carries an SPDX form, so the field is left
386/// absent rather than holding key-form text. This mirrors the key-form derivation
387/// in [`FileInfo::new`] and the scanner's per-file combination.
388pub(crate) fn detected_license_expression_spdx_from_detections(
389    detections: &[LicenseDetection],
390) -> Option<String> {
391    if detections.is_empty() {
392        return None;
393    }
394    let expressions: Option<Vec<String>> = detections
395        .iter()
396        .map(|detection| {
397            (!detection.license_expression_spdx.is_empty())
398                .then(|| detection.license_expression_spdx.clone())
399        })
400        .collect();
401    expressions.and_then(|expressions| {
402        crate::utils::spdx::select_primary_license_expression_strict(expressions.clone()).or_else(
403            || {
404                crate::utils::spdx::combine_license_expressions_preserving_structure_strict(
405                    expressions,
406                )
407            },
408        )
409    })
410}
411
412fn enrich_package_data_license_provenance(package_data: &mut PackageData, path: &str) {
413    for detection in &mut package_data.license_detections {
414        enrich_license_detection_provenance(detection, path);
415    }
416    for detection in &mut package_data.other_license_detections {
417        enrich_license_detection_provenance(detection, path);
418    }
419}
420
421pub(crate) fn enrich_license_detection_provenance(detection: &mut LicenseDetection, path: &str) {
422    for detection_match in &mut detection.matches {
423        if detection_match.from_file.is_none() {
424            detection_match.from_file = Some(path.to_string());
425        }
426
427        if detection_match.rule_identifier.is_empty() {
428            detection_match.rule_identifier = detection_match.matcher.to_string();
429        }
430    }
431
432    if detection.identifier.is_empty() {
433        detection.identifier = compute_public_detection_identifier(detection);
434    }
435}
436
437fn compute_public_detection_identifier(detection: &LicenseDetection) -> String {
438    let expression = python_safe_name(&detection.license_expression);
439    let mut hasher = Sha1::new();
440    hasher.update(format_public_detection_content(detection).as_bytes());
441    let hex_str = hex::encode(hasher.finalize());
442    let uuid_hex = &hex_str[..32];
443    let content_uuid = uuid::Uuid::parse_str(uuid_hex)
444        .map(|uuid| uuid.to_string())
445        .unwrap_or_else(|_| uuid_hex.to_string());
446
447    format!("{}-{}", expression, content_uuid)
448}
449
450fn format_public_detection_content(detection: &LicenseDetection) -> String {
451    let mut result = String::from("(");
452
453    for (index, detection_match) in detection.matches.iter().enumerate() {
454        if index > 0 {
455            result.push_str(", ");
456        }
457        result.push_str(&format!(
458            "({}, {}, {})",
459            python_str_repr(if detection_match.rule_identifier.is_empty() {
460                detection_match.matcher.as_str()
461            } else {
462                detection_match.rule_identifier.as_str()
463            }),
464            detection_match.score.value() as f32,
465            python_token_tuple_repr(&tokenize_without_stopwords(
466                detection_match.matched_text.as_deref().unwrap_or_default(),
467            )),
468        ));
469    }
470
471    if detection.matches.len() == 1 {
472        result.push(',');
473    }
474    result.push(')');
475    result
476}
477
478fn python_safe_name(value: &str) -> String {
479    let mut result = String::new();
480    let mut prev_underscore = false;
481
482    for character in value.chars() {
483        if character.is_alphanumeric() {
484            result.push(character);
485            prev_underscore = false;
486        } else if !prev_underscore {
487            result.push('_');
488            prev_underscore = true;
489        }
490    }
491
492    let trimmed = result.trim_matches('_');
493    if trimmed.is_empty() {
494        String::new()
495    } else {
496        trimmed.to_string()
497    }
498}
499
500fn python_str_repr(value: &str) -> String {
501    if value.contains('\'') && !value.contains('"') {
502        format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
503    } else {
504        format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\\'"))
505    }
506}
507
508fn python_token_tuple_repr(tokens: &[String]) -> String {
509    if tokens.is_empty() {
510        return String::from("()");
511    }
512
513    let mut result = String::from("(");
514    for (index, token) in tokens.iter().enumerate() {
515        if index > 0 {
516            result.push_str(", ");
517        }
518        result.push_str(&python_str_repr(token));
519    }
520
521    if tokens.len() == 1 {
522        result.push(',');
523    }
524    result.push(')');
525    result
526}
527
528/// Package metadata extracted from manifest files.
529///
530/// Compatible with ScanCode Toolkit output format. Contains standardized package
531/// information including name, version, dependencies, licenses, and other metadata.
532/// This is the primary data structure returned by all parsers.
533#[derive(Serialize, Deserialize, Debug, Clone, Default)]
534pub struct PackageData {
535    pub package_type: Option<PackageType>,
536    pub namespace: Option<String>,
537    pub name: Option<String>,
538    pub version: Option<String>,
539    #[serde(default)]
540    pub qualifiers: Option<HashMap<String, String>>,
541    pub subpath: Option<String>,
542    pub primary_language: Option<String>,
543    pub description: Option<String>,
544    pub release_date: Option<String>,
545    #[serde(default)]
546    pub parties: Vec<Party>,
547    #[serde(default)]
548    pub keywords: Vec<String>,
549    pub homepage_url: Option<String>,
550    pub download_url: Option<String>,
551    pub size: Option<u64>,
552    pub sha1: Option<Sha1Digest>,
553    pub md5: Option<Md5Digest>,
554    pub sha256: Option<Sha256Digest>,
555    pub sha512: Option<Sha512Digest>,
556    pub bug_tracking_url: Option<String>,
557    pub code_view_url: Option<String>,
558    pub vcs_url: Option<String>,
559    pub copyright: Option<String>,
560    pub holder: Option<String>,
561    pub declared_license_expression: Option<String>,
562    pub declared_license_expression_spdx: Option<String>,
563    #[serde(default)]
564    pub license_detections: Vec<LicenseDetection>,
565    pub other_license_expression: Option<String>,
566    pub other_license_expression_spdx: Option<String>,
567    #[serde(default)]
568    pub other_license_detections: Vec<LicenseDetection>,
569    pub extracted_license_statement: Option<String>,
570    pub notice_text: Option<String>,
571    #[serde(default)]
572    pub source_packages: Vec<String>,
573    #[serde(default)]
574    pub file_references: Vec<FileReference>,
575    #[serde(default)]
576    pub is_private: bool,
577    #[serde(default)]
578    pub is_virtual: bool,
579    #[serde(default, with = "super::json_value_map")]
580    pub extra_data: Option<HashMap<String, serde_json::Value>>,
581    #[serde(default)]
582    pub dependencies: Vec<Dependency>,
583    pub repository_homepage_url: Option<String>,
584    pub repository_download_url: Option<String>,
585    pub api_data_url: Option<String>,
586    pub datasource_id: Option<DatasourceId>,
587    pub purl: Option<String>,
588}
589
590impl PackageData {
591    /// Extracts a single license expression from all license detections in this package.
592    /// Returns None if there are no license detections.
593    pub fn get_license_expression(&self) -> Option<String> {
594        if self.license_detections.is_empty() {
595            return None;
596        }
597
598        let expressions = self
599            .license_detections
600            .iter()
601            .map(|detection| detection.license_expression.clone());
602        combine_license_expressions(expressions)
603    }
604}
605
606/// License detection result containing matched license expressions.
607///
608/// Aggregates multiple license matches into a single SPDX license expression.
609#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
610pub struct LicenseDetection {
611    pub license_expression: String,
612    pub license_expression_spdx: String,
613    pub matches: Vec<Match>,
614    #[serde(default)]
615    pub detection_log: Vec<String>,
616    #[serde(default = "String::new")]
617    pub identifier: String,
618}
619
620/// Individual license text match with location and confidence score.
621///
622/// Represents a specific region of text that matched a known license pattern.
623#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
624pub struct Match {
625    pub license_expression: String,
626    pub license_expression_spdx: String,
627    pub from_file: Option<String>,
628    pub start_line: LineNumber,
629    pub end_line: LineNumber,
630    #[serde(default)]
631    pub matcher: MatcherKind,
632    pub score: MatchScore,
633    pub matched_length: Option<usize>,
634    pub match_coverage: Option<f64>,
635    pub rule_relevance: Option<u8>,
636    #[serde(default = "String::new")]
637    pub rule_identifier: String,
638    pub rule_url: Option<String>,
639    pub matched_text: Option<String>,
640    pub matched_text_diagnostics: Option<String>,
641    #[serde(default)]
642    pub referenced_filenames: Option<Vec<String>>,
643}
644
645#[derive(Serialize, Deserialize, Debug, Clone)]
646pub struct Copyright {
647    pub copyright: String,
648    #[serde(default)]
649    pub normalized_copyright: Option<String>,
650    pub start_line: LineNumber,
651    pub end_line: LineNumber,
652}
653
654impl Copyright {
655    pub fn normalized_text(&self) -> &str {
656        self.normalized_copyright
657            .as_deref()
658            .unwrap_or(self.copyright.as_str())
659    }
660}
661
662#[derive(Serialize, Deserialize, Debug, Clone)]
663pub struct Holder {
664    pub holder: String,
665    pub start_line: LineNumber,
666    pub end_line: LineNumber,
667}
668
669#[derive(Serialize, Deserialize, Debug, Clone)]
670pub struct Author {
671    pub author: String,
672    pub start_line: LineNumber,
673    pub end_line: LineNumber,
674}
675
676/// Package dependency information with version constraints.
677///
678/// Represents a declared dependency with scope (e.g., runtime, dev, optional)
679/// and optional resolved package details.
680#[derive(Serialize, Deserialize, Debug, Clone)]
681pub struct Dependency {
682    pub purl: Option<String>,
683    pub extracted_requirement: Option<String>,
684    pub scope: Option<String>,
685    pub is_runtime: Option<bool>,
686    pub is_optional: Option<bool>,
687    pub is_pinned: Option<bool>,
688    pub is_direct: Option<bool>,
689    pub resolved_package: Option<Box<ResolvedPackage>>,
690    #[serde(default, with = "super::json_value_map")]
691    pub extra_data: Option<HashMap<String, serde_json::Value>>,
692}
693
694#[derive(Serialize, Deserialize, Debug, Clone)]
695pub struct ResolvedPackage {
696    pub package_type: PackageType,
697    pub namespace: String,
698    pub name: String,
699    pub version: String,
700    #[serde(default)]
701    pub qualifiers: Option<HashMap<String, String>>,
702    pub subpath: Option<String>,
703    pub primary_language: Option<String>,
704    pub description: Option<String>,
705    pub release_date: Option<String>,
706    #[serde(default)]
707    pub parties: Vec<Party>,
708    #[serde(default)]
709    pub keywords: Vec<String>,
710    pub homepage_url: Option<String>,
711    pub download_url: Option<String>,
712    pub size: Option<u64>,
713    pub sha1: Option<Sha1Digest>,
714    pub md5: Option<Md5Digest>,
715    pub sha256: Option<Sha256Digest>,
716    pub sha512: Option<Sha512Digest>,
717    pub bug_tracking_url: Option<String>,
718    pub code_view_url: Option<String>,
719    pub vcs_url: Option<String>,
720    pub copyright: Option<String>,
721    pub holder: Option<String>,
722    pub declared_license_expression: Option<String>,
723    pub declared_license_expression_spdx: Option<String>,
724    #[serde(default)]
725    pub license_detections: Vec<LicenseDetection>,
726    pub other_license_expression: Option<String>,
727    pub other_license_expression_spdx: Option<String>,
728    #[serde(default)]
729    pub other_license_detections: Vec<LicenseDetection>,
730    pub extracted_license_statement: Option<String>,
731    pub notice_text: Option<String>,
732    #[serde(default)]
733    pub source_packages: Vec<String>,
734    #[serde(default)]
735    pub file_references: Vec<FileReference>,
736    #[serde(default)]
737    pub is_private: bool,
738    #[serde(default)]
739    pub is_virtual: bool,
740    #[serde(default, with = "super::json_value_map")]
741    pub extra_data: Option<HashMap<String, serde_json::Value>>,
742    #[serde(default)]
743    pub dependencies: Vec<Dependency>,
744    pub repository_homepage_url: Option<String>,
745    pub repository_download_url: Option<String>,
746    pub api_data_url: Option<String>,
747    pub datasource_id: Option<DatasourceId>,
748    pub purl: Option<String>,
749}
750
751impl ResolvedPackage {
752    pub fn new(
753        package_type: PackageType,
754        namespace: String,
755        name: String,
756        version: String,
757    ) -> Self {
758        Self {
759            package_type,
760            namespace,
761            name,
762            version,
763            qualifiers: None,
764            subpath: None,
765            primary_language: None,
766            description: None,
767            release_date: None,
768            parties: vec![],
769            keywords: vec![],
770            homepage_url: None,
771            download_url: None,
772            size: None,
773            sha1: None,
774            md5: None,
775            sha256: None,
776            sha512: None,
777            bug_tracking_url: None,
778            code_view_url: None,
779            vcs_url: None,
780            copyright: None,
781            holder: None,
782            declared_license_expression: None,
783            declared_license_expression_spdx: None,
784            license_detections: vec![],
785            other_license_expression: None,
786            other_license_expression_spdx: None,
787            other_license_detections: vec![],
788            extracted_license_statement: None,
789            notice_text: None,
790            source_packages: vec![],
791            file_references: vec![],
792            is_private: false,
793            is_virtual: false,
794            extra_data: None,
795            dependencies: vec![],
796            repository_homepage_url: None,
797            repository_download_url: None,
798            api_data_url: None,
799            datasource_id: None,
800            purl: None,
801        }
802    }
803
804    pub fn from_package_data(package_data: &PackageData, fallback_type: PackageType) -> Self {
805        Self {
806            package_type: package_data.package_type.unwrap_or(fallback_type),
807            namespace: package_data.namespace.clone().unwrap_or_default(),
808            name: package_data.name.clone().unwrap_or_default(),
809            version: package_data.version.clone().unwrap_or_default(),
810            qualifiers: package_data.qualifiers.clone(),
811            subpath: package_data.subpath.clone(),
812            primary_language: package_data.primary_language.clone(),
813            description: package_data.description.clone(),
814            release_date: package_data.release_date.clone(),
815            parties: package_data.parties.clone(),
816            keywords: package_data.keywords.clone(),
817            homepage_url: package_data.homepage_url.clone(),
818            download_url: package_data.download_url.clone(),
819            size: package_data.size,
820            sha1: package_data.sha1,
821            md5: package_data.md5,
822            sha256: package_data.sha256,
823            sha512: package_data.sha512,
824            bug_tracking_url: package_data.bug_tracking_url.clone(),
825            code_view_url: package_data.code_view_url.clone(),
826            vcs_url: package_data.vcs_url.clone(),
827            copyright: package_data.copyright.clone(),
828            holder: package_data.holder.clone(),
829            declared_license_expression: package_data.declared_license_expression.clone(),
830            declared_license_expression_spdx: package_data.declared_license_expression_spdx.clone(),
831            license_detections: package_data.license_detections.clone(),
832            other_license_expression: package_data.other_license_expression.clone(),
833            other_license_expression_spdx: package_data.other_license_expression_spdx.clone(),
834            other_license_detections: package_data.other_license_detections.clone(),
835            extracted_license_statement: package_data.extracted_license_statement.clone(),
836            notice_text: package_data.notice_text.clone(),
837            source_packages: package_data.source_packages.clone(),
838            file_references: package_data.file_references.clone(),
839            is_private: package_data.is_private,
840            is_virtual: package_data.is_virtual,
841            extra_data: package_data.extra_data.clone(),
842            dependencies: package_data.dependencies.clone(),
843            repository_homepage_url: package_data.repository_homepage_url.clone(),
844            repository_download_url: package_data.repository_download_url.clone(),
845            api_data_url: package_data.api_data_url.clone(),
846            datasource_id: package_data.datasource_id,
847            purl: package_data.purl.clone(),
848        }
849    }
850}
851
852#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
853pub enum PartyType {
854    Person,
855    Organization,
856}
857
858impl fmt::Display for PartyType {
859    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
860        match self {
861            PartyType::Person => write!(f, "person"),
862            PartyType::Organization => write!(f, "organization"),
863        }
864    }
865}
866
867impl FromStr for PartyType {
868    type Err = String;
869    fn from_str(s: &str) -> Result<Self, Self::Err> {
870        match s {
871            "person" => Ok(PartyType::Person),
872            "organization" => Ok(PartyType::Organization),
873            other => Err(format!("unknown party type: {other}")),
874        }
875    }
876}
877
878/// Author, maintainer, or contributor information.
879///
880/// Represents a person or organization associated with a package.
881#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
882pub struct Party {
883    pub r#type: Option<PartyType>,
884    pub role: Option<String>,
885    pub name: Option<String>,
886    pub email: Option<String>,
887    pub url: Option<String>,
888    pub organization: Option<String>,
889    pub organization_url: Option<String>,
890    pub timezone: Option<String>,
891}
892
893impl Party {
894    pub(crate) fn person(role: &str, name: Option<String>, email: Option<String>) -> Self {
895        Self {
896            r#type: Some(PartyType::Person),
897            role: Some(role.to_string()),
898            name,
899            email,
900            url: None,
901            organization: None,
902            organization_url: None,
903            timezone: None,
904        }
905    }
906}
907
908/// Reference to a file within a package archive with checksums.
909///
910/// Used in SBOM generation to track files within distribution archives.
911#[derive(Serialize, Deserialize, Debug, Clone)]
912pub struct FileReference {
913    pub path: String,
914    pub size: Option<u64>,
915    pub sha1: Option<Sha1Digest>,
916    pub md5: Option<Md5Digest>,
917    pub sha256: Option<Sha256Digest>,
918    pub sha512: Option<Sha512Digest>,
919    #[serde(with = "super::json_value_map")]
920    pub extra_data: Option<std::collections::HashMap<String, serde_json::Value>>,
921}
922
923impl FileReference {
924    pub(crate) fn from_path(path: String) -> Self {
925        Self {
926            path,
927            size: None,
928            sha1: None,
929            md5: None,
930            sha256: None,
931            sha512: None,
932            extra_data: None,
933        }
934    }
935}
936
937/// Top-level assembled package, created by merging one or more `PackageData`
938/// objects from related manifest/lockfiles (e.g., package.json + package-lock.json).
939///
940/// Compatible with ScanCode Toolkit output format. The key differences from
941/// `PackageData` are:
942/// - `package_uid`: unique identifier (PURL with UUID qualifier)
943/// - `datafile_paths`: list of all contributing files
944/// - `datasource_ids`: list of all contributing parsers
945/// - Excludes `dependencies` and `file_references` (hoisted to top-level)
946#[derive(Serialize, Deserialize, Debug, Clone)]
947pub struct Package {
948    pub package_type: Option<PackageType>,
949    pub namespace: Option<String>,
950    pub name: Option<String>,
951    pub version: Option<String>,
952    #[serde(default)]
953    pub qualifiers: Option<HashMap<String, String>>,
954    pub subpath: Option<String>,
955    pub primary_language: Option<String>,
956    pub description: Option<String>,
957    pub release_date: Option<String>,
958    #[serde(default)]
959    pub parties: Vec<Party>,
960    #[serde(default)]
961    pub keywords: Vec<String>,
962    pub homepage_url: Option<String>,
963    pub download_url: Option<String>,
964    pub size: Option<u64>,
965    pub sha1: Option<Sha1Digest>,
966    pub md5: Option<Md5Digest>,
967    pub sha256: Option<Sha256Digest>,
968    pub sha512: Option<Sha512Digest>,
969    pub bug_tracking_url: Option<String>,
970    pub code_view_url: Option<String>,
971    pub vcs_url: Option<String>,
972    pub copyright: Option<String>,
973    pub holder: Option<String>,
974    pub declared_license_expression: Option<String>,
975    pub declared_license_expression_spdx: Option<String>,
976    #[serde(default)]
977    pub license_detections: Vec<LicenseDetection>,
978    pub other_license_expression: Option<String>,
979    pub other_license_expression_spdx: Option<String>,
980    #[serde(default)]
981    pub other_license_detections: Vec<LicenseDetection>,
982    pub extracted_license_statement: Option<String>,
983    pub notice_text: Option<String>,
984    #[serde(default)]
985    pub source_packages: Vec<String>,
986    #[serde(default)]
987    pub is_private: bool,
988    #[serde(default)]
989    pub is_virtual: bool,
990    #[serde(default, with = "super::json_value_map")]
991    pub extra_data: Option<HashMap<String, serde_json::Value>>,
992    pub repository_homepage_url: Option<String>,
993    pub repository_download_url: Option<String>,
994    pub api_data_url: Option<String>,
995    pub purl: Option<String>,
996    /// Unique identifier for this package instance (PURL with UUID qualifier).
997    pub package_uid: PackageUid,
998    /// Paths to all datafiles that contributed to this package.
999    pub datafile_paths: Vec<String>,
1000    /// Datasource identifiers for all parsers that contributed to this package.
1001    pub datasource_ids: Vec<DatasourceId>,
1002}
1003
1004impl Package {
1005    /// Create a `Package` from a `PackageData` and its source file path.
1006    ///
1007    /// Generates a unique `package_uid` from the package PURL when available.
1008    /// For packages without a PURL but with enough manifest identity to assemble,
1009    /// falls back to an opaque UID derived from datasource/name/version.
1010    pub fn from_package_data(package_data: &PackageData, datafile_path: String) -> Self {
1011        let mut package_data = package_data.clone();
1012        enrich_package_data_license_provenance(&mut package_data, &datafile_path);
1013
1014        let mut package = Package {
1015            package_type: package_data.package_type,
1016            namespace: package_data.namespace.clone(),
1017            name: package_data.name.clone(),
1018            version: package_data.version.clone(),
1019            qualifiers: package_data.qualifiers.clone(),
1020            subpath: package_data.subpath.clone(),
1021            primary_language: package_data.primary_language.clone(),
1022            description: package_data.description.clone(),
1023            release_date: package_data.release_date.clone(),
1024            parties: package_data.parties.clone(),
1025            keywords: package_data.keywords.clone(),
1026            homepage_url: package_data.homepage_url.clone(),
1027            download_url: package_data.download_url.clone(),
1028            size: package_data.size,
1029            sha1: package_data.sha1,
1030            md5: package_data.md5,
1031            sha256: package_data.sha256,
1032            sha512: package_data.sha512,
1033            bug_tracking_url: package_data.bug_tracking_url.clone(),
1034            code_view_url: package_data.code_view_url.clone(),
1035            vcs_url: package_data.vcs_url.clone(),
1036            copyright: package_data.copyright.clone(),
1037            holder: package_data.holder.clone(),
1038            declared_license_expression: package_data.declared_license_expression.clone(),
1039            declared_license_expression_spdx: package_data.declared_license_expression_spdx.clone(),
1040            license_detections: package_data.license_detections.clone(),
1041            other_license_expression: package_data.other_license_expression.clone(),
1042            other_license_expression_spdx: package_data.other_license_expression_spdx.clone(),
1043            other_license_detections: package_data.other_license_detections.clone(),
1044            extracted_license_statement: package_data.extracted_license_statement.clone(),
1045            notice_text: package_data.notice_text.clone(),
1046            source_packages: package_data.source_packages.clone(),
1047            is_private: package_data.is_private,
1048            is_virtual: package_data.is_virtual,
1049            extra_data: package_data.extra_data.clone(),
1050            repository_homepage_url: package_data.repository_homepage_url.clone(),
1051            repository_download_url: package_data.repository_download_url.clone(),
1052            api_data_url: package_data.api_data_url.clone(),
1053            purl: package_data.purl.clone(),
1054            package_uid: PackageUid::empty(),
1055            datafile_paths: vec![datafile_path],
1056            datasource_ids: if let Some(dsid) = package_data.datasource_id {
1057                vec![dsid]
1058            } else {
1059                vec![]
1060            },
1061        };
1062
1063        package.refresh_identity();
1064        if package.package_uid.is_empty() {
1065            package.package_uid = package.fallback_package_uid();
1066        }
1067
1068        package
1069    }
1070
1071    /// Update this package with data from another `PackageData`.
1072    ///
1073    /// Merges data from a related file (e.g., lockfile) into this package.
1074    /// Existing non-empty values are preserved; empty fields are filled from
1075    /// the new data. Lists (parties, license_detections) are merged.
1076    pub fn update(&mut self, package_data: &PackageData, datafile_path: String) {
1077        let mut package_data = package_data.clone();
1078        enrich_package_data_license_provenance(&mut package_data, &datafile_path);
1079
1080        if let Some(dsid) = package_data.datasource_id {
1081            self.datasource_ids.push(dsid);
1082        }
1083        self.datafile_paths.push(datafile_path);
1084
1085        macro_rules! fill_if_empty {
1086            ($field:ident) => {
1087                if self.$field.is_none() {
1088                    self.$field = package_data.$field;
1089                }
1090            };
1091        }
1092
1093        fill_if_empty!(package_type);
1094        fill_if_empty!(name);
1095        fill_if_empty!(namespace);
1096        fill_if_empty!(version);
1097        fill_if_empty!(qualifiers);
1098        fill_if_empty!(subpath);
1099        fill_if_empty!(primary_language);
1100        fill_if_empty!(description);
1101        fill_if_empty!(release_date);
1102        fill_if_empty!(homepage_url);
1103        fill_if_empty!(download_url);
1104        fill_if_empty!(size);
1105        fill_if_empty!(sha1);
1106        fill_if_empty!(md5);
1107        fill_if_empty!(sha256);
1108        fill_if_empty!(sha512);
1109        fill_if_empty!(bug_tracking_url);
1110        fill_if_empty!(code_view_url);
1111        fill_if_empty!(vcs_url);
1112        fill_if_empty!(copyright);
1113        fill_if_empty!(holder);
1114        fill_if_empty!(declared_license_expression);
1115        fill_if_empty!(declared_license_expression_spdx);
1116        fill_if_empty!(other_license_expression);
1117        fill_if_empty!(other_license_expression_spdx);
1118        fill_if_empty!(extracted_license_statement);
1119        fill_if_empty!(notice_text);
1120        match (&mut self.extra_data, &package_data.extra_data) {
1121            (None, Some(extra_data)) => {
1122                self.extra_data = Some(extra_data.clone());
1123            }
1124            (Some(existing), Some(incoming)) => {
1125                for (key, value) in incoming {
1126                    existing.entry(key.clone()).or_insert_with(|| value.clone());
1127                }
1128            }
1129            _ => {}
1130        }
1131        fill_if_empty!(repository_homepage_url);
1132        fill_if_empty!(repository_download_url);
1133        fill_if_empty!(api_data_url);
1134
1135        for party in &package_data.parties {
1136            if let Some(existing) = self.parties.iter_mut().find(|p| {
1137                p.role == party.role
1138                    && ((p.name.is_some() && p.name == party.name)
1139                        || (p.email.is_some() && p.email == party.email))
1140            }) {
1141                if existing.name.is_none() {
1142                    existing.name = party.name.clone();
1143                }
1144                if existing.email.is_none() {
1145                    existing.email = party.email.clone();
1146                }
1147            } else {
1148                self.parties.push(party.clone());
1149            }
1150        }
1151
1152        for keyword in &package_data.keywords {
1153            if !self.keywords.contains(keyword) {
1154                self.keywords.push(keyword.clone());
1155            }
1156        }
1157
1158        for detection in &package_data.license_detections {
1159            self.license_detections.push(detection.clone());
1160        }
1161
1162        for detection in &package_data.other_license_detections {
1163            self.other_license_detections.push(detection.clone());
1164        }
1165
1166        for source_pkg in &package_data.source_packages {
1167            if !self.source_packages.contains(source_pkg) {
1168                self.source_packages.push(source_pkg.clone());
1169            }
1170        }
1171
1172        self.refresh_identity();
1173    }
1174
1175    pub fn backfill_license_provenance(&mut self) {
1176        let Some(datafile_path) = self.datafile_paths.first().cloned() else {
1177            return;
1178        };
1179
1180        for detection in &mut self.license_detections {
1181            enrich_license_detection_provenance(detection, &datafile_path);
1182        }
1183        for detection in &mut self.other_license_detections {
1184            enrich_license_detection_provenance(detection, &datafile_path);
1185        }
1186    }
1187
1188    fn refresh_identity(&mut self) {
1189        let Some(next_purl) = self.build_current_purl() else {
1190            return;
1191        };
1192        // Canonicalize via the central per-type normalizer so the identity and
1193        // its derived `package_uid` (the dedup key) are spec-compliant
1194        // regardless of which parser produced the PURL.
1195        let next_purl = crate::models::normalize_purl(&next_purl);
1196
1197        if self.purl.as_deref() != Some(next_purl.as_str()) || self.package_uid.is_empty() {
1198            self.package_uid = PackageUid::new(&next_purl);
1199        }
1200
1201        self.purl = Some(next_purl);
1202    }
1203
1204    fn fallback_package_uid(&self) -> PackageUid {
1205        let name = self
1206            .name
1207            .as_deref()
1208            .map(str::trim)
1209            .filter(|value| !value.is_empty())
1210            .unwrap_or("unknown");
1211        let version = self
1212            .version
1213            .as_deref()
1214            .map(str::trim)
1215            .filter(|value| !value.is_empty())
1216            .unwrap_or("unknown");
1217        let datasource = self
1218            .datasource_ids
1219            .first()
1220            .map(DatasourceId::as_str)
1221            .unwrap_or("unknown");
1222
1223        PackageUid::new_opaque(&format!("generated-package:{datasource}/{name}@{version}"))
1224    }
1225
1226    fn build_current_purl(&self) -> Option<String> {
1227        if let Some(existing_purl) = self.purl.as_deref() {
1228            let mut purl = PackageUrl::from_str(existing_purl).ok()?;
1229
1230            if let Some(version) = self
1231                .version
1232                .as_deref()
1233                .filter(|value| !value.trim().is_empty())
1234            {
1235                purl.with_version(version).ok()?;
1236            } else {
1237                purl.without_version();
1238            }
1239
1240            return Some(purl.to_string());
1241        }
1242
1243        if let (Some(package_type), Some(name)) = (
1244            self.package_type.as_ref(),
1245            self.name
1246                .as_deref()
1247                .filter(|value| !value.trim().is_empty()),
1248        ) {
1249            let purl_type = match package_type {
1250                PackageType::Deno => "generic",
1251                _ => package_type.as_str(),
1252            };
1253
1254            let has_namespace = self
1255                .namespace
1256                .as_deref()
1257                .is_some_and(|value| !value.trim().is_empty());
1258
1259            // swift requires a namespace (VCS host + owner); without one, omit
1260            // the identity rather than reconstruct a spec-invalid, name-only PURL.
1261            if purl_type == "swift" && !has_namespace {
1262                return None;
1263            }
1264
1265            let mut purl = PackageUrl::new(purl_type, name).ok()?;
1266
1267            if let Some(namespace) = self
1268                .namespace
1269                .as_deref()
1270                .filter(|value| !value.trim().is_empty())
1271            {
1272                purl.with_namespace(namespace).ok()?;
1273            }
1274
1275            if let Some(version) = self
1276                .version
1277                .as_deref()
1278                .filter(|value| !value.trim().is_empty())
1279            {
1280                purl.with_version(version).ok()?;
1281            }
1282
1283            if let Some(qualifiers) = &self.qualifiers {
1284                for (key, value) in qualifiers {
1285                    purl.add_qualifier(key.as_str(), value.as_str()).ok()?;
1286                }
1287            }
1288
1289            if let Some(subpath) = self
1290                .subpath
1291                .as_deref()
1292                .filter(|value| !value.trim().is_empty())
1293            {
1294                purl.with_subpath(subpath).ok()?;
1295            }
1296
1297            return Some(purl.to_string());
1298        }
1299        None
1300    }
1301}
1302
1303#[cfg(test)]
1304mod tests {
1305    use super::*;
1306
1307    #[test]
1308    fn file_info_new_backfills_package_detection_provenance() {
1309        let package_data = PackageData {
1310            package_type: Some(PackageType::Npm),
1311            license_detections: vec![LicenseDetection {
1312                license_expression: "mit".to_string(),
1313                license_expression_spdx: "MIT".to_string(),
1314                matches: vec![Match {
1315                    license_expression: "mit".to_string(),
1316                    license_expression_spdx: "MIT".to_string(),
1317                    from_file: None,
1318                    start_line: LineNumber::ONE,
1319                    end_line: LineNumber::ONE,
1320                    matcher: MatcherKind::Declared,
1321                    score: MatchScore::MAX,
1322                    matched_length: Some(1),
1323                    match_coverage: Some(100.0),
1324                    rule_relevance: Some(100),
1325                    rule_identifier: String::new(),
1326                    rule_url: None,
1327                    matched_text: Some("MIT".to_string()),
1328                    referenced_filenames: None,
1329                    matched_text_diagnostics: None,
1330                }],
1331                detection_log: vec![],
1332                identifier: String::new(),
1333            }],
1334            ..PackageData::default()
1335        };
1336
1337        let file_info = FileInfo::new(
1338            "package.json".to_string(),
1339            "package".to_string(),
1340            ".json".to_string(),
1341            "project/package.json".to_string(),
1342            FileType::File,
1343            None,
1344            None,
1345            1,
1346            None,
1347            None,
1348            None,
1349            None,
1350            None,
1351            vec![package_data],
1352            None,
1353            vec![],
1354            vec![],
1355            vec![],
1356            vec![],
1357            vec![],
1358            vec![],
1359            vec![],
1360            vec![],
1361            vec![],
1362        );
1363
1364        assert_eq!(file_info.license_detections.len(), 1);
1365        assert_eq!(
1366            file_info.license_detections[0].matches[0]
1367                .from_file
1368                .as_deref(),
1369            Some("project/package.json")
1370        );
1371        assert!(!file_info.license_detections[0].identifier.is_empty());
1372        assert_eq!(
1373            file_info.package_data[0].license_detections[0].matches[0]
1374                .from_file
1375                .as_deref(),
1376            Some("project/package.json")
1377        );
1378        assert_eq!(
1379            file_info.package_data[0].license_detections[0].matches[0].rule_identifier,
1380            "parser-declared-license"
1381        );
1382        assert!(
1383            !file_info.package_data[0].license_detections[0]
1384                .identifier
1385                .is_empty()
1386        );
1387    }
1388
1389    #[test]
1390    fn file_info_new_separates_key_and_spdx_detected_expressions() {
1391        // The key field carries ScanCode keys; the SPDX field carries SPDX ids, both
1392        // derived from the same detections, even when the two spellings differ.
1393        let detection = LicenseDetection {
1394            license_expression: "bsl-1.1".to_string(),
1395            license_expression_spdx: "BUSL-1.1".to_string(),
1396            matches: vec![],
1397            detection_log: vec![],
1398            identifier: String::new(),
1399        };
1400
1401        let file_info = FileInfo::new(
1402            "LICENSE".to_string(),
1403            "LICENSE".to_string(),
1404            String::new(),
1405            "project/LICENSE".to_string(),
1406            FileType::File,
1407            None,
1408            None,
1409            1,
1410            None,
1411            None,
1412            None,
1413            None,
1414            None,
1415            vec![],
1416            None,
1417            vec![detection],
1418            vec![],
1419            vec![],
1420            vec![],
1421            vec![],
1422            vec![],
1423            vec![],
1424            vec![],
1425            vec![],
1426        );
1427
1428        assert_eq!(
1429            file_info.detected_license_expression.as_deref(),
1430            Some("bsl-1.1")
1431        );
1432        assert_eq!(
1433            file_info.detected_license_expression_spdx.as_deref(),
1434            Some("BUSL-1.1")
1435        );
1436    }
1437
1438    #[test]
1439    fn detected_license_expression_spdx_absent_when_no_detection_spdx() {
1440        // A detection whose SPDX form is empty (e.g. a custom/unmapped license) leaves
1441        // the SPDX field absent rather than echoing the key form.
1442        let detection = LicenseDetection {
1443            license_expression: "proprietary-license".to_string(),
1444            license_expression_spdx: String::new(),
1445            matches: vec![],
1446            detection_log: vec![],
1447            identifier: String::new(),
1448        };
1449        assert_eq!(
1450            detected_license_expression_spdx_from_detections(&[detection]),
1451            None
1452        );
1453    }
1454
1455    #[test]
1456    fn package_from_package_data_backfills_detection_provenance() {
1457        let package_data = PackageData {
1458            package_type: Some(PackageType::Npm),
1459            license_detections: vec![LicenseDetection {
1460                license_expression: "mit".to_string(),
1461                license_expression_spdx: "MIT".to_string(),
1462                matches: vec![Match {
1463                    license_expression: "mit".to_string(),
1464                    license_expression_spdx: "MIT".to_string(),
1465                    from_file: None,
1466                    start_line: LineNumber::ONE,
1467                    end_line: LineNumber::ONE,
1468                    matcher: MatcherKind::Declared,
1469                    score: MatchScore::MAX,
1470                    matched_length: Some(1),
1471                    match_coverage: Some(100.0),
1472                    rule_relevance: Some(100),
1473                    rule_identifier: String::new(),
1474                    rule_url: None,
1475                    matched_text: Some("MIT".to_string()),
1476                    referenced_filenames: None,
1477                    matched_text_diagnostics: None,
1478                }],
1479                detection_log: vec![],
1480                identifier: String::new(),
1481            }],
1482            ..PackageData::default()
1483        };
1484
1485        let package = Package::from_package_data(&package_data, "project/package.json".to_string());
1486
1487        assert_eq!(
1488            package.license_detections[0].matches[0]
1489                .from_file
1490                .as_deref(),
1491            Some("project/package.json")
1492        );
1493        assert_eq!(
1494            package.license_detections[0].matches[0].rule_identifier,
1495            "parser-declared-license"
1496        );
1497        assert!(!package.license_detections[0].identifier.is_empty());
1498    }
1499
1500    #[test]
1501    fn package_from_package_data_preserves_existing_purl_qualifiers() {
1502        let package_data = PackageData {
1503            package_type: Some(PackageType::Apk),
1504            namespace: Some("alpine".to_string()),
1505            name: Some("busybox".to_string()),
1506            version: Some("1.35.0-r17".to_string()),
1507            purl: Some("pkg:alpine/busybox@1.35.0-r17?arch=x86_64".to_string()),
1508            ..PackageData::default()
1509        };
1510
1511        let package = Package::from_package_data(&package_data, "lib/apk/db/installed".to_string());
1512
1513        assert_eq!(
1514            package.purl.as_deref(),
1515            Some("pkg:alpine/busybox@1.35.0-r17?arch=x86_64")
1516        );
1517        assert!(
1518            package
1519                .package_uid
1520                .starts_with("pkg:alpine/busybox@1.35.0-r17?arch=x86_64&uuid=")
1521        );
1522    }
1523}
1524
1525/// Top-level dependency instance, created during package assembly.
1526///
1527/// Extends the file-level `Dependency` with traceability fields that link
1528/// each dependency to its owning package and source datafile.
1529#[derive(Serialize, Deserialize, Debug, Clone)]
1530pub struct TopLevelDependency {
1531    pub purl: Option<String>,
1532    pub extracted_requirement: Option<String>,
1533    pub scope: Option<String>,
1534    pub is_runtime: Option<bool>,
1535    pub is_optional: Option<bool>,
1536    pub is_pinned: Option<bool>,
1537    pub is_direct: Option<bool>,
1538    pub resolved_package: Option<Box<ResolvedPackage>>,
1539    #[serde(default, with = "super::json_value_map")]
1540    pub extra_data: Option<HashMap<String, serde_json::Value>>,
1541    /// Unique identifier for this dependency instance (PURL with UUID qualifier).
1542    pub dependency_uid: DependencyUid,
1543    /// The `package_uid` of the package this dependency belongs to.
1544    pub for_package_uid: Option<PackageUid>,
1545    /// Path to the datafile where this dependency was declared.
1546    pub datafile_path: String,
1547    /// Datasource identifier for the parser that extracted this dependency.
1548    pub datasource_id: DatasourceId,
1549    /// Namespace for the dependency (e.g., distribution name for RPM packages).
1550    pub namespace: Option<String>,
1551}
1552
1553impl TopLevelDependency {
1554    /// Create a `TopLevelDependency` from a file-level `Dependency`.
1555    pub fn from_dependency(
1556        dep: &Dependency,
1557        datafile_path: String,
1558        datasource_id: DatasourceId,
1559        for_package_uid: Option<PackageUid>,
1560    ) -> Self {
1561        let dependency_uid = dep
1562            .purl
1563            .as_ref()
1564            .map(|p| DependencyUid::new(p))
1565            .unwrap_or_else(DependencyUid::empty);
1566
1567        TopLevelDependency {
1568            purl: dep.purl.clone(),
1569            extracted_requirement: dep.extracted_requirement.clone(),
1570            scope: dep.scope.clone(),
1571            is_runtime: dep.is_runtime,
1572            is_optional: dep.is_optional,
1573            is_pinned: dep.is_pinned,
1574            is_direct: dep.is_direct,
1575            resolved_package: dep.resolved_package.clone(),
1576            extra_data: dep.extra_data.clone(),
1577            dependency_uid,
1578            for_package_uid,
1579            datafile_path,
1580            datasource_id,
1581            namespace: None,
1582        }
1583    }
1584}
1585
1586#[derive(Serialize, Deserialize, Debug, Clone)]
1587pub struct OutputEmail {
1588    pub email: String,
1589    pub start_line: LineNumber,
1590    pub end_line: LineNumber,
1591}
1592
1593#[derive(Serialize, Deserialize, Debug, Clone)]
1594pub struct OutputURL {
1595    pub url: String,
1596    pub start_line: LineNumber,
1597    pub end_line: LineNumber,
1598}
1599
1600#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1601pub struct LicensePolicyEntry {
1602    pub license_key: String,
1603    #[serde(default)]
1604    pub label: String,
1605    #[serde(default)]
1606    pub color_code: String,
1607    #[serde(default)]
1608    pub icon: String,
1609    /// Provenant extension: optional machine-readable compliance severity used by
1610    /// the `--fail-on` build gate and SARIF output. Absent means informational.
1611    #[serde(default, skip_serializing_if = "Option::is_none")]
1612    pub compliance_alert: Option<ComplianceAlert>,
1613}
1614
1615/// Severity attached to a license policy entry. Ordered `Error` > `Warning`.
1616#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1617#[serde(rename_all = "lowercase")]
1618pub enum ComplianceAlert {
1619    // Ordering matters: derived Ord ranks later variants higher, so Warning < Error.
1620    Warning,
1621    Error,
1622}
1623
1624#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1625pub enum FileType {
1626    File,
1627    Directory,
1628}