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_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}