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    pub(crate) fn detected_license_expression_spdx(&self) -> Option<String> {
125        {
126            let expressions: Option<Vec<String>> = self
127                .license_detections
128                .iter()
129                .map(|detection| {
130                    (!detection.license_expression_spdx.is_empty())
131                        .then(|| detection.license_expression_spdx.clone())
132                })
133                .collect();
134            expressions.and_then(|expressions| {
135                crate::utils::spdx::select_primary_license_expression_strict(expressions.clone())
136                    .or_else(|| {
137                        crate::utils::spdx::combine_license_expressions_preserving_structure_strict(
138                            expressions,
139                        )
140                    })
141            })
142        }
143        .or_else(|| {
144            let expressions: Option<Vec<String>> = self
145                .package_data
146                .iter()
147                .flat_map(|package_data| package_data.license_detections.iter())
148                .map(|detection| {
149                    (!detection.license_expression_spdx.is_empty())
150                        .then(|| detection.license_expression_spdx.clone())
151                })
152                .collect();
153            expressions.and_then(|expressions| {
154                crate::utils::spdx::select_primary_license_expression_strict(expressions.clone())
155                    .or_else(|| {
156                        crate::utils::spdx::combine_license_expressions_preserving_structure_strict(
157                            expressions,
158                        )
159                    })
160            })
161        })
162        .or_else(|| {
163            self.license_expression
164                .clone()
165                .filter(|expression| !expression.is_empty())
166                .and_then(|expression| {
167                    crate::utils::spdx::combine_license_expressions_preserving_structure_strict([
168                        expression,
169                    ])
170                })
171        })
172    }
173}
174
175impl Serialize for OutputFileInfo {
176    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
177    where
178        S: Serializer,
179    {
180        let mut map = Map::new();
181        insert_json(&mut map, "path", &self.path)?;
182        insert_json(&mut map, "type", self.file_type)?;
183        insert_json(&mut map, "name", &self.name)?;
184        insert_json(&mut map, "base_name", &self.base_name)?;
185        insert_json(&mut map, "extension", &self.extension)?;
186        insert_json(&mut map, "size", self.size)?;
187
188        if self.should_serialize_info_surface() {
189            insert_json(&mut map, "date", &self.date)?;
190            insert_json(&mut map, "sha1", self.sha1.as_ref())?;
191            insert_json(&mut map, "md5", self.md5.as_ref())?;
192            insert_json(&mut map, "sha256", self.sha256.as_ref())?;
193            insert_json(&mut map, "sha1_git", self.sha1_git.as_ref())?;
194            insert_json(&mut map, "mime_type", &self.mime_type)?;
195            insert_json(&mut map, "file_type", &self.file_type_label)?;
196            insert_json(&mut map, "programming_language", &self.programming_language)?;
197            insert_json(&mut map, "is_binary", self.is_binary)?;
198            insert_json(&mut map, "is_text", self.is_text)?;
199            insert_json(&mut map, "is_archive", self.is_archive)?;
200            insert_json(&mut map, "is_media", self.is_media)?;
201            insert_json(&mut map, "is_source", self.is_source)?;
202            insert_json(&mut map, "is_script", self.is_script)?;
203            insert_json(&mut map, "files_count", self.files_count)?;
204            insert_json(&mut map, "dirs_count", self.dirs_count)?;
205            insert_json(&mut map, "size_count", self.size_count)?;
206        }
207
208        insert_json(&mut map, "package_data", &self.package_data)?;
209        insert_json(
210            &mut map,
211            "detected_license_expression_spdx",
212            self.detected_license_expression_spdx(),
213        )?;
214        insert_json(&mut map, "license_detections", &self.license_detections)?;
215        if self.should_serialize_license_surface() {
216            insert_json(&mut map, "license_clues", &self.license_clues)?;
217        }
218        if self.percentage_of_license_text.is_some() {
219            insert_json(
220                &mut map,
221                "percentage_of_license_text",
222                self.percentage_of_license_text,
223            )?;
224        }
225        insert_json(&mut map, "copyrights", &self.copyrights)?;
226        insert_json(&mut map, "holders", &self.holders)?;
227        insert_json(&mut map, "authors", &self.authors)?;
228        if !self.emails.is_empty() {
229            insert_json(&mut map, "emails", &self.emails)?;
230        }
231        insert_json(&mut map, "urls", &self.urls)?;
232        insert_json(&mut map, "for_packages", &self.for_packages)?;
233        insert_json(&mut map, "scan_errors", &self.scan_errors)?;
234        if self.license_policy.is_some() {
235            insert_json(&mut map, "license_policy", &self.license_policy)?;
236        }
237        if self.is_generated.is_some() {
238            insert_json(&mut map, "is_generated", self.is_generated)?;
239        }
240        if self.source_count.is_some() {
241            insert_json(&mut map, "source_count", self.source_count)?;
242        }
243        if self.is_legal {
244            insert_json(&mut map, "is_legal", self.is_legal)?;
245        }
246        if self.is_manifest {
247            insert_json(&mut map, "is_manifest", self.is_manifest)?;
248        }
249        if self.is_readme {
250            insert_json(&mut map, "is_readme", self.is_readme)?;
251        }
252        if self.is_top_level {
253            insert_json(&mut map, "is_top_level", self.is_top_level)?;
254        }
255        if self.is_key_file {
256            insert_json(&mut map, "is_key_file", self.is_key_file)?;
257        }
258        if self.is_referenced {
259            insert_json(&mut map, "is_referenced", self.is_referenced)?;
260        }
261        if self.is_community {
262            insert_json(&mut map, "is_community", self.is_community)?;
263        }
264        if !self.facets.is_empty() {
265            insert_json(&mut map, "facets", &self.facets)?;
266        }
267        if self.tallies.is_some() {
268            insert_json(&mut map, "tallies", &self.tallies)?;
269        }
270
271        map.serialize(serializer)
272    }
273}
274
275impl From<&crate::models::FileInfo> for OutputFileInfo {
276    fn from(value: &crate::models::FileInfo) -> Self {
277        Self::from_with_compat_mode(value, crate::cli::CompatibilityMode::Native)
278    }
279}
280
281impl OutputFileInfo {
282    pub fn from_with_compat_mode(
283        value: &crate::models::FileInfo,
284        mode: crate::cli::CompatibilityMode,
285    ) -> Self {
286        Self {
287            name: value.name.clone(),
288            base_name: value.base_name.clone(),
289            extension: value.extension.clone(),
290            path: value.path.clone(),
291            file_type: OutputFileType::from(&value.file_type),
292            mime_type: value.mime_type.clone(),
293            file_type_label: value.file_type_label.clone(),
294            size: value.size,
295            date: value.date.clone(),
296            sha1: value.sha1.as_ref().map(|d| d.as_hex()),
297            md5: value.md5.as_ref().map(|d| d.as_hex()),
298            sha256: value.sha256.as_ref().map(|d| d.as_hex()),
299            sha1_git: value.sha1_git.as_ref().map(|d| d.as_hex()),
300            programming_language: value.programming_language.clone(),
301            package_data: value
302                .package_data
303                .iter()
304                .map(OutputPackageData::from)
305                .collect(),
306            license_expression: value.detected_license_expression.clone(),
307            license_detections: value
308                .license_detections
309                .iter()
310                .map(OutputLicenseDetection::from)
311                .collect(),
312            license_clues: value.license_clues.iter().map(OutputMatch::from).collect(),
313            percentage_of_license_text: value.percentage_of_license_text,
314            copyrights: value
315                .copyrights
316                .iter()
317                .map(|copyright| OutputCopyright::from_with_compat_mode(copyright, mode))
318                .collect(),
319            holders: value.holders.iter().map(OutputHolder::from).collect(),
320            authors: value.authors.iter().map(OutputAuthor::from).collect(),
321            emails: value.emails.iter().map(OutputEmail::from).collect(),
322            urls: value.urls.iter().map(OutputURL::from).collect(),
323            for_packages: value
324                .for_packages
325                .iter()
326                .map(|uid| uid.to_string())
327                .collect(),
328            scan_errors: value
329                .scan_diagnostics
330                .iter()
331                .map(|d| d.message.clone())
332                .collect(),
333            license_policy: value
334                .license_policy
335                .as_ref()
336                .map(|v| v.iter().map(OutputLicensePolicyEntry::from).collect()),
337            is_generated: value.is_generated,
338            is_binary: value.is_binary,
339            is_text: value.is_text,
340            is_archive: value.is_archive,
341            is_media: value.is_media,
342            is_source: value.is_source,
343            is_script: value.is_script,
344            files_count: value.files_count,
345            dirs_count: value.dirs_count,
346            size_count: value.size_count,
347            source_count: value.source_count,
348            is_legal: value.is_legal,
349            is_manifest: value.is_manifest,
350            is_readme: value.is_readme,
351            is_top_level: value.is_top_level,
352            is_key_file: value.is_key_file,
353            is_referenced: value.is_referenced,
354            is_community: value.is_community,
355            facets: value.facets.clone(),
356            tallies: value.tallies.as_ref().map(OutputTallies::from),
357        }
358    }
359}
360
361impl TryFrom<&OutputFileInfo> for crate::models::FileInfo {
362    type Error = String;
363    fn try_from(value: &OutputFileInfo) -> Result<Self, Self::Error> {
364        let mut package_data = Vec::with_capacity(value.package_data.len());
365        for p in &value.package_data {
366            package_data.push(crate::models::PackageData::try_from(p)?);
367        }
368        let mut license_detections = Vec::with_capacity(value.license_detections.len());
369        for d in &value.license_detections {
370            license_detections.push(crate::models::LicenseDetection::try_from(d)?);
371        }
372        let mut license_clues = Vec::with_capacity(value.license_clues.len());
373        for m in &value.license_clues {
374            license_clues.push(crate::models::Match::try_from(m)?);
375        }
376        let mut copyrights = Vec::with_capacity(value.copyrights.len());
377        for c in &value.copyrights {
378            copyrights.push(crate::models::Copyright::try_from(c)?);
379        }
380        let mut holders = Vec::with_capacity(value.holders.len());
381        for h in &value.holders {
382            holders.push(crate::models::Holder::try_from(h)?);
383        }
384        let mut authors = Vec::with_capacity(value.authors.len());
385        for a in &value.authors {
386            authors.push(crate::models::Author::try_from(a)?);
387        }
388        let mut emails = Vec::with_capacity(value.emails.len());
389        for e in &value.emails {
390            emails.push(crate::models::OutputEmail::try_from(e)?);
391        }
392        let mut urls = Vec::with_capacity(value.urls.len());
393        for u in &value.urls {
394            urls.push(crate::models::OutputURL::try_from(u)?);
395        }
396        let license_policy = value
397            .license_policy
398            .as_ref()
399            .map(|v| {
400                v.iter()
401                    .map(crate::models::LicensePolicyEntry::try_from)
402                    .collect::<Result<Vec<_>, _>>()
403            })
404            .transpose()?;
405        Ok(Self {
406            name: value.name.clone(),
407            base_name: value.base_name.clone(),
408            extension: value.extension.clone(),
409            path: value.path.clone(),
410            file_type: crate::models::FileType::try_from(value.file_type)?,
411            mime_type: value.mime_type.clone(),
412            file_type_label: value.file_type_label.clone(),
413            size: value.size,
414            date: value.date.clone(),
415            sha1: value
416                .sha1
417                .as_ref()
418                .map(|s| crate::models::Sha1Digest::from_hex(s))
419                .transpose()
420                .map_err(|e| format!("invalid sha1: {}", e))?,
421            md5: value
422                .md5
423                .as_ref()
424                .map(|s| crate::models::Md5Digest::from_hex(s))
425                .transpose()
426                .map_err(|e| format!("invalid md5: {}", e))?,
427            sha256: value
428                .sha256
429                .as_ref()
430                .map(|s| crate::models::Sha256Digest::from_hex(s))
431                .transpose()
432                .map_err(|e| format!("invalid sha256: {}", e))?,
433            sha1_git: value
434                .sha1_git
435                .as_ref()
436                .map(|s| crate::models::GitSha1::from_hex(s))
437                .transpose()
438                .map_err(|e| format!("invalid sha1_git: {}", e))?,
439            programming_language: value.programming_language.clone(),
440            package_data,
441            detected_license_expression: value.license_expression.clone(),
442            license_detections,
443            license_clues,
444            percentage_of_license_text: value.percentage_of_license_text,
445            copyrights,
446            holders,
447            authors,
448            emails,
449            urls,
450            for_packages: value
451                .for_packages
452                .iter()
453                .map(|s| crate::models::PackageUid::from_raw(s.clone()))
454                .collect(),
455            scan_diagnostics: crate::models::diagnostics_from_legacy_scan_errors(
456                &value.scan_errors,
457            ),
458            license_policy,
459            is_generated: value.is_generated,
460            is_binary: value.is_binary,
461            is_text: value.is_text,
462            is_archive: value.is_archive,
463            is_media: value.is_media,
464            is_source: value.is_source,
465            is_script: value.is_script,
466            files_count: value.files_count,
467            dirs_count: value.dirs_count,
468            size_count: value.size_count,
469            source_count: value.source_count,
470            is_legal: value.is_legal,
471            is_manifest: value.is_manifest,
472            is_readme: value.is_readme,
473            is_top_level: value.is_top_level,
474            is_key_file: value.is_key_file,
475            is_referenced: value.is_referenced,
476            is_community: value.is_community,
477            facets: value.facets.clone(),
478            tallies: value
479                .tallies
480                .as_ref()
481                .map(crate::models::Tallies::try_from)
482                .transpose()?,
483        })
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::OutputFileInfo;
490    use crate::output_schema::OutputFileType;
491    use crate::output_schema::license_detection::OutputLicenseDetection;
492    use serde_json::json;
493
494    fn base_output_file_info() -> OutputFileInfo {
495        OutputFileInfo {
496            name: "mod.rs".to_string(),
497            base_name: "mod".to_string(),
498            extension: ".rs".to_string(),
499            path: "mod.rs".to_string(),
500            file_type: OutputFileType::File,
501            mime_type: None,
502            file_type_label: None,
503            size: 0,
504            date: None,
505            sha1: None,
506            md5: None,
507            sha256: None,
508            sha1_git: None,
509            programming_language: None,
510            package_data: Vec::new(),
511            license_expression: None,
512            license_detections: Vec::new(),
513            license_clues: Vec::new(),
514            percentage_of_license_text: None,
515            copyrights: Vec::new(),
516            holders: Vec::new(),
517            authors: Vec::new(),
518            emails: Vec::new(),
519            urls: Vec::new(),
520            for_packages: Vec::new(),
521            scan_errors: Vec::new(),
522            license_policy: None,
523            is_generated: None,
524            is_binary: None,
525            is_text: None,
526            is_archive: None,
527            is_media: None,
528            is_source: None,
529            is_script: None,
530            files_count: None,
531            dirs_count: None,
532            size_count: None,
533            source_count: None,
534            is_legal: false,
535            is_manifest: false,
536            is_readme: false,
537            is_top_level: false,
538            is_key_file: false,
539            is_referenced: false,
540            is_community: false,
541            facets: Vec::new(),
542            tallies: None,
543        }
544    }
545
546    #[test]
547    fn detected_license_expression_spdx_does_not_recombine_partial_detection_spdx() {
548        let mut file_info = base_output_file_info();
549        file_info.license_expression = Some("Apache-2.0 AND MIT".to_string());
550        file_info.license_detections = vec![
551            OutputLicenseDetection {
552                license_expression: "apache-2.0".to_string(),
553                license_expression_spdx: "Apache-2.0".to_string(),
554                matches: Vec::new(),
555                detection_log: Vec::new(),
556                identifier: None,
557            },
558            OutputLicenseDetection {
559                license_expression: "mit".to_string(),
560                license_expression_spdx: String::new(),
561                matches: Vec::new(),
562                detection_log: Vec::new(),
563                identifier: None,
564            },
565        ];
566
567        assert_eq!(
568            file_info.detected_license_expression_spdx().as_deref(),
569            Some("Apache-2.0 AND MIT")
570        );
571    }
572
573    #[test]
574    fn detected_license_expression_spdx_rejects_invalid_fallback_expression() {
575        let mut file_info = base_output_file_info();
576        file_info.license_expression = Some("MIT\" or malformed".to_string());
577
578        assert_eq!(file_info.detected_license_expression_spdx(), None);
579    }
580
581    #[test]
582    fn serialize_includes_is_referenced_only_when_true() {
583        let mut file_info = base_output_file_info();
584        let without_flag = serde_json::to_value(&file_info).expect("file should serialize");
585        assert_eq!(
586            without_flag,
587            json!({
588                "path": "mod.rs",
589                "type": "file",
590                "name": "mod.rs",
591                "base_name": "mod",
592                "extension": ".rs",
593                "size": 0,
594                "package_data": [],
595                "detected_license_expression_spdx": null,
596                "license_detections": [],
597                "copyrights": [],
598                "holders": [],
599                "authors": [],
600                "urls": [],
601                "for_packages": [],
602                "scan_errors": []
603            })
604        );
605
606        file_info.is_referenced = true;
607        let with_flag = serde_json::to_value(&file_info).expect("file should serialize");
608        assert_eq!(with_flag["is_referenced"], json!(true));
609    }
610}