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