1use 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(&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 .map(|d| d.message.clone())
382 .collect(),
383 license_policy: value
384 .license_policy
385 .as_ref()
386 .map(|v| v.iter().map(OutputLicensePolicyEntry::from).collect()),
387 is_generated: value.is_generated,
388 is_binary: value.is_binary,
389 is_text: value.is_text,
390 is_archive: value.is_archive,
391 is_media: value.is_media,
392 is_source: value.is_source,
393 is_script: value.is_script,
394 files_count: value.files_count,
395 dirs_count: value.dirs_count,
396 size_count: value.size_count,
397 source_count: value.source_count,
398 is_legal: value.is_legal,
399 is_manifest: value.is_manifest,
400 is_readme: value.is_readme,
401 is_top_level: value.is_top_level,
402 is_key_file: value.is_key_file,
403 is_referenced: value.is_referenced,
404 is_community: value.is_community,
405 facets: value.facets.clone(),
406 tallies: value.tallies.as_ref().map(OutputTallies::from),
407 }
408 }
409}
410
411impl TryFrom<&OutputFileInfo> for crate::models::FileInfo {
412 type Error = String;
413 fn try_from(value: &OutputFileInfo) -> Result<Self, Self::Error> {
414 let mut package_data = Vec::with_capacity(value.package_data.len());
415 for p in &value.package_data {
416 package_data.push(crate::models::PackageData::try_from(p)?);
417 }
418 let mut license_detections = Vec::with_capacity(value.license_detections.len());
419 for d in &value.license_detections {
420 license_detections.push(crate::models::LicenseDetection::try_from(d)?);
421 }
422 let mut license_clues = Vec::with_capacity(value.license_clues.len());
423 for m in &value.license_clues {
424 license_clues.push(crate::models::Match::try_from(m)?);
425 }
426 let mut copyrights = Vec::with_capacity(value.copyrights.len());
427 for c in &value.copyrights {
428 copyrights.push(crate::models::Copyright::try_from(c)?);
429 }
430 let mut holders = Vec::with_capacity(value.holders.len());
431 for h in &value.holders {
432 holders.push(crate::models::Holder::try_from(h)?);
433 }
434 let mut authors = Vec::with_capacity(value.authors.len());
435 for a in &value.authors {
436 authors.push(crate::models::Author::try_from(a)?);
437 }
438 let mut emails = Vec::with_capacity(value.emails.len());
439 for e in &value.emails {
440 emails.push(crate::models::OutputEmail::try_from(e)?);
441 }
442 let mut urls = Vec::with_capacity(value.urls.len());
443 for u in &value.urls {
444 urls.push(crate::models::OutputURL::try_from(u)?);
445 }
446 let license_policy = value
447 .license_policy
448 .as_ref()
449 .map(|v| {
450 v.iter()
451 .map(crate::models::LicensePolicyEntry::try_from)
452 .collect::<Result<Vec<_>, _>>()
453 })
454 .transpose()?;
455 Ok(Self {
456 name: value.name.clone(),
457 base_name: value.base_name.clone(),
458 extension: value.extension.clone(),
459 path: value.path.clone(),
460 file_type: crate::models::FileType::try_from(value.file_type)?,
461 mime_type: value.mime_type.clone(),
462 file_type_label: value.file_type_label.clone(),
463 size: value.size,
464 date: value.date.clone(),
465 sha1: value
466 .sha1
467 .as_ref()
468 .map(|s| crate::models::Sha1Digest::from_hex(s))
469 .transpose()
470 .map_err(|e| format!("invalid sha1: {}", e))?,
471 md5: value
472 .md5
473 .as_ref()
474 .map(|s| crate::models::Md5Digest::from_hex(s))
475 .transpose()
476 .map_err(|e| format!("invalid md5: {}", e))?,
477 sha256: value
478 .sha256
479 .as_ref()
480 .map(|s| crate::models::Sha256Digest::from_hex(s))
481 .transpose()
482 .map_err(|e| format!("invalid sha256: {}", e))?,
483 sha1_git: value
484 .sha1_git
485 .as_ref()
486 .map(|s| crate::models::GitSha1::from_hex(s))
487 .transpose()
488 .map_err(|e| format!("invalid sha1_git: {}", e))?,
489 programming_language: value.programming_language.clone(),
490 package_data,
491 detected_license_expression: value.license_expression.clone(),
492 license_detections,
493 license_clues,
494 percentage_of_license_text: value.percentage_of_license_text,
495 copyrights,
496 holders,
497 authors,
498 emails,
499 urls,
500 for_packages: value
501 .for_packages
502 .iter()
503 .map(|s| crate::models::PackageUid::from_raw(s.clone()))
504 .collect(),
505 scan_diagnostics: crate::models::diagnostics_from_legacy_scan_errors(
506 &value.scan_errors,
507 ),
508 license_policy,
509 is_generated: value.is_generated,
510 is_binary: value.is_binary,
511 is_text: value.is_text,
512 is_archive: value.is_archive,
513 is_media: value.is_media,
514 is_source: value.is_source,
515 is_script: value.is_script,
516 files_count: value.files_count,
517 dirs_count: value.dirs_count,
518 size_count: value.size_count,
519 source_count: value.source_count,
520 is_legal: value.is_legal,
521 is_manifest: value.is_manifest,
522 is_readme: value.is_readme,
523 is_top_level: value.is_top_level,
524 is_key_file: value.is_key_file,
525 is_referenced: value.is_referenced,
526 is_community: value.is_community,
527 facets: value.facets.clone(),
528 tallies: value
529 .tallies
530 .as_ref()
531 .map(crate::models::Tallies::try_from)
532 .transpose()?,
533 })
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::OutputFileInfo;
540 use crate::output_schema::OutputFileType;
541 use crate::output_schema::license_detection::OutputLicenseDetection;
542 use serde_json::json;
543
544 fn base_output_file_info() -> OutputFileInfo {
545 OutputFileInfo {
546 name: "mod.rs".to_string(),
547 base_name: "mod".to_string(),
548 extension: ".rs".to_string(),
549 path: "mod.rs".to_string(),
550 file_type: OutputFileType::File,
551 mime_type: None,
552 file_type_label: None,
553 size: 0,
554 date: None,
555 sha1: None,
556 md5: None,
557 sha256: None,
558 sha1_git: None,
559 programming_language: None,
560 package_data: Vec::new(),
561 license_expression: None,
562 license_detections: Vec::new(),
563 license_clues: Vec::new(),
564 percentage_of_license_text: None,
565 copyrights: Vec::new(),
566 holders: Vec::new(),
567 authors: Vec::new(),
568 emails: Vec::new(),
569 urls: Vec::new(),
570 for_packages: Vec::new(),
571 scan_errors: Vec::new(),
572 license_policy: None,
573 is_generated: None,
574 is_binary: None,
575 is_text: None,
576 is_archive: None,
577 is_media: None,
578 is_source: None,
579 is_script: None,
580 files_count: None,
581 dirs_count: None,
582 size_count: None,
583 source_count: None,
584 is_legal: false,
585 is_manifest: false,
586 is_readme: false,
587 is_top_level: false,
588 is_key_file: false,
589 is_referenced: false,
590 is_community: false,
591 facets: Vec::new(),
592 tallies: None,
593 }
594 }
595
596 #[test]
597 fn detected_license_expression_spdx_does_not_recombine_partial_detection_spdx() {
598 let mut file_info = base_output_file_info();
599 file_info.license_expression = Some("Apache-2.0 AND MIT".to_string());
600 file_info.license_detections = vec![
601 OutputLicenseDetection {
602 license_expression: "apache-2.0".to_string(),
603 license_expression_spdx: "Apache-2.0".to_string(),
604 matches: Vec::new(),
605 detection_log: Vec::new(),
606 identifier: None,
607 },
608 OutputLicenseDetection {
609 license_expression: "mit".to_string(),
610 license_expression_spdx: String::new(),
611 matches: Vec::new(),
612 detection_log: Vec::new(),
613 identifier: None,
614 },
615 ];
616
617 assert_eq!(
618 file_info.detected_license_expression_spdx().as_deref(),
619 Some("Apache-2.0 AND MIT")
620 );
621 }
622
623 #[test]
624 fn detected_license_expression_spdx_rejects_invalid_fallback_expression() {
625 let mut file_info = base_output_file_info();
626 file_info.license_expression = Some("MIT\" or malformed".to_string());
627
628 assert_eq!(file_info.detected_license_expression_spdx(), None);
629 }
630
631 #[test]
632 fn serialize_includes_is_referenced_only_when_true() {
633 let mut file_info = base_output_file_info();
634 let without_flag = serde_json::to_value(&file_info).expect("file should serialize");
635 assert_eq!(
636 without_flag,
637 json!({
638 "path": "mod.rs",
639 "type": "file",
640 "name": "mod.rs",
641 "base_name": "mod",
642 "extension": ".rs",
643 "size": 0,
644 "package_data": [],
645 "detected_license_expression": null,
646 "detected_license_expression_spdx": null,
647 "license_detections": [],
648 "copyrights": [],
649 "holders": [],
650 "authors": [],
651 "urls": [],
652 "for_packages": [],
653 "scan_errors": []
654 })
655 );
656
657 file_info.is_referenced = true;
658 let with_flag = serde_json::to_value(&file_info).expect("file should serialize");
659 assert_eq!(with_flag["is_referenced"], json!(true));
660 }
661}