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