Skip to main content

provenant/output_schema/
file_info.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use serde::{Deserialize, Serialize, Serializer};
5use serde_json::Map;
6
7use super::author::OutputAuthor;
8use super::copyright::OutputCopyright;
9use super::email::OutputEmail;
10use super::file_type::OutputFileType;
11use super::holder::OutputHolder;
12use super::license_detection::OutputLicenseDetection;
13use super::license_match::OutputMatch;
14use super::license_policy_entry::OutputLicensePolicyEntry;
15use super::package_data::OutputPackageData;
16use super::serde_helpers::insert_json;
17use super::tallies::OutputTallies;
18use super::url::OutputURL;
19
20#[derive(Debug, Clone, Deserialize)]
21pub struct OutputFileInfo {
22    #[serde(default)]
23    pub name: String,
24    #[serde(default)]
25    pub base_name: String,
26    #[serde(default)]
27    pub extension: String,
28    pub path: String,
29    #[serde(rename = "type")]
30    pub file_type: OutputFileType,
31    pub mime_type: Option<String>,
32    #[serde(rename = "file_type")]
33    pub file_type_label: Option<String>,
34    #[serde(default)]
35    pub size: u64,
36    pub date: Option<String>,
37    pub sha1: Option<String>,
38    pub md5: Option<String>,
39    pub sha256: Option<String>,
40    pub sha1_git: Option<String>,
41    pub programming_language: Option<String>,
42    #[serde(default)]
43    pub package_data: Vec<OutputPackageData>,
44    #[serde(rename = "detected_license_expression_spdx")]
45    pub license_expression: Option<String>,
46    /// Carried SPDX-form counterpart of [`Self::license_expression`], used as the
47    /// final fallback by [`Self::detected_license_expression_spdx`] when no
48    /// detection supplies an SPDX form. Not serialized directly; the SPDX JSON
49    /// field is produced by the accessor.
50    #[serde(skip)]
51    pub license_expression_spdx: Option<String>,
52    #[serde(default)]
53    pub license_detections: Vec<OutputLicenseDetection>,
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub license_clues: Vec<OutputMatch>,
56    pub percentage_of_license_text: Option<f64>,
57    #[serde(default)]
58    pub copyrights: Vec<OutputCopyright>,
59    #[serde(default)]
60    pub holders: Vec<OutputHolder>,
61    #[serde(default)]
62    pub authors: Vec<OutputAuthor>,
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub emails: Vec<OutputEmail>,
65    #[serde(default)]
66    pub urls: Vec<OutputURL>,
67    #[serde(default)]
68    pub for_packages: Vec<String>,
69    #[serde(default)]
70    pub scan_errors: Vec<String>,
71    pub license_policy: Option<Vec<OutputLicensePolicyEntry>>,
72    pub is_generated: Option<bool>,
73    pub is_binary: Option<bool>,
74    pub is_text: Option<bool>,
75    pub is_archive: Option<bool>,
76    pub is_media: Option<bool>,
77    pub is_source: Option<bool>,
78    pub is_script: Option<bool>,
79    pub files_count: Option<usize>,
80    pub dirs_count: Option<usize>,
81    pub size_count: Option<u64>,
82    pub source_count: Option<usize>,
83    #[serde(default, skip_serializing_if = "is_false")]
84    pub is_legal: bool,
85    #[serde(default, skip_serializing_if = "is_false")]
86    pub is_manifest: bool,
87    #[serde(default, skip_serializing_if = "is_false")]
88    pub is_readme: bool,
89    #[serde(default, skip_serializing_if = "is_false")]
90    pub is_top_level: bool,
91    #[serde(default, skip_serializing_if = "is_false")]
92    pub is_key_file: bool,
93    #[serde(default, skip_serializing_if = "is_false")]
94    pub is_referenced: bool,
95    #[serde(default, skip_serializing_if = "is_false")]
96    pub is_community: bool,
97    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98    pub facets: Vec<String>,
99    pub tallies: Option<OutputTallies>,
100}
101
102impl OutputFileInfo {
103    pub(crate) fn should_serialize_info_surface(&self) -> bool {
104        self.date.is_some()
105            || self.sha1.is_some()
106            || self.md5.is_some()
107            || self.sha256.is_some()
108            || self.sha1_git.is_some()
109            || self.mime_type.is_some()
110            || self.file_type_label.is_some()
111            || self.programming_language.is_some()
112            || self.is_binary.is_some()
113            || self.is_text.is_some()
114            || self.is_archive.is_some()
115            || self.is_media.is_some()
116            || self.is_source.is_some()
117            || self.is_script.is_some()
118            || self.files_count.is_some()
119            || self.dirs_count.is_some()
120            || self.size_count.is_some()
121    }
122
123    pub(crate) fn should_serialize_license_surface(&self) -> bool {
124        self.license_expression.is_some()
125            || !self.license_detections.is_empty()
126            || !self.license_clues.is_empty()
127            || self.percentage_of_license_text.is_some()
128    }
129
130    /// The scancode-key counterpart of [`Self::detected_license_expression_spdx`].
131    /// Mirrors the same three-tier fallback (file detections, then package-data
132    /// detections, then the carried expression) but on the non-SPDX
133    /// `license_expression` and with the non-strict combiner, since scancode keys
134    /// such as `proprietary-license` are not valid SPDX tokens.
135    pub(crate) fn detected_license_expression(&self) -> Option<String> {
136        let combine = |expressions: Vec<String>| -> Option<String> {
137            let expressions: Vec<String> = expressions
138                .into_iter()
139                .filter(|expression| !expression.is_empty())
140                .collect();
141            if expressions.is_empty() {
142                return None;
143            }
144            crate::utils::spdx::select_primary_license_expression(expressions.clone()).or_else(
145                || {
146                    crate::utils::spdx::combine_license_expressions_preserving_structure(
147                        expressions,
148                    )
149                },
150            )
151        };
152
153        combine(
154            self.license_detections
155                .iter()
156                .map(|detection| detection.license_expression.clone())
157                .collect(),
158        )
159        .or_else(|| {
160            combine(
161                self.package_data
162                    .iter()
163                    .flat_map(|package_data| package_data.license_detections.iter())
164                    .map(|detection| detection.license_expression.clone())
165                    .collect(),
166            )
167        })
168        .or_else(|| {
169            self.license_expression
170                .clone()
171                .filter(|expression| !expression.is_empty())
172        })
173    }
174
175    pub(crate) fn detected_license_expression_spdx(&self) -> Option<String> {
176        {
177            let expressions: Option<Vec<String>> = self
178                .license_detections
179                .iter()
180                .map(|detection| {
181                    (!detection.license_expression_spdx.is_empty())
182                        .then(|| detection.license_expression_spdx.clone())
183                })
184                .collect();
185            expressions.and_then(|expressions| {
186                crate::utils::spdx::select_primary_license_expression_strict(expressions.clone())
187                    .or_else(|| {
188                        crate::utils::spdx::combine_license_expressions_preserving_structure_strict(
189                            expressions,
190                        )
191                    })
192            })
193        }
194        .or_else(|| {
195            let expressions: Option<Vec<String>> = self
196                .package_data
197                .iter()
198                .flat_map(|package_data| package_data.license_detections.iter())
199                .map(|detection| {
200                    (!detection.license_expression_spdx.is_empty())
201                        .then(|| detection.license_expression_spdx.clone())
202                })
203                .collect();
204            expressions.and_then(|expressions| {
205                crate::utils::spdx::select_primary_license_expression_strict(expressions.clone())
206                    .or_else(|| {
207                        crate::utils::spdx::combine_license_expressions_preserving_structure_strict(
208                            expressions,
209                        )
210                    })
211            })
212        })
213        .or_else(|| {
214            // Final fallback: the carried SPDX-form expression. The key-form
215            // `license_expression` is not run through the SPDX combiner here, as a
216            // key token (e.g. `bsd-new`) is not a valid SPDX id and would leak
217            // key-form text into the SPDX field.
218            self.license_expression_spdx
219                .clone()
220                .filter(|expression| !expression.is_empty())
221                .and_then(|expression| {
222                    crate::utils::spdx::combine_license_expressions_preserving_structure_strict([
223                        expression,
224                    ])
225                })
226        })
227    }
228}
229
230impl Serialize for OutputFileInfo {
231    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
232    where
233        S: Serializer,
234    {
235        let mut map = Map::new();
236        insert_json(&mut map, "path", &self.path)?;
237        insert_json(&mut map, "type", self.file_type)?;
238        insert_json(&mut map, "name", &self.name)?;
239        insert_json(&mut map, "base_name", &self.base_name)?;
240        insert_json(&mut map, "extension", &self.extension)?;
241        insert_json(&mut map, "size", self.size)?;
242
243        if self.should_serialize_info_surface() {
244            insert_json(&mut map, "date", &self.date)?;
245            insert_json(&mut map, "sha1", self.sha1.as_ref())?;
246            insert_json(&mut map, "md5", self.md5.as_ref())?;
247            insert_json(&mut map, "sha256", self.sha256.as_ref())?;
248            insert_json(&mut map, "sha1_git", self.sha1_git.as_ref())?;
249            insert_json(&mut map, "mime_type", &self.mime_type)?;
250            insert_json(&mut map, "file_type", &self.file_type_label)?;
251            insert_json(&mut map, "programming_language", &self.programming_language)?;
252            insert_json(&mut map, "is_binary", self.is_binary)?;
253            insert_json(&mut map, "is_text", self.is_text)?;
254            insert_json(&mut map, "is_archive", self.is_archive)?;
255            insert_json(&mut map, "is_media", self.is_media)?;
256            insert_json(&mut map, "is_source", self.is_source)?;
257            insert_json(&mut map, "is_script", self.is_script)?;
258            insert_json(&mut map, "files_count", self.files_count)?;
259            insert_json(&mut map, "dirs_count", self.dirs_count)?;
260            insert_json(&mut map, "size_count", self.size_count)?;
261        }
262
263        insert_json(&mut map, "package_data", &self.package_data)?;
264        insert_json(
265            &mut map,
266            "detected_license_expression",
267            self.detected_license_expression(),
268        )?;
269        insert_json(
270            &mut map,
271            "detected_license_expression_spdx",
272            self.detected_license_expression_spdx(),
273        )?;
274        insert_json(&mut map, "license_detections", &self.license_detections)?;
275        if self.should_serialize_license_surface() {
276            insert_json(&mut map, "license_clues", &self.license_clues)?;
277        }
278        if self.percentage_of_license_text.is_some() {
279            insert_json(
280                &mut map,
281                "percentage_of_license_text",
282                self.percentage_of_license_text,
283            )?;
284        }
285        insert_json(&mut map, "copyrights", &self.copyrights)?;
286        insert_json(&mut map, "holders", &self.holders)?;
287        insert_json(&mut map, "authors", &self.authors)?;
288        if !self.emails.is_empty() {
289            insert_json(&mut map, "emails", &self.emails)?;
290        }
291        insert_json(&mut map, "urls", &self.urls)?;
292        insert_json(&mut map, "for_packages", &self.for_packages)?;
293        insert_json(&mut map, "scan_errors", &self.scan_errors)?;
294        if self.license_policy.is_some() {
295            insert_json(&mut map, "license_policy", &self.license_policy)?;
296        }
297        if self.is_generated.is_some() {
298            insert_json(&mut map, "is_generated", self.is_generated)?;
299        }
300        if self.source_count.is_some() {
301            insert_json(&mut map, "source_count", self.source_count)?;
302        }
303        if self.is_legal {
304            insert_json(&mut map, "is_legal", self.is_legal)?;
305        }
306        if self.is_manifest {
307            insert_json(&mut map, "is_manifest", self.is_manifest)?;
308        }
309        if self.is_readme {
310            insert_json(&mut map, "is_readme", self.is_readme)?;
311        }
312        if self.is_top_level {
313            insert_json(&mut map, "is_top_level", self.is_top_level)?;
314        }
315        if self.is_key_file {
316            insert_json(&mut map, "is_key_file", self.is_key_file)?;
317        }
318        if self.is_referenced {
319            insert_json(&mut map, "is_referenced", self.is_referenced)?;
320        }
321        if self.is_community {
322            insert_json(&mut map, "is_community", self.is_community)?;
323        }
324        if !self.facets.is_empty() {
325            insert_json(&mut map, "facets", &self.facets)?;
326        }
327        if self.tallies.is_some() {
328            insert_json(&mut map, "tallies", &self.tallies)?;
329        }
330
331        map.serialize(serializer)
332    }
333}
334
335impl From<&crate::models::FileInfo> for OutputFileInfo {
336    fn from(value: &crate::models::FileInfo) -> Self {
337        Self::from_with_compat_mode(value, crate::cli::CompatibilityMode::Native)
338    }
339}
340
341impl OutputFileInfo {
342    pub fn from_with_compat_mode(
343        value: &crate::models::FileInfo,
344        mode: crate::cli::CompatibilityMode,
345    ) -> Self {
346        Self {
347            name: value.name.clone(),
348            base_name: value.base_name.clone(),
349            extension: value.extension.clone(),
350            path: value.path.clone(),
351            file_type: OutputFileType::from(&value.file_type),
352            mime_type: value.mime_type.clone(),
353            file_type_label: value.file_type_label.clone(),
354            size: value.size,
355            date: value.date.clone(),
356            sha1: value.sha1.as_ref().map(|d| d.as_hex()),
357            md5: value.md5.as_ref().map(|d| d.as_hex()),
358            sha256: value.sha256.as_ref().map(|d| d.as_hex()),
359            sha1_git: value.sha1_git.as_ref().map(|d| d.as_hex()),
360            programming_language: value.programming_language.clone(),
361            package_data: value
362                .package_data
363                .iter()
364                .map(OutputPackageData::from)
365                .collect(),
366            license_expression: value.detected_license_expression.clone(),
367            license_expression_spdx: value.detected_license_expression_spdx.clone(),
368            license_detections: value
369                .license_detections
370                .iter()
371                .map(OutputLicenseDetection::from)
372                .collect(),
373            license_clues: value.license_clues.iter().map(OutputMatch::from).collect(),
374            percentage_of_license_text: value.percentage_of_license_text,
375            copyrights: value
376                .copyrights
377                .iter()
378                .map(|copyright| OutputCopyright::from_with_compat_mode(copyright, mode))
379                .collect(),
380            holders: value.holders.iter().map(OutputHolder::from).collect(),
381            authors: value.authors.iter().map(OutputAuthor::from).collect(),
382            emails: value.emails.iter().map(OutputEmail::from).collect(),
383            urls: value.urls.iter().map(OutputURL::from).collect(),
384            for_packages: value
385                .for_packages
386                .iter()
387                .map(|uid| uid.to_string())
388                .collect(),
389            scan_errors: value
390                .scan_diagnostics
391                .iter()
392                .filter(|d| d.severity != crate::models::DiagnosticSeverity::Info)
393                .map(|d| d.message.clone())
394                .collect(),
395            license_policy: value
396                .license_policy
397                .as_ref()
398                .map(|v| v.iter().map(OutputLicensePolicyEntry::from).collect()),
399            is_generated: value.is_generated,
400            is_binary: value.is_binary,
401            is_text: value.is_text,
402            is_archive: value.is_archive,
403            is_media: value.is_media,
404            is_source: value.is_source,
405            is_script: value.is_script,
406            files_count: value.files_count,
407            dirs_count: value.dirs_count,
408            size_count: value.size_count,
409            source_count: value.source_count,
410            is_legal: value.is_legal,
411            is_manifest: value.is_manifest,
412            is_readme: value.is_readme,
413            is_top_level: value.is_top_level,
414            is_key_file: value.is_key_file,
415            is_referenced: value.is_referenced,
416            is_community: value.is_community,
417            facets: value.facets.clone(),
418            tallies: value.tallies.as_ref().map(OutputTallies::from),
419        }
420    }
421}
422
423impl TryFrom<&OutputFileInfo> for crate::models::FileInfo {
424    type Error = String;
425    fn try_from(value: &OutputFileInfo) -> Result<Self, Self::Error> {
426        let mut package_data = Vec::with_capacity(value.package_data.len());
427        for p in &value.package_data {
428            package_data.push(crate::models::PackageData::try_from(p)?);
429        }
430        let mut license_detections = Vec::with_capacity(value.license_detections.len());
431        for d in &value.license_detections {
432            license_detections.push(crate::models::LicenseDetection::try_from(d)?);
433        }
434        let mut license_clues = Vec::with_capacity(value.license_clues.len());
435        for m in &value.license_clues {
436            license_clues.push(crate::models::Match::try_from(m)?);
437        }
438        let mut copyrights = Vec::with_capacity(value.copyrights.len());
439        for c in &value.copyrights {
440            copyrights.push(crate::models::Copyright::try_from(c)?);
441        }
442        let mut holders = Vec::with_capacity(value.holders.len());
443        for h in &value.holders {
444            holders.push(crate::models::Holder::try_from(h)?);
445        }
446        let mut authors = Vec::with_capacity(value.authors.len());
447        for a in &value.authors {
448            authors.push(crate::models::Author::try_from(a)?);
449        }
450        let mut emails = Vec::with_capacity(value.emails.len());
451        for e in &value.emails {
452            emails.push(crate::models::OutputEmail::try_from(e)?);
453        }
454        let mut urls = Vec::with_capacity(value.urls.len());
455        for u in &value.urls {
456            urls.push(crate::models::OutputURL::try_from(u)?);
457        }
458        let license_policy = value
459            .license_policy
460            .as_ref()
461            .map(|v| {
462                v.iter()
463                    .map(crate::models::LicensePolicyEntry::try_from)
464                    .collect::<Result<Vec<_>, _>>()
465            })
466            .transpose()?;
467        Ok(Self {
468            name: value.name.clone(),
469            base_name: value.base_name.clone(),
470            extension: value.extension.clone(),
471            path: value.path.clone(),
472            file_type: crate::models::FileType::try_from(value.file_type)?,
473            mime_type: value.mime_type.clone(),
474            file_type_label: value.file_type_label.clone(),
475            size: value.size,
476            date: value.date.clone(),
477            sha1: value
478                .sha1
479                .as_ref()
480                .map(|s| crate::models::Sha1Digest::from_hex(s))
481                .transpose()
482                .map_err(|e| format!("invalid sha1: {}", e))?,
483            md5: value
484                .md5
485                .as_ref()
486                .map(|s| crate::models::Md5Digest::from_hex(s))
487                .transpose()
488                .map_err(|e| format!("invalid md5: {}", e))?,
489            sha256: value
490                .sha256
491                .as_ref()
492                .map(|s| crate::models::Sha256Digest::from_hex(s))
493                .transpose()
494                .map_err(|e| format!("invalid sha256: {}", e))?,
495            sha1_git: value
496                .sha1_git
497                .as_ref()
498                .map(|s| crate::models::GitSha1::from_hex(s))
499                .transpose()
500                .map_err(|e| format!("invalid sha1_git: {}", e))?,
501            programming_language: value.programming_language.clone(),
502            package_data,
503            // `value.license_expression` carries the renamed `detected_license_expression_spdx`
504            // JSON field, i.e. the SPDX form. The key form is recovered only from the
505            // recombined detections (never from that SPDX string, which would leak SPDX
506            // text into the key field for a detection-less input); the SPDX field falls
507            // back to the carried SPDX string when no detection supplies one.
508            detected_license_expression:
509                crate::models::file_info::detected_license_expression_from_detections(
510                    &license_detections,
511                ),
512            detected_license_expression_spdx:
513                crate::models::file_info::detected_license_expression_spdx_from_detections(
514                    &license_detections,
515                )
516                .or_else(|| value.license_expression.clone()),
517            license_detections,
518            license_clues,
519            percentage_of_license_text: value.percentage_of_license_text,
520            copyrights,
521            holders,
522            authors,
523            emails,
524            urls,
525            for_packages: value
526                .for_packages
527                .iter()
528                .map(|s| crate::models::PackageUid::from_raw(s.clone()))
529                .collect(),
530            scan_diagnostics: crate::models::diagnostics_from_legacy_scan_errors(
531                &value.scan_errors,
532            ),
533            license_policy,
534            is_generated: value.is_generated,
535            is_binary: value.is_binary,
536            is_text: value.is_text,
537            is_archive: value.is_archive,
538            is_media: value.is_media,
539            is_source: value.is_source,
540            is_script: value.is_script,
541            files_count: value.files_count,
542            dirs_count: value.dirs_count,
543            size_count: value.size_count,
544            source_count: value.source_count,
545            is_legal: value.is_legal,
546            is_manifest: value.is_manifest,
547            is_readme: value.is_readme,
548            is_top_level: value.is_top_level,
549            is_key_file: value.is_key_file,
550            is_referenced: value.is_referenced,
551            is_community: value.is_community,
552            facets: value.facets.clone(),
553            tallies: value
554                .tallies
555                .as_ref()
556                .map(crate::models::Tallies::try_from)
557                .transpose()?,
558        })
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::OutputFileInfo;
565    use crate::models::{FileInfoBuilder, FileType, ScanDiagnostic};
566    use crate::output_schema::OutputFileType;
567    use crate::output_schema::license_detection::OutputLicenseDetection;
568    use serde_json::json;
569
570    fn file_info_with_diagnostics(diagnostics: Vec<ScanDiagnostic>) -> crate::models::FileInfo {
571        FileInfoBuilder::default()
572            .name("sample.bin".to_string())
573            .base_name("sample".to_string())
574            .extension(".bin".to_string())
575            .path("sample.bin".to_string())
576            .file_type(FileType::File)
577            .size(1)
578            .scan_diagnostics(diagnostics)
579            .build()
580            .expect("builder should produce file info")
581    }
582
583    #[test]
584    fn scan_errors_excludes_info_diagnostics_but_keeps_real_failures() {
585        let file_info = file_info_with_diagnostics(vec![
586            ScanDiagnostic::info("Text skipped from license/copyright detection: likely binary"),
587            ScanDiagnostic::error("PDF text extraction failed after 3 attempts"),
588            ScanDiagnostic::timeout("license detection timed out"),
589        ]);
590
591        let output = OutputFileInfo::from(&file_info);
592
593        assert_eq!(
594            output.scan_errors,
595            vec![
596                "PDF text extraction failed after 3 attempts".to_string(),
597                "license detection timed out".to_string(),
598            ],
599            "Info diagnostics must not surface as scan_errors, but real failures must"
600        );
601    }
602
603    #[test]
604    fn scan_errors_empty_when_only_info_diagnostics_present() {
605        let file_info = file_info_with_diagnostics(vec![ScanDiagnostic::info(
606            "Text skipped from license/copyright detection: likely binary",
607        )]);
608
609        let output = OutputFileInfo::from(&file_info);
610
611        assert!(
612            output.scan_errors.is_empty(),
613            "a benign binary-content skip must not produce any scan_errors"
614        );
615    }
616
617    fn base_output_file_info() -> OutputFileInfo {
618        OutputFileInfo {
619            name: "mod.rs".to_string(),
620            base_name: "mod".to_string(),
621            extension: ".rs".to_string(),
622            path: "mod.rs".to_string(),
623            file_type: OutputFileType::File,
624            mime_type: None,
625            file_type_label: None,
626            size: 0,
627            date: None,
628            sha1: None,
629            md5: None,
630            sha256: None,
631            sha1_git: None,
632            programming_language: None,
633            package_data: Vec::new(),
634            license_expression: None,
635            license_expression_spdx: None,
636            license_detections: Vec::new(),
637            license_clues: Vec::new(),
638            percentage_of_license_text: None,
639            copyrights: Vec::new(),
640            holders: Vec::new(),
641            authors: Vec::new(),
642            emails: Vec::new(),
643            urls: Vec::new(),
644            for_packages: Vec::new(),
645            scan_errors: Vec::new(),
646            license_policy: None,
647            is_generated: None,
648            is_binary: None,
649            is_text: None,
650            is_archive: None,
651            is_media: None,
652            is_source: None,
653            is_script: None,
654            files_count: None,
655            dirs_count: None,
656            size_count: None,
657            source_count: None,
658            is_legal: false,
659            is_manifest: false,
660            is_readme: false,
661            is_top_level: false,
662            is_key_file: false,
663            is_referenced: false,
664            is_community: false,
665            facets: Vec::new(),
666            tallies: None,
667        }
668    }
669
670    #[test]
671    fn detected_license_expression_spdx_does_not_recombine_partial_detection_spdx() {
672        // When the per-detection SPDX forms are partial (one operand lacks an SPDX id),
673        // the SPDX accessor does not recombine them; it falls back to the carried
674        // SPDX-form expression, never to the key-form `license_expression`.
675        let mut file_info = base_output_file_info();
676        file_info.license_expression = Some("apache-2.0 AND mit".to_string());
677        file_info.license_expression_spdx = Some("Apache-2.0 AND MIT".to_string());
678        file_info.license_detections = vec![
679            OutputLicenseDetection {
680                license_expression: "apache-2.0".to_string(),
681                license_expression_spdx: "Apache-2.0".to_string(),
682                matches: Vec::new(),
683                detection_log: Vec::new(),
684                identifier: None,
685            },
686            OutputLicenseDetection {
687                license_expression: "mit".to_string(),
688                license_expression_spdx: String::new(),
689                matches: Vec::new(),
690                detection_log: Vec::new(),
691                identifier: None,
692            },
693        ];
694
695        assert_eq!(
696            file_info.detected_license_expression_spdx().as_deref(),
697            Some("Apache-2.0 AND MIT")
698        );
699    }
700
701    #[test]
702    fn detected_license_expression_spdx_does_not_fall_back_to_key_form() {
703        // The key-form `license_expression` must never leak into the SPDX field. With
704        // no SPDX-form fallback set and no detection SPDX, the SPDX accessor is absent.
705        let mut file_info = base_output_file_info();
706        file_info.license_expression = Some("bsd-new".to_string());
707        file_info.license_expression_spdx = None;
708
709        assert_eq!(file_info.detected_license_expression_spdx(), None);
710        assert_eq!(
711            file_info.detected_license_expression().as_deref(),
712            Some("bsd-new")
713        );
714    }
715
716    #[test]
717    fn detected_license_expression_spdx_rejects_invalid_fallback_expression() {
718        let mut file_info = base_output_file_info();
719        file_info.license_expression_spdx = Some("MIT\" or malformed".to_string());
720
721        assert_eq!(file_info.detected_license_expression_spdx(), None);
722    }
723
724    #[test]
725    fn from_json_reshape_with_spdx_but_no_detections_keeps_forms_separated() {
726        // A reshaped (`--from-json`) record whose carried `license_expression` holds
727        // the SPDX JSON value but has no usable detections must NOT leak that SPDX text
728        // into the internal key-form field. The key field stays absent (no key form is
729        // recoverable from SPDX-only text); the SPDX field keeps the SPDX value.
730        let mut output = base_output_file_info();
731        output.license_expression = Some("Apache-2.0 AND MIT".to_string());
732        output.license_detections = Vec::new();
733
734        let internal = crate::models::FileInfo::try_from(&output).expect("reshape succeeds");
735        assert_eq!(internal.detected_license_expression, None);
736        assert_eq!(
737            internal.detected_license_expression_spdx.as_deref(),
738            Some("Apache-2.0 AND MIT")
739        );
740
741        // Re-serializing must not emit SPDX text in the key-form output field.
742        let reshaped = OutputFileInfo::from(&internal);
743        assert_eq!(reshaped.detected_license_expression(), None);
744        assert_eq!(
745            reshaped.detected_license_expression_spdx().as_deref(),
746            Some("Apache-2.0 AND MIT")
747        );
748    }
749
750    #[test]
751    fn from_json_reshape_recovers_key_form_from_detections() {
752        // With usable detections, the reshape recovers the key form from them (not from
753        // the SPDX carrier), and the SPDX field from the detections' SPDX form.
754        let mut output = base_output_file_info();
755        output.license_expression = Some("BUSL-1.1".to_string());
756        output.license_detections = vec![OutputLicenseDetection {
757            license_expression: "bsl-1.1".to_string(),
758            license_expression_spdx: "BUSL-1.1".to_string(),
759            matches: Vec::new(),
760            detection_log: Vec::new(),
761            identifier: None,
762        }];
763
764        let internal = crate::models::FileInfo::try_from(&output).expect("reshape succeeds");
765        assert_eq!(
766            internal.detected_license_expression.as_deref(),
767            Some("bsl-1.1")
768        );
769        assert_eq!(
770            internal.detected_license_expression_spdx.as_deref(),
771            Some("BUSL-1.1")
772        );
773    }
774
775    #[test]
776    fn serialize_includes_is_referenced_only_when_true() {
777        let mut file_info = base_output_file_info();
778        let without_flag = serde_json::to_value(&file_info).expect("file should serialize");
779        assert_eq!(
780            without_flag,
781            json!({
782                "path": "mod.rs",
783                "type": "file",
784                "name": "mod.rs",
785                "base_name": "mod",
786                "extension": ".rs",
787                "size": 0,
788                "package_data": [],
789                "detected_license_expression": null,
790                "detected_license_expression_spdx": null,
791                "license_detections": [],
792                "copyrights": [],
793                "holders": [],
794                "authors": [],
795                "urls": [],
796                "for_packages": [],
797                "scan_errors": []
798            })
799        );
800
801        file_info.is_referenced = true;
802        let with_flag = serde_json::to_value(&file_info).expect("file should serialize");
803        assert_eq!(with_flag["is_referenced"], json!(true));
804    }
805}