1use std::fs::File;
8use std::io::{self, BufWriter, Write};
9
10use crate::output_schema::Output;
11
12mod cyclonedx;
13mod debian;
14mod html;
15mod jsonl;
16mod public_serialize;
17mod sarif;
18mod sbom;
19mod shared;
20mod spdx;
21mod spdx_plan;
22mod template;
23
24pub(crate) const SPDX_DOCUMENT_NOTICE: &str = "Generated with Provenant and provided on an \"AS IS\" BASIS, WITHOUT WARRANTIES\nOR CONDITIONS OF ANY KIND, either express or implied. No content created from\nProvenant should be considered or used as legal advice. Consult an attorney\nfor legal advice.\nProvenant is a free software code scanning tool.\nVisit https://github.com/getprovenant/provenant/ for support and download.\nSPDX License List: 3.27";
25const OUTPUT_BUFFER_SIZE: usize = 1024 * 1024;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum OutputFormat {
29 #[default]
30 Json,
31 JsonPretty,
32 Yaml,
33 JsonLines,
34 Debian,
35 Html,
36 CustomTemplate,
37 SpdxTv,
38 SpdxRdf,
39 CycloneDxJson,
40 CycloneDxXml,
41 Sarif,
42}
43
44#[derive(Debug, Clone, Default)]
45pub struct OutputWriteConfig {
46 pub format: OutputFormat,
47 pub custom_template: Option<String>,
48 pub scanned_path: Option<String>,
49}
50
51pub trait OutputWriter {
52 fn write(
53 &self,
54 output: &Output,
55 writer: &mut dyn Write,
56 config: &OutputWriteConfig,
57 ) -> io::Result<()>;
58}
59
60type FormatWriterFn = fn(&Output, &mut dyn Write, &OutputWriteConfig) -> io::Result<()>;
61
62struct OutputFormatDescriptor {
69 format: OutputFormat,
70 cli_flag: &'static str,
71 is_sbom: bool,
72 write: FormatWriterFn,
73}
74
75const OUTPUT_WRITERS: &[OutputFormatDescriptor] = &[
80 OutputFormatDescriptor {
81 format: OutputFormat::Json,
82 cli_flag: "--json",
83 is_sbom: false,
84 write: write_json,
85 },
86 OutputFormatDescriptor {
87 format: OutputFormat::JsonPretty,
88 cli_flag: "--json-pp",
89 is_sbom: false,
90 write: write_json_pretty,
91 },
92 OutputFormatDescriptor {
93 format: OutputFormat::JsonLines,
94 cli_flag: "--json-lines",
95 is_sbom: false,
96 write: |output, writer, _config| jsonl::write_json_lines(output, writer),
97 },
98 OutputFormatDescriptor {
99 format: OutputFormat::Yaml,
100 cli_flag: "--yaml",
101 is_sbom: false,
102 write: |output, writer, _config| write_yaml(output, writer),
103 },
104 OutputFormatDescriptor {
105 format: OutputFormat::Debian,
106 cli_flag: "--debian",
107 is_sbom: false,
108 write: |output, writer, _config| debian::write_debian_copyright(output, writer),
109 },
110 OutputFormatDescriptor {
111 format: OutputFormat::Html,
112 cli_flag: "--html",
113 is_sbom: false,
114 write: |output, writer, _config| html::write_html_report(output, writer),
115 },
116 OutputFormatDescriptor {
117 format: OutputFormat::CustomTemplate,
118 cli_flag: "--custom-output",
119 is_sbom: false,
120 write: |output, writer, config| template::write_custom_template(output, writer, config),
121 },
122 OutputFormatDescriptor {
123 format: OutputFormat::SpdxTv,
124 cli_flag: "--spdx-tv",
125 is_sbom: true,
126 write: |output, writer, config| spdx::write_spdx_tag_value(output, writer, config),
127 },
128 OutputFormatDescriptor {
129 format: OutputFormat::SpdxRdf,
130 cli_flag: "--spdx-rdf",
131 is_sbom: true,
132 write: |output, writer, config| spdx::write_spdx_rdf_xml(output, writer, config),
133 },
134 OutputFormatDescriptor {
135 format: OutputFormat::CycloneDxJson,
136 cli_flag: "--cyclonedx",
137 is_sbom: true,
138 write: |output, writer, _config| cyclonedx::write_cyclonedx_json(output, writer),
139 },
140 OutputFormatDescriptor {
141 format: OutputFormat::CycloneDxXml,
142 cli_flag: "--cyclonedx-xml",
143 is_sbom: true,
144 write: |output, writer, _config| cyclonedx::write_cyclonedx_xml(output, writer),
145 },
146 OutputFormatDescriptor {
147 format: OutputFormat::Sarif,
148 cli_flag: "--sarif",
149 is_sbom: false,
150 write: |output, writer, _config| sarif::write_sarif(output, writer),
151 },
152];
153
154fn descriptor_for(format: OutputFormat) -> &'static OutputFormatDescriptor {
155 OUTPUT_WRITERS
156 .iter()
157 .find(|descriptor| descriptor.format == format)
158 .expect("OUTPUT_WRITERS must have an entry for every OutputFormat variant")
159}
160
161pub(crate) fn cli_flag_for(format: OutputFormat) -> &'static str {
165 descriptor_for(format).cli_flag
166}
167
168pub(crate) fn is_sbom_format(format: OutputFormat) -> bool {
171 descriptor_for(format).is_sbom
172}
173
174pub struct FormatWriter {
175 format: OutputFormat,
176}
177
178pub fn writer_for_format(format: OutputFormat) -> FormatWriter {
179 FormatWriter { format }
180}
181
182impl OutputWriter for FormatWriter {
183 fn write(
184 &self,
185 output: &Output,
186 writer: &mut dyn Write,
187 config: &OutputWriteConfig,
188 ) -> io::Result<()> {
189 (descriptor_for(self.format).write)(output, writer, config)
190 }
191}
192
193fn write_json(
194 output: &Output,
195 writer: &mut dyn Write,
196 _config: &OutputWriteConfig,
197) -> io::Result<()> {
198 serde_json::to_writer(&mut *writer, &public_serialize::PublicOutput(output))
199 .map_err(shared::io_other)?;
200 writer.write_all(b"\n")
201}
202
203fn write_json_pretty(
204 output: &Output,
205 writer: &mut dyn Write,
206 _config: &OutputWriteConfig,
207) -> io::Result<()> {
208 serde_json::to_writer_pretty(&mut *writer, &public_serialize::PublicOutput(output))
209 .map_err(shared::io_other)?;
210 writer.write_all(b"\n")
211}
212
213pub fn write_output_file(
214 output_file: &str,
215 output: &Output,
216 config: &OutputWriteConfig,
217) -> io::Result<()> {
218 if output_file == "-" {
219 let stdout = io::stdout();
220 let handle = stdout.lock();
221 let mut writer = BufWriter::with_capacity(OUTPUT_BUFFER_SIZE, handle);
222 writer_for_format(config.format).write(output, &mut writer, config)?;
223 return writer.flush();
224 }
225
226 let file = File::create(output_file)?;
227 let mut writer = BufWriter::with_capacity(OUTPUT_BUFFER_SIZE, file);
228 writer_for_format(config.format).write(output, &mut writer, config)?;
229 writer.flush()
230}
231
232fn write_yaml(output: &Output, writer: &mut dyn Write) -> io::Result<()> {
233 yaml_serde::to_writer(&mut *writer, &public_serialize::PublicOutput(output))
234 .map_err(shared::io_other)?;
235 writer.write_all(b"\n")
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use serde_json::Value;
242 use std::fs;
243
244 use crate::license_detection::MatcherKind;
245 use crate::models::{
246 Author, Copyright, ExtraData, FileInfo, FileType, GitSha1, Header, Holder,
247 LicenseDetection, LineNumber, Match, MatchScore, Md5Digest, OutputEmail, OutputURL,
248 Package, PackageData, PackageUid, Sha1Digest, Sha256Digest, SystemEnvironment,
249 };
250 use crate::output_schema::OutputFileInfo;
251
252 #[test]
253 fn test_output_writers_registry_covers_every_format_exactly_once() {
254 let all_formats = [
258 OutputFormat::Json,
259 OutputFormat::JsonPretty,
260 OutputFormat::Yaml,
261 OutputFormat::JsonLines,
262 OutputFormat::Debian,
263 OutputFormat::Html,
264 OutputFormat::CustomTemplate,
265 OutputFormat::SpdxTv,
266 OutputFormat::SpdxRdf,
267 OutputFormat::CycloneDxJson,
268 OutputFormat::CycloneDxXml,
269 OutputFormat::Sarif,
270 ];
271 assert_eq!(OUTPUT_WRITERS.len(), all_formats.len());
272 for format in all_formats {
273 assert_eq!(
274 OUTPUT_WRITERS
275 .iter()
276 .filter(|descriptor| descriptor.format == format)
277 .count(),
278 1,
279 "missing or duplicate OUTPUT_WRITERS entry for {format:?}"
280 );
281 }
282 }
283
284 #[test]
285 fn test_sbom_formats_are_exactly_spdx_and_cyclonedx() {
286 let sbom_formats: Vec<OutputFormat> = OUTPUT_WRITERS
287 .iter()
288 .filter(|descriptor| descriptor.is_sbom)
289 .map(|descriptor| descriptor.format)
290 .collect();
291 assert_eq!(
292 sbom_formats,
293 vec![
294 OutputFormat::SpdxTv,
295 OutputFormat::SpdxRdf,
296 OutputFormat::CycloneDxJson,
297 OutputFormat::CycloneDxXml,
298 ]
299 );
300 assert_eq!(cli_flag_for(OutputFormat::CycloneDxJson), "--cyclonedx");
301 assert!(is_sbom_format(OutputFormat::SpdxTv));
302 assert!(!is_sbom_format(OutputFormat::Json));
303 }
304
305 #[test]
306 fn test_yaml_writer_outputs_yaml() {
307 let output = Output::from(&sample_internal_output());
308 let mut bytes = Vec::new();
309 writer_for_format(OutputFormat::Yaml)
310 .write(&output, &mut bytes, &OutputWriteConfig::default())
311 .expect("yaml write should succeed");
312 let rendered = String::from_utf8(bytes).expect("yaml should be utf-8");
313 assert!(rendered.contains("headers:"));
314 assert!(rendered.contains("files:"));
315 }
316
317 #[test]
318 fn test_json_lines_writer_outputs_parseable_lines() {
319 let output = Output::from(&sample_internal_output());
320 let mut bytes = Vec::new();
321 writer_for_format(OutputFormat::JsonLines)
322 .write(&output, &mut bytes, &OutputWriteConfig::default())
323 .expect("json-lines write should succeed");
324
325 let rendered = String::from_utf8(bytes).expect("json-lines should be utf-8");
326 let lines = rendered.lines().collect::<Vec<_>>();
327 assert!(lines.len() >= 2);
328 for line in lines {
329 serde_json::from_str::<Value>(line).expect("each line should be valid json");
330 }
331 }
332
333 #[test]
334 fn test_yaml_writer_emits_license_index_provenance_in_headers() {
335 let output = Output::from(&sample_internal_output());
336 let mut bytes = Vec::new();
337 writer_for_format(OutputFormat::Yaml)
338 .write(&output, &mut bytes, &OutputWriteConfig::default())
339 .expect("yaml write should succeed");
340
341 let rendered = String::from_utf8(bytes).expect("yaml should be utf-8");
342 assert!(rendered.contains("license_index_provenance:"));
343 assert!(rendered.contains("dataset_fingerprint: test-fingerprint"));
344 assert!(rendered.contains("source: embedded-artifact"));
345 }
346
347 #[test]
348 fn test_debian_writer_outputs_dep5_style_document() {
349 let mut internal = sample_internal_output();
350 internal.files[0].detected_license_expression = Some("mit".to_string());
351 internal.files[0].license_detections[0].matches[0].matched_text = Some(
352 "Permission is hereby granted, free of charge, to any person obtaining a copy"
353 .to_string(),
354 );
355 let output = Output::from(&internal);
356
357 let mut bytes = Vec::new();
358 writer_for_format(OutputFormat::Debian)
359 .write(&output, &mut bytes, &OutputWriteConfig::default())
360 .expect("debian write should succeed");
361
362 let rendered = String::from_utf8(bytes).expect("debian output should be utf-8");
363 assert!(rendered.contains(
364 "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/"
365 ));
366 assert!(rendered.contains("Comment: Generated with Provenant"));
367 assert!(rendered.contains("Files: src/main.rs"));
368 assert!(rendered.contains("Copyright: Example Org"));
369 assert!(rendered.contains("License: MIT"));
370 assert!(rendered.contains(" Permission is hereby granted, free of charge"));
371 }
372
373 #[test]
374 fn test_debian_writer_skips_directories_and_deduplicates_license_texts() {
375 let mut internal = sample_internal_output();
376 internal.files.insert(
377 0,
378 FileInfo::new(
379 "src".to_string(),
380 "src".to_string(),
381 String::new(),
382 "src".to_string(),
383 FileType::Directory,
384 None,
385 None,
386 0,
387 None,
388 None,
389 None,
390 None,
391 None,
392 vec![],
393 None,
394 vec![],
395 vec![],
396 vec![],
397 vec![],
398 vec![],
399 vec![],
400 vec![],
401 vec![],
402 vec![],
403 ),
404 );
405 internal.files[1].detected_license_expression = Some("mit".to_string());
406 internal.files[1].license_detections[0].matches[0].matched_text =
407 Some("Same text".to_string());
408 internal.files[1].license_detections[0].matches.push(Match {
409 license_expression: "mit".to_string(),
410 license_expression_spdx: "MIT".to_string(),
411 from_file: Some("src/main.rs".to_string()),
412 start_line: LineNumber::ONE,
413 end_line: LineNumber::ONE,
414 matcher: MatcherKind::Aho,
415 score: MatchScore::MAX,
416 matched_length: Some(1),
417 match_coverage: Some(100.0),
418 rule_relevance: Some(100),
419 rule_identifier: "mit_rule".to_string(),
420 rule_url: None,
421 matched_text: Some("Same text again".to_string()),
422 referenced_filenames: None,
423 matched_text_diagnostics: None,
424 });
425 let output = Output::from(&internal);
426
427 let mut bytes = Vec::new();
428 writer_for_format(OutputFormat::Debian)
429 .write(&output, &mut bytes, &OutputWriteConfig::default())
430 .expect("debian write should succeed");
431
432 let rendered = String::from_utf8(bytes).expect("debian output should be utf-8");
433 assert!(!rendered.contains("Files: src\n"));
434 assert_eq!(rendered.matches(" Same text").count(), 1);
435 }
436
437 #[test]
438 fn test_file_info_serialization_omits_info_fields_when_unset() {
439 let file = FileInfo::new(
440 "main.rs".to_string(),
441 "main".to_string(),
442 "rs".to_string(),
443 "src/main.rs".to_string(),
444 FileType::File,
445 None,
446 None,
447 42,
448 None,
449 None,
450 None,
451 None,
452 None,
453 vec![],
454 None,
455 vec![],
456 vec![],
457 vec![],
458 vec![],
459 vec![],
460 vec![],
461 vec![],
462 vec![],
463 vec![],
464 );
465
466 let schema_file = OutputFileInfo::from(&file);
467 let value = serde_json::to_value(&schema_file).expect("file info serializes");
468 let object = value.as_object().expect("file info object");
469
470 assert!(!object.contains_key("date"));
471 assert!(!object.contains_key("sha1"));
472 assert!(!object.contains_key("md5"));
473 assert!(!object.contains_key("sha256"));
474 assert!(!object.contains_key("sha1_git"));
475 assert!(!object.contains_key("mime_type"));
476 assert!(!object.contains_key("file_type"));
477 assert!(!object.contains_key("programming_language"));
478 assert!(!object.contains_key("is_binary"));
479 assert!(!object.contains_key("is_text"));
480 assert!(!object.contains_key("is_archive"));
481 assert!(!object.contains_key("is_media"));
482 assert!(!object.contains_key("is_source"));
483 assert!(!object.contains_key("is_script"));
484 assert!(!object.contains_key("files_count"));
485 assert!(!object.contains_key("dirs_count"));
486 assert!(!object.contains_key("size_count"));
487 assert!(!object.contains_key("license_policy"));
488 }
489
490 #[test]
491 fn test_file_info_serialization_keeps_license_policy_when_enabled() {
492 let mut file = FileInfo::new(
493 "main.rs".to_string(),
494 "main".to_string(),
495 "rs".to_string(),
496 "src/main.rs".to_string(),
497 FileType::File,
498 Some("text/plain".to_string()),
499 Some("text".to_string()),
500 42,
501 Some("2026-01-01T00:00:00Z".to_string()),
502 Some(Sha1Digest::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").unwrap()),
503 Some(Md5Digest::from_hex("d41d8cd98f00b204e9800998ecf8427e").unwrap()),
504 Some(
505 Sha256Digest::from_hex(
506 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
507 )
508 .unwrap(),
509 ),
510 Some("Rust".to_string()),
511 vec![],
512 None,
513 vec![],
514 vec![],
515 vec![],
516 vec![],
517 vec![],
518 vec![],
519 vec![],
520 vec![],
521 vec![],
522 );
523 file.license_policy = Some(vec![]);
524 file.sha1_git =
525 Some(GitSha1::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").unwrap());
526 file.is_binary = Some(false);
527 file.is_text = Some(true);
528 file.is_archive = Some(false);
529 file.is_media = Some(false);
530 file.is_source = Some(true);
531 file.is_script = Some(false);
532 file.files_count = Some(0);
533 file.dirs_count = Some(0);
534 file.size_count = Some(0);
535
536 let schema_file = OutputFileInfo::from(&file);
537 let value = serde_json::to_value(&schema_file).expect("file info serializes");
538 let object = value.as_object().expect("file info object");
539
540 assert_eq!(object.get("license_policy"), Some(&serde_json::json!([])));
541 assert_eq!(object.get("file_type"), Some(&serde_json::json!("text")));
542 assert_eq!(object.get("is_binary"), Some(&serde_json::json!(false)));
543 assert_eq!(object.get("is_text"), Some(&serde_json::json!(true)));
544 assert_eq!(object.get("files_count"), Some(&serde_json::json!(0)));
545 assert_eq!(object.get("dirs_count"), Some(&serde_json::json!(0)));
546 assert_eq!(object.get("size_count"), Some(&serde_json::json!(0)));
547 }
548
549 #[test]
550 fn test_detected_license_expression_spdx_prefers_detection_spdx_values() {
551 let mut internal = sample_internal_output();
552 internal.files[0].detected_license_expression = Some("mit".to_string());
553
554 let schema_file = OutputFileInfo::from(&internal.files[0]);
555 let schema_value = serde_json::to_value(&schema_file).expect("file info serializes");
556 assert_eq!(schema_value["detected_license_expression_spdx"], "MIT");
557
558 let output = Output::from(&internal);
559 let mut bytes = Vec::new();
560 writer_for_format(OutputFormat::Json)
561 .write(&output, &mut bytes, &OutputWriteConfig::default())
562 .expect("json write should succeed");
563
564 let rendered: Value = serde_json::from_slice(&bytes).expect("json output should parse");
565 assert_eq!(
566 rendered["files"][0]["detected_license_expression_spdx"],
567 "MIT"
568 );
569 }
570
571 #[test]
572 fn test_detected_license_expression_spdx_preserves_distinct_nested_operands() {
573 let mut internal = sample_internal_output();
574 internal.files[0].license_detections = vec![crate::models::LicenseDetection {
575 license_expression: "mit AND (apache-2.0 OR mit)".to_string(),
576 license_expression_spdx: "MIT AND (Apache-2.0 OR MIT)".to_string(),
577 matches: vec![],
578 detection_log: vec![],
579 identifier: String::new(),
580 }];
581 internal.files[0].detected_license_expression = None;
582
583 let schema_file = OutputFileInfo::from(&internal.files[0]);
584 let schema_value = serde_json::to_value(&schema_file).expect("file info serializes");
585 assert_eq!(
586 schema_value["detected_license_expression_spdx"],
587 "MIT AND (Apache-2.0 OR MIT)"
588 );
589 }
590
591 #[test]
592 fn test_detected_license_expression_spdx_prefers_covering_joined_expression() {
593 let mut internal = sample_internal_output();
594 internal.files[0].license_detections = vec![
595 crate::models::LicenseDetection {
596 license_expression: "apache-2.0 OR mit".to_string(),
597 license_expression_spdx: "Apache-2.0 OR MIT".to_string(),
598 matches: vec![],
599 detection_log: vec![],
600 identifier: String::new(),
601 },
602 crate::models::LicenseDetection {
603 license_expression: "apache-2.0".to_string(),
604 license_expression_spdx: "Apache-2.0".to_string(),
605 matches: vec![],
606 detection_log: vec![],
607 identifier: String::new(),
608 },
609 ];
610 internal.files[0].detected_license_expression = None;
611
612 let schema_file = OutputFileInfo::from(&internal.files[0]);
613 let schema_value = serde_json::to_value(&schema_file).expect("file info serializes");
614 assert_eq!(
615 schema_value["detected_license_expression_spdx"],
616 "Apache-2.0 OR MIT"
617 );
618 }
619
620 #[test]
621 fn test_detected_license_expression_prefers_detection_values() {
622 let mut internal = sample_internal_output();
625 internal.files[0].license_detections = vec![crate::models::LicenseDetection {
626 license_expression: "bsd-new".to_string(),
627 license_expression_spdx: "BSD-3-Clause".to_string(),
628 matches: vec![],
629 detection_log: vec![],
630 identifier: String::new(),
631 }];
632 internal.files[0].detected_license_expression = None;
633
634 let schema_file = OutputFileInfo::from(&internal.files[0]);
635 let schema_value = serde_json::to_value(&schema_file).expect("file info serializes");
636 assert_eq!(schema_value["detected_license_expression"], "bsd-new");
637 assert_eq!(
638 schema_value["detected_license_expression_spdx"],
639 "BSD-3-Clause"
640 );
641 }
642
643 #[test]
644 fn test_detected_license_expression_falls_back_to_package_data_detections() {
645 let mut internal = sample_internal_output();
648 internal.files[0].license_detections = vec![];
649 internal.files[0].detected_license_expression = None;
650 internal.files[0].package_data = vec![crate::models::PackageData {
651 license_detections: vec![crate::models::LicenseDetection {
652 license_expression: "bsd-simplified".to_string(),
653 license_expression_spdx: "BSD-2-Clause".to_string(),
654 matches: vec![],
655 detection_log: vec![],
656 identifier: String::new(),
657 }],
658 ..Default::default()
659 }];
660
661 let schema_file = OutputFileInfo::from(&internal.files[0]);
662 let schema_value = serde_json::to_value(&schema_file).expect("file info serializes");
663 assert_eq!(
664 schema_value["detected_license_expression"],
665 "bsd-simplified"
666 );
667 assert_eq!(
668 schema_value["detected_license_expression_spdx"],
669 "BSD-2-Clause"
670 );
671 }
672
673 #[test]
674 fn test_json_lines_writer_sorts_files_by_path_for_reproducibility() {
675 let mut internal = sample_internal_output();
676 internal.files.reverse();
677 let output = Output::from(&internal);
678 let mut bytes = Vec::new();
679 writer_for_format(OutputFormat::JsonLines)
680 .write(&output, &mut bytes, &OutputWriteConfig::default())
681 .expect("json-lines write should succeed");
682
683 let rendered = String::from_utf8(bytes).expect("json-lines should be utf-8");
684 let file_lines = rendered
685 .lines()
686 .filter_map(|line| {
687 let value: Value = serde_json::from_str(line).ok()?;
688 let files = value.get("files")?.as_array()?;
689 files.first()?.get("path")?.as_str().map(str::to_string)
690 })
691 .collect::<Vec<_>>();
692
693 let mut sorted = file_lines.clone();
694 sorted.sort();
695 assert_eq!(file_lines, sorted);
696 }
697
698 #[test]
699 fn test_spdx_tag_value_writer_contains_required_fields() {
700 let output = Output::from(&sample_internal_output());
701 let mut bytes = Vec::new();
702 writer_for_format(OutputFormat::SpdxTv)
703 .write(
704 &output,
705 &mut bytes,
706 &OutputWriteConfig {
707 format: OutputFormat::SpdxTv,
708 custom_template: None,
709 scanned_path: Some("scan".to_string()),
710 },
711 )
712 .expect("spdx tv write should succeed");
713
714 let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
715 assert!(rendered.contains("SPDXVersion: SPDX-2.2"));
716 assert!(rendered.contains("FileName: ./src/main.rs"));
717 assert!(
718 rendered.contains("Creator: Tool: Provenant-"),
719 "SPDX TV must emit CreationInfo Creator for spdx-tools"
720 );
721 assert!(
722 rendered.contains("Created:"),
723 "SPDX TV must emit CreationInfo Created for spdx-tools"
724 );
725 assert!(
726 rendered.contains("Relationship: SPDXRef-DOCUMENT DESCRIBES SPDXRef-001"),
727 "SPDX TV must describe the package from the document"
728 );
729 }
730
731 #[test]
732 fn test_spdx_rdf_writer_outputs_xml() {
733 let output = Output::from(&sample_internal_output());
734 let mut bytes = Vec::new();
735 writer_for_format(OutputFormat::SpdxRdf)
736 .write(
737 &output,
738 &mut bytes,
739 &OutputWriteConfig {
740 format: OutputFormat::SpdxRdf,
741 custom_template: None,
742 scanned_path: Some("scan".to_string()),
743 },
744 )
745 .expect("spdx rdf write should succeed");
746
747 let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
748 assert!(rendered.contains("<rdf:RDF"));
749 assert!(rendered.contains("<spdx:SpdxDocument"));
750 assert!(rendered.contains("<spdx:created>2026-01-01T00:00:00Z</spdx:created>"));
751 assert!(
752 rendered.contains("<spdx:creator>Tool: Provenant-"),
753 "SPDX RDF must emit a creator Actor for spdx-tools"
754 );
755 assert!(
756 rendered.contains("relationshipType_describes"),
757 "SPDX RDF must include Document DESCRIBES package"
758 );
759 }
760
761 #[test]
762 fn test_cyclonedx_writers_keep_iso_timestamps_when_headers_use_scancode_format() {
763 let mut internal = sample_internal_output();
764 internal.packages.push(Package::from_package_data(
765 &PackageData {
766 name: Some("demo".to_string()),
767 version: Some("1.0.0".to_string()),
768 ..PackageData::default()
769 },
770 "scan/package.json".to_string(),
771 ));
772 let output = Output::from(&internal);
773
774 let mut json_bytes = Vec::new();
775 writer_for_format(OutputFormat::CycloneDxJson)
776 .write(
777 &output,
778 &mut json_bytes,
779 &OutputWriteConfig {
780 format: OutputFormat::CycloneDxJson,
781 custom_template: None,
782 scanned_path: Some("scan".to_string()),
783 },
784 )
785 .expect("cyclonedx json write should succeed");
786 let json_value: Value =
787 serde_json::from_slice(&json_bytes).expect("cyclonedx json should parse");
788 assert_eq!(
789 json_value["metadata"]["timestamp"].as_str(),
790 Some("2026-01-01T00:00:01Z")
791 );
792
793 let mut xml_bytes = Vec::new();
794 writer_for_format(OutputFormat::CycloneDxXml)
795 .write(
796 &output,
797 &mut xml_bytes,
798 &OutputWriteConfig {
799 format: OutputFormat::CycloneDxXml,
800 custom_template: None,
801 scanned_path: Some("scan".to_string()),
802 },
803 )
804 .expect("cyclonedx xml write should succeed");
805 let xml = String::from_utf8(xml_bytes).expect("cyclonedx xml should be utf-8");
806 assert!(xml.contains("<timestamp>2026-01-01T00:00:01Z</timestamp>"));
807 }
808
809 #[test]
810 fn test_spdx_writers_emit_real_file_and_package_license_info() {
811 let output = Output::from(&sample_internal_output());
812
813 let mut tv_bytes = Vec::new();
814 writer_for_format(OutputFormat::SpdxTv)
815 .write(
816 &output,
817 &mut tv_bytes,
818 &OutputWriteConfig {
819 format: OutputFormat::SpdxTv,
820 custom_template: None,
821 scanned_path: Some("scan".to_string()),
822 },
823 )
824 .expect("spdx tv write should succeed");
825 let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
826 assert!(tv_rendered.contains("PackageLicenseConcluded: NOASSERTION"));
827 assert!(tv_rendered.contains("PackageLicenseInfoFromFiles: MIT"));
828 assert!(tv_rendered.contains("LicenseConcluded: NOASSERTION"));
829 assert!(tv_rendered.contains("LicenseInfoInFile: MIT"));
830 assert!(tv_rendered.contains("PackageCopyrightText: Copyright (c) Example"));
831
832 let mut rdf_bytes = Vec::new();
833 writer_for_format(OutputFormat::SpdxRdf)
834 .write(
835 &output,
836 &mut rdf_bytes,
837 &OutputWriteConfig {
838 format: OutputFormat::SpdxRdf,
839 custom_template: None,
840 scanned_path: Some("scan".to_string()),
841 },
842 )
843 .expect("spdx rdf write should succeed");
844 let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
845 assert!(rdf_rendered.contains(
846 "<spdx:licenseInfoFromFiles rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
847 ));
848 assert!(
849 rdf_rendered.contains(
850 "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
851 )
852 );
853 assert!(rdf_rendered.contains(
854 "<spdx:licenseConcluded rdf:resource=\"http://spdx.org/rdf/terms#noassertion\"/>"
855 ));
856 }
857
858 #[test]
859 fn test_spdx_writers_emit_license_ref_metadata_and_matched_text() {
860 let mut internal = sample_internal_output();
861 internal.files[0].license_detections = vec![LicenseDetection {
862 license_expression: "unknown-license-reference".to_string(),
863 license_expression_spdx: "LicenseRef-scancode-unknown-license-reference".to_string(),
864 matches: vec![Match {
865 license_expression: "unknown-license-reference".to_string(),
866 license_expression_spdx: "LicenseRef-scancode-unknown-license-reference"
867 .to_string(),
868 from_file: Some("src/main.rs".to_string()),
869 start_line: LineNumber::ONE,
870 end_line: LineNumber::new(2).unwrap(),
871 matcher: MatcherKind::Aho,
872 score: MatchScore::MAX,
873 matched_length: Some(4),
874 match_coverage: Some(100.0),
875 rule_relevance: Some(100),
876 rule_identifier: "unknown-license-reference.RULE".to_string(),
877 rule_url: Some("https://example.com/unknown-license-reference.LICENSE".to_string()),
878 matched_text: Some("Custom license text".to_string()),
879 referenced_filenames: Some(vec!["LICENSE".to_string()]),
880 matched_text_diagnostics: None,
881 }],
882 detection_log: vec![],
883 identifier: "unknown-ref-id".to_string(),
884 }];
885 internal.license_references = vec![crate::models::LicenseReference {
886 key: Some("unknown-license-reference".to_string()),
887 language: Some("en".to_string()),
888 name: "Unknown License Reference".to_string(),
889 short_name: "Unknown License Reference".to_string(),
890 owner: None,
891 homepage_url: None,
892 spdx_license_key: "LicenseRef-scancode-unknown-license-reference".to_string(),
893 other_spdx_license_keys: vec![],
894 osi_license_key: None,
895 text_urls: vec![],
896 osi_url: None,
897 faq_url: None,
898 other_urls: vec![],
899 category: None,
900 is_exception: false,
901 is_unknown: true,
902 is_generic: false,
903 notes: None,
904 minimum_coverage: None,
905 standard_notice: None,
906 ignorable_copyrights: vec![],
907 ignorable_holders: vec![],
908 ignorable_authors: vec![],
909 ignorable_urls: vec![],
910 ignorable_emails: vec![],
911 scancode_url: None,
912 licensedb_url: None,
913 spdx_url: None,
914 text: "Unused fallback text".to_string(),
915 }];
916 let output = Output::from(&internal);
917
918 let mut tv_bytes = Vec::new();
919 writer_for_format(OutputFormat::SpdxTv)
920 .write(
921 &output,
922 &mut tv_bytes,
923 &OutputWriteConfig {
924 format: OutputFormat::SpdxTv,
925 custom_template: None,
926 scanned_path: Some("scan".to_string()),
927 },
928 )
929 .expect("spdx tv write should succeed");
930 let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
931 assert!(
932 tv_rendered
933 .contains("LicenseInfoInFile: LicenseRef-scancode-unknown-license-reference")
934 );
935 assert!(tv_rendered.contains(
936 "PackageLicenseInfoFromFiles: LicenseRef-scancode-unknown-license-reference"
937 ));
938 assert!(tv_rendered.contains("LicenseID: LicenseRef-scancode-unknown-license-reference"));
939 assert!(tv_rendered.contains("ExtractedText: <text>Custom license text"));
940 assert!(tv_rendered.contains("LicenseName: Unknown License Reference"));
941 assert!(tv_rendered.contains(
942 "LicenseComment: <text>See details at https://example.com/unknown-license-reference.LICENSE"
943 ));
944
945 let mut rdf_bytes = Vec::new();
946 writer_for_format(OutputFormat::SpdxRdf)
947 .write(
948 &output,
949 &mut rdf_bytes,
950 &OutputWriteConfig {
951 format: OutputFormat::SpdxRdf,
952 custom_template: None,
953 scanned_path: Some("scan".to_string()),
954 },
955 )
956 .expect("spdx rdf write should succeed");
957 let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
958 assert!(rdf_rendered.contains(
959 "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/spdxdocs/scan#LicenseRef-scancode-unknown-license-reference\"/>"
960 ));
961 assert!(rdf_rendered.contains(
962 "<spdx:hasExtractedLicensingInfo><spdx:ExtractedLicensingInfo rdf:about=\"http://spdx.org/spdxdocs/scan#LicenseRef-scancode-unknown-license-reference\">"
963 ));
964 assert!(
965 rdf_rendered.contains("<spdx:extractedText>Custom license text</spdx:extractedText>")
966 );
967 }
968
969 #[test]
970 fn test_cyclonedx_json_writer_outputs_bom() {
971 let output = Output::from(&sample_internal_output());
972 let mut bytes = Vec::new();
973 writer_for_format(OutputFormat::CycloneDxJson)
974 .write(&output, &mut bytes, &OutputWriteConfig::default())
975 .expect("cyclonedx json write should succeed");
976
977 let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
978 let value: Value = serde_json::from_str(&rendered).expect("valid json");
979 assert_eq!(value["bomFormat"], "CycloneDX");
980 assert_eq!(value["specVersion"], "1.7");
981 assert_eq!(
982 value["$schema"],
983 "http://cyclonedx.org/schema/bom-1.7.schema.json"
984 );
985 }
986
987 #[test]
988 fn test_json_writer_includes_summary_and_key_file_flags() {
989 let mut internal = sample_internal_output();
990 internal.summary = Some(crate::models::Summary {
991 declared_license_expression: Some("apache-2.0".to_string()),
992 license_clarity_score: Some(crate::models::LicenseClarityScore {
993 score: 100,
994 declared_license: true,
995 identification_precision: true,
996 has_license_text: true,
997 declared_copyrights: true,
998 conflicting_license_categories: false,
999 ambiguous_compound_licensing: false,
1000 }),
1001 declared_holder: Some("Example Corp.".to_string()),
1002 primary_language: Some("Ruby".to_string()),
1003 other_license_expressions: vec![crate::models::TallyEntry {
1004 value: Some("mit".to_string()),
1005 count: 1,
1006 }],
1007 other_holders: vec![
1008 crate::models::TallyEntry {
1009 value: None,
1010 count: 2,
1011 },
1012 crate::models::TallyEntry {
1013 value: Some("Other Corp.".to_string()),
1014 count: 1,
1015 },
1016 ],
1017 other_languages: vec![crate::models::TallyEntry {
1018 value: Some("Python".to_string()),
1019 count: 2,
1020 }],
1021 });
1022 internal.files[0].is_legal = true;
1023 internal.files[0].is_top_level = true;
1024 internal.files[0].is_key_file = true;
1025 let output = Output::from(&internal);
1026
1027 let mut bytes = Vec::new();
1028 writer_for_format(OutputFormat::Json)
1029 .write(&output, &mut bytes, &OutputWriteConfig::default())
1030 .expect("json write should succeed");
1031
1032 let rendered = String::from_utf8(bytes).expect("json should be utf-8");
1033 let value: Value = serde_json::from_str(&rendered).expect("valid json");
1034
1035 assert_eq!(
1036 value["summary"]["declared_license_expression"],
1037 "apache-2.0"
1038 );
1039 assert_eq!(value["summary"]["license_clarity_score"]["score"], 100);
1040 assert_eq!(value["summary"]["declared_holder"], "Example Corp.");
1041 assert_eq!(value["summary"]["primary_language"], "Ruby");
1042 assert_eq!(
1043 value["summary"]["other_license_expressions"][0]["value"],
1044 "mit"
1045 );
1046 assert!(value["summary"]["other_holders"][0]["value"].is_null());
1047 assert_eq!(value["summary"]["other_holders"][1]["value"], "Other Corp.");
1048 assert_eq!(value["summary"]["other_languages"][0]["value"], "Python");
1049 assert_eq!(value["files"][0]["is_key_file"], true);
1050 }
1051
1052 #[test]
1053 fn test_json_and_json_lines_writers_include_top_level_tallies() {
1054 let mut internal = sample_internal_output();
1055 internal.tallies = Some(crate::models::Tallies {
1056 detected_license_expression: vec![crate::models::TallyEntry {
1057 value: Some("mit".to_string()),
1058 count: 2,
1059 }],
1060 copyrights: vec![crate::models::TallyEntry {
1061 value: Some("Copyright (c) Example Org".to_string()),
1062 count: 1,
1063 }],
1064 holders: vec![crate::models::TallyEntry {
1065 value: Some("Example Org".to_string()),
1066 count: 1,
1067 }],
1068 authors: vec![crate::models::TallyEntry {
1069 value: Some("Jane Doe".to_string()),
1070 count: 1,
1071 }],
1072 programming_language: vec![crate::models::TallyEntry {
1073 value: Some("Rust".to_string()),
1074 count: 1,
1075 }],
1076 });
1077 let output = Output::from(&internal);
1078
1079 let mut json_bytes = Vec::new();
1080 writer_for_format(OutputFormat::Json)
1081 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1082 .expect("json write should succeed");
1083 let json_value: Value =
1084 serde_json::from_slice(&json_bytes).expect("json output should parse");
1085 assert_eq!(
1086 json_value["tallies"]["detected_license_expression"][0]["value"],
1087 "mit"
1088 );
1089 assert_eq!(
1090 json_value["tallies"]["programming_language"][0]["value"],
1091 "Rust"
1092 );
1093
1094 let mut jsonl_bytes = Vec::new();
1095 writer_for_format(OutputFormat::JsonLines)
1096 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1097 .expect("json-lines write should succeed");
1098 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1099 assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
1100 }
1101
1102 #[test]
1103 fn test_json_and_json_lines_writers_include_key_file_tallies() {
1104 let mut internal = sample_internal_output();
1105 internal.tallies_of_key_files = Some(crate::models::Tallies {
1106 detected_license_expression: vec![crate::models::TallyEntry {
1107 value: Some("apache-2.0".to_string()),
1108 count: 1,
1109 }],
1110 copyrights: vec![],
1111 holders: vec![],
1112 authors: vec![],
1113 programming_language: vec![crate::models::TallyEntry {
1114 value: Some("Markdown".to_string()),
1115 count: 1,
1116 }],
1117 });
1118 let output = Output::from(&internal);
1119
1120 let mut json_bytes = Vec::new();
1121 writer_for_format(OutputFormat::Json)
1122 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1123 .expect("json write should succeed");
1124 let json_value: Value =
1125 serde_json::from_slice(&json_bytes).expect("json output should parse");
1126 assert_eq!(
1127 json_value["tallies_of_key_files"]["detected_license_expression"][0]["value"],
1128 "apache-2.0"
1129 );
1130
1131 let mut jsonl_bytes = Vec::new();
1132 writer_for_format(OutputFormat::JsonLines)
1133 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1134 .expect("json-lines write should succeed");
1135 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1136 assert!(
1137 rendered
1138 .lines()
1139 .any(|line| line.contains("\"tallies_of_key_files\""))
1140 );
1141 }
1142
1143 #[test]
1144 fn test_json_and_json_lines_writers_include_file_tallies() {
1145 let mut internal = sample_internal_output();
1146 internal.files[0].tallies = Some(crate::models::Tallies {
1147 detected_license_expression: vec![crate::models::TallyEntry {
1148 value: Some("mit".to_string()),
1149 count: 1,
1150 }],
1151 copyrights: vec![crate::models::TallyEntry {
1152 value: None,
1153 count: 1,
1154 }],
1155 holders: vec![],
1156 authors: vec![],
1157 programming_language: vec![crate::models::TallyEntry {
1158 value: Some("Rust".to_string()),
1159 count: 1,
1160 }],
1161 });
1162 let output = Output::from(&internal);
1163
1164 let mut json_bytes = Vec::new();
1165 writer_for_format(OutputFormat::Json)
1166 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1167 .expect("json write should succeed");
1168 let json_value: Value =
1169 serde_json::from_slice(&json_bytes).expect("json output should parse");
1170 assert_eq!(
1171 json_value["files"][0]["tallies"]["detected_license_expression"][0]["value"],
1172 "mit"
1173 );
1174
1175 let mut jsonl_bytes = Vec::new();
1176 writer_for_format(OutputFormat::JsonLines)
1177 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1178 .expect("json-lines write should succeed");
1179 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1180 assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
1181 }
1182
1183 #[test]
1184 fn test_json_and_json_lines_writers_include_facets_and_tallies_by_facet() {
1185 let mut internal = sample_internal_output();
1186 internal.files[0].facets = vec!["core".to_string(), "docs".to_string()];
1187 internal.tallies_by_facet = Some(vec![crate::models::FacetTallies {
1188 facet: "core".to_string(),
1189 tallies: crate::models::Tallies {
1190 detected_license_expression: vec![crate::models::TallyEntry {
1191 value: Some("mit".to_string()),
1192 count: 1,
1193 }],
1194 copyrights: vec![],
1195 holders: vec![],
1196 authors: vec![],
1197 programming_language: vec![],
1198 },
1199 }]);
1200 let output = Output::from(&internal);
1201
1202 let mut json_bytes = Vec::new();
1203 writer_for_format(OutputFormat::Json)
1204 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1205 .expect("json write should succeed");
1206 let json_value: Value =
1207 serde_json::from_slice(&json_bytes).expect("json output should parse");
1208 assert_eq!(json_value["files"][0]["facets"][0], "core");
1209 assert_eq!(json_value["tallies_by_facet"][0]["facet"], "core");
1210
1211 let mut jsonl_bytes = Vec::new();
1212 writer_for_format(OutputFormat::JsonLines)
1213 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1214 .expect("json-lines write should succeed");
1215 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1216 assert!(
1217 rendered
1218 .lines()
1219 .any(|line| line.contains("\"tallies_by_facet\""))
1220 );
1221 }
1222
1223 #[test]
1224 fn test_json_and_json_lines_writers_include_top_level_license_references() {
1225 let mut internal = sample_internal_output();
1226 internal.license_references = vec![crate::models::LicenseReference {
1227 key: Some("mit".to_string()),
1228 language: Some("en".to_string()),
1229 name: "MIT License".to_string(),
1230 short_name: "MIT".to_string(),
1231 owner: Some("Example Owner".to_string()),
1232 homepage_url: Some("https://example.com/license".to_string()),
1233 spdx_license_key: "MIT".to_string(),
1234 other_spdx_license_keys: vec![],
1235 osi_license_key: Some("MIT".to_string()),
1236 text_urls: vec!["https://example.com/license.txt".to_string()],
1237 osi_url: Some("https://opensource.org/licenses/MIT".to_string()),
1238 faq_url: None,
1239 other_urls: vec![],
1240 category: None,
1241 is_exception: false,
1242 is_unknown: false,
1243 is_generic: false,
1244 notes: None,
1245 minimum_coverage: None,
1246 standard_notice: None,
1247 ignorable_copyrights: vec![],
1248 ignorable_holders: vec![],
1249 ignorable_authors: vec![],
1250 ignorable_urls: vec![],
1251 ignorable_emails: vec![],
1252 scancode_url: None,
1253 licensedb_url: None,
1254 spdx_url: None,
1255 text: "MIT text".to_string(),
1256 }];
1257 internal.license_rule_references = vec![crate::models::LicenseRuleReference {
1258 identifier: "license-clue_1.RULE".to_string(),
1259 license_expression: "unknown-license-reference".to_string(),
1260 is_license_text: false,
1261 is_license_notice: false,
1262 is_license_reference: false,
1263 is_license_tag: false,
1264 is_license_clue: true,
1265 is_license_intro: false,
1266 language: None,
1267 rule_url: None,
1268 is_required_phrase: false,
1269 skip_for_required_phrase_generation: false,
1270 replaced_by: vec![],
1271 is_continuous: false,
1272 is_synthetic: false,
1273 is_from_license: false,
1274 length: 0,
1275 relevance: None,
1276 minimum_coverage: None,
1277 referenced_filenames: vec![],
1278 notes: None,
1279 ignorable_copyrights: vec![],
1280 ignorable_holders: vec![],
1281 ignorable_authors: vec![],
1282 ignorable_urls: vec![],
1283 ignorable_emails: vec![],
1284 text: None,
1285 }];
1286 let output = Output::from(&internal);
1287
1288 let mut json_bytes = Vec::new();
1289 writer_for_format(OutputFormat::Json)
1290 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1291 .expect("json write should succeed");
1292 let json_value: Value =
1293 serde_json::from_slice(&json_bytes).expect("json output should parse");
1294 assert_eq!(
1295 json_value["license_references"][0]["spdx_license_key"],
1296 "MIT"
1297 );
1298 assert_eq!(json_value["license_references"][0]["key"], "mit");
1299 assert_eq!(json_value["license_references"][0]["language"], "en");
1300 assert_eq!(
1301 json_value["license_references"][0]["owner"],
1302 "Example Owner"
1303 );
1304 assert_eq!(
1305 json_value["license_references"][0]["homepage_url"],
1306 "https://example.com/license"
1307 );
1308 assert_eq!(
1309 json_value["license_references"][0]["osi_license_key"],
1310 "MIT"
1311 );
1312 assert_eq!(
1313 json_value["license_references"][0]["text_urls"][0],
1314 "https://example.com/license.txt"
1315 );
1316 assert_eq!(
1317 json_value["license_rule_references"][0]["identifier"],
1318 "license-clue_1.RULE"
1319 );
1320 assert_eq!(
1321 json_value["license_rule_references"][0]["relevance"],
1322 Value::Null
1323 );
1324 assert_eq!(
1325 json_value["license_rule_references"][0]["length"],
1326 Value::from(0)
1327 );
1328
1329 let mut jsonl_bytes = Vec::new();
1330 writer_for_format(OutputFormat::JsonLines)
1331 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1332 .expect("json-lines write should succeed");
1333 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1334 assert!(
1335 rendered
1336 .lines()
1337 .any(|line| line.contains("\"license_references\""))
1338 );
1339 assert!(
1340 rendered
1341 .lines()
1342 .any(|line| line.contains("\"license_rule_references\""))
1343 );
1344 }
1345
1346 #[test]
1347 fn test_json_and_json_lines_writers_include_top_level_license_detections() {
1348 let mut internal = sample_internal_output();
1349 internal.license_detections = vec![crate::models::TopLevelLicenseDetection {
1350 identifier: "mit-id".to_string(),
1351 license_expression: "mit".to_string(),
1352 license_expression_spdx: "MIT".to_string(),
1353 detection_count: 2,
1354 detection_log: vec![],
1355 reference_matches: vec![crate::models::Match {
1356 license_expression: "mit".to_string(),
1357 license_expression_spdx: "MIT".to_string(),
1358 from_file: Some("src/main.rs".to_string()),
1359 start_line: LineNumber::ONE,
1360 end_line: LineNumber::new(3).unwrap(),
1361 matcher: MatcherKind::Hash,
1362 score: MatchScore::MAX,
1363 matched_length: Some(10),
1364 match_coverage: Some(100.0),
1365 rule_relevance: Some(100),
1366 rule_identifier: "mit.LICENSE".to_string(),
1367 rule_url: None,
1368 matched_text: None,
1369 referenced_filenames: None,
1370 matched_text_diagnostics: None,
1371 }],
1372 }];
1373 let output = Output::from(&internal);
1374
1375 let mut json_bytes = Vec::new();
1376 writer_for_format(OutputFormat::Json)
1377 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1378 .expect("json write should succeed");
1379 let json_value: Value =
1380 serde_json::from_slice(&json_bytes).expect("json output should parse");
1381 assert_eq!(json_value["license_detections"][0]["identifier"], "mit-id");
1382 assert_eq!(json_value["license_detections"][0]["detection_count"], 2);
1383
1384 let mut jsonl_bytes = Vec::new();
1385 writer_for_format(OutputFormat::JsonLines)
1386 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1387 .expect("json-lines write should succeed");
1388 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1389 assert!(
1390 rendered
1391 .lines()
1392 .any(|line| line.contains("\"license_detections\""))
1393 );
1394 }
1395
1396 #[test]
1397 fn test_json_and_json_lines_writers_keep_empty_top_level_license_detections() {
1398 let output = Output::from(&sample_internal_output());
1399
1400 let mut json_bytes = Vec::new();
1401 writer_for_format(OutputFormat::Json)
1402 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1403 .expect("json write should succeed");
1404 let json_value: Value =
1405 serde_json::from_slice(&json_bytes).expect("json output should parse");
1406 assert_eq!(json_value["license_detections"], Value::Array(vec![]));
1407
1408 let mut jsonl_bytes = Vec::new();
1409 writer_for_format(OutputFormat::JsonLines)
1410 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1411 .expect("json-lines write should succeed");
1412 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1413 assert!(
1414 rendered
1415 .lines()
1416 .any(|line| line == r#"{"license_detections":[]}"#)
1417 );
1418 }
1419
1420 #[test]
1421 fn test_public_writer_normalizes_empty_package_maps_without_changing_schema_output() {
1422 let mut internal = sample_internal_output();
1423 internal.packages.push(Package::from_package_data(
1424 &PackageData {
1425 package_type: Some(crate::models::PackageType::Npm),
1426 name: Some("demo".to_string()),
1427 version: Some("1.0.0".to_string()),
1428 ..PackageData::default()
1429 },
1430 "scan/package.json".to_string(),
1431 ));
1432
1433 let output = Output::from(&internal);
1434 let raw_schema = serde_json::to_value(&output).expect("schema output should serialize");
1435 assert_eq!(
1436 raw_schema["packages"][0]["qualifiers"],
1437 serde_json::json!({})
1438 );
1439 assert_eq!(
1440 raw_schema["packages"][0]["extra_data"],
1441 serde_json::json!({})
1442 );
1443
1444 let mut bytes = Vec::new();
1445 writer_for_format(OutputFormat::Json)
1446 .write(&output, &mut bytes, &OutputWriteConfig::default())
1447 .expect("json write should succeed");
1448 let public_value: Value = serde_json::from_slice(&bytes).expect("public json should parse");
1449
1450 assert!(public_value["packages"][0]["qualifiers"].is_null());
1451 assert!(public_value["packages"][0]["extra_data"].is_null());
1452 }
1453
1454 #[test]
1455 fn test_cyclonedx_xml_writer_outputs_xml() {
1456 let output = Output::from(&sample_internal_output());
1457 let mut bytes = Vec::new();
1458 writer_for_format(OutputFormat::CycloneDxXml)
1459 .write(&output, &mut bytes, &OutputWriteConfig::default())
1460 .expect("cyclonedx xml write should succeed");
1461
1462 let rendered = String::from_utf8(bytes).expect("cyclonedx xml should be utf-8");
1463 assert!(rendered.contains("<bom xmlns=\"http://cyclonedx.org/schema/bom/1.7\""));
1464 assert!(rendered.contains("<components>"));
1465 }
1466
1467 #[test]
1468 fn test_cyclonedx_json_includes_component_license_expression() {
1469 let mut internal = sample_internal_output();
1470 internal.packages = vec![crate::models::Package {
1471 package_type: Some(crate::models::PackageType::Maven),
1472 namespace: Some("example".to_string()),
1473 name: Some("gradle-project".to_string()),
1474 version: Some("1.0.0".to_string()),
1475 qualifiers: None,
1476 subpath: None,
1477 primary_language: Some("Java".to_string()),
1478 description: None,
1479 release_date: None,
1480 parties: vec![],
1481 keywords: vec![],
1482 homepage_url: None,
1483 download_url: None,
1484 size: None,
1485 sha1: None,
1486 md5: None,
1487 sha256: None,
1488 sha512: None,
1489 bug_tracking_url: None,
1490 code_view_url: None,
1491 vcs_url: None,
1492 copyright: None,
1493 holder: None,
1494 declared_license_expression: Some("Apache-2.0".to_string()),
1495 declared_license_expression_spdx: Some("Apache-2.0".to_string()),
1496 license_detections: vec![],
1497 other_license_expression: None,
1498 other_license_expression_spdx: None,
1499 other_license_detections: vec![],
1500 extracted_license_statement: Some("Apache-2.0".to_string()),
1501 notice_text: None,
1502 source_packages: vec![],
1503 is_private: false,
1504 is_virtual: false,
1505 extra_data: None,
1506 repository_homepage_url: None,
1507 repository_download_url: None,
1508 api_data_url: None,
1509 datasource_ids: vec![],
1510 purl: Some("pkg:maven/example/gradle-project@1.0.0".to_string()),
1511 package_uid: PackageUid::from_raw(
1512 "pkg:maven/example/gradle-project@1.0.0?uuid=test".to_string(),
1513 ),
1514 datafile_paths: vec![],
1515 }];
1516 let output = Output::from(&internal);
1517
1518 let mut bytes = Vec::new();
1519 writer_for_format(OutputFormat::CycloneDxJson)
1520 .write(&output, &mut bytes, &OutputWriteConfig::default())
1521 .expect("cyclonedx json write should succeed");
1522
1523 let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
1524 let value: Value = serde_json::from_str(&rendered).expect("valid json");
1525
1526 assert_eq!(
1527 value["components"][0]["licenses"][0]["expression"],
1528 "Apache-2.0"
1529 );
1530 }
1531
1532 #[test]
1533 fn test_cyclonedx_external_references_are_deduplicated() {
1534 let mut internal = sample_internal_output();
1535 internal.packages = vec![Package::from_package_data(
1536 &PackageData {
1537 package_type: Some(crate::models::PackageType::Npm),
1538 name: Some("demo".to_string()),
1539 version: Some("1.0.0".to_string()),
1540 download_url: Some("https://example.com/download.tgz".to_string()),
1541 repository_download_url: Some("https://example.com/download.tgz".to_string()),
1542 homepage_url: Some("https://example.com".to_string()),
1543 repository_homepage_url: Some("https://example.com".to_string()),
1544 ..PackageData::default()
1545 },
1546 "scan/package.json".to_string(),
1547 )];
1548 let output = Output::from(&internal);
1549
1550 let mut json_bytes = Vec::new();
1551 writer_for_format(OutputFormat::CycloneDxJson)
1552 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1553 .expect("cyclonedx json write should succeed");
1554 let value: Value = serde_json::from_slice(&json_bytes).expect("valid cyclonedx json");
1555 let refs = value["components"][0]["externalReferences"]
1556 .as_array()
1557 .expect("external references should be an array");
1558 assert_eq!(refs.len(), 2);
1559
1560 let mut xml_bytes = Vec::new();
1561 writer_for_format(OutputFormat::CycloneDxXml)
1562 .write(&output, &mut xml_bytes, &OutputWriteConfig::default())
1563 .expect("cyclonedx xml write should succeed");
1564 let xml = String::from_utf8(xml_bytes).expect("cyclonedx xml should be utf-8");
1565 let components_section = xml
1570 .split("<components>")
1571 .nth(1)
1572 .and_then(|s| s.split("</components>").next())
1573 .expect("xml must contain a components section");
1574 assert_eq!(
1575 components_section
1576 .matches("https://example.com/download.tgz")
1577 .count(),
1578 1
1579 );
1580 assert_eq!(
1581 components_section
1582 .matches("https://example.com</url>")
1583 .count(),
1584 1
1585 );
1586 }
1587
1588 #[test]
1589 fn test_spdx_prefers_single_detected_package_name_over_scan_root() {
1590 let mut internal = sample_internal_output();
1591 internal.packages = vec![Package::from_package_data(
1592 &PackageData {
1593 package_type: Some(crate::models::PackageType::Npm),
1594 name: Some("detected-package".to_string()),
1595 version: Some("1.0.0".to_string()),
1596 ..PackageData::default()
1597 },
1598 "scan/package.json".to_string(),
1599 )];
1600 let output = Output::from(&internal);
1601
1602 let mut tv_bytes = Vec::new();
1603 writer_for_format(OutputFormat::SpdxTv)
1604 .write(
1605 &output,
1606 &mut tv_bytes,
1607 &OutputWriteConfig {
1608 format: OutputFormat::SpdxTv,
1609 custom_template: None,
1610 scanned_path: Some("scan-root".to_string()),
1611 },
1612 )
1613 .expect("spdx tv write should succeed");
1614 let tv = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
1615 assert!(tv.contains("PackageName: detected-package"));
1616 assert!(tv.contains("DocumentNamespace: http://spdx.org/spdxdocs/detected-package"));
1617
1618 let mut rdf_bytes = Vec::new();
1619 writer_for_format(OutputFormat::SpdxRdf)
1620 .write(
1621 &output,
1622 &mut rdf_bytes,
1623 &OutputWriteConfig {
1624 format: OutputFormat::SpdxRdf,
1625 custom_template: None,
1626 scanned_path: Some("scan-root".to_string()),
1627 },
1628 )
1629 .expect("spdx rdf write should succeed");
1630 let rdf = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
1631 assert!(rdf.contains("<spdx:name>detected-package</spdx:name>"));
1632 }
1633
1634 #[test]
1635 fn test_spdx_empty_scan_tag_value_matches_python_sentinel() {
1636 let output = Output {
1637 summary: None,
1638 tallies: None,
1639 tallies_of_key_files: None,
1640 tallies_by_facet: None,
1641 headers: vec![],
1642 packages: vec![],
1643 dependencies: vec![],
1644 license_detections: vec![],
1645 files: vec![],
1646 license_references: vec![],
1647 license_rule_references: vec![],
1648 };
1649 let mut bytes = Vec::new();
1650 writer_for_format(OutputFormat::SpdxTv)
1651 .write(
1652 &output,
1653 &mut bytes,
1654 &OutputWriteConfig {
1655 format: OutputFormat::SpdxTv,
1656 custom_template: None,
1657 scanned_path: Some("scan".to_string()),
1658 },
1659 )
1660 .expect("spdx tv write should succeed");
1661
1662 let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
1663 assert_eq!(rendered, "# No results for package 'scan'.\n");
1664 }
1665
1666 #[test]
1667 fn test_spdx_empty_scan_rdf_matches_python_sentinel() {
1668 let output = Output {
1669 summary: None,
1670 tallies: None,
1671 tallies_of_key_files: None,
1672 tallies_by_facet: None,
1673 headers: vec![],
1674 packages: vec![],
1675 dependencies: vec![],
1676 license_detections: vec![],
1677 files: vec![],
1678 license_references: vec![],
1679 license_rule_references: vec![],
1680 };
1681 let mut bytes = Vec::new();
1682 writer_for_format(OutputFormat::SpdxRdf)
1683 .write(
1684 &output,
1685 &mut bytes,
1686 &OutputWriteConfig {
1687 format: OutputFormat::SpdxRdf,
1688 custom_template: None,
1689 scanned_path: Some("scan".to_string()),
1690 },
1691 )
1692 .expect("spdx rdf write should succeed");
1693
1694 let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
1695 assert_eq!(rendered, "<!-- No results for package 'scan'. -->\n");
1696 }
1697
1698 #[test]
1699 fn test_html_writer_outputs_html_document() {
1700 let output = Output::from(&sample_internal_output());
1701 let mut bytes = Vec::new();
1702 writer_for_format(OutputFormat::Html)
1703 .write(&output, &mut bytes, &OutputWriteConfig::default())
1704 .expect("html write should succeed");
1705 let rendered = String::from_utf8(bytes).expect("html should be utf-8");
1706 assert!(rendered.contains("<!doctype html>"));
1707 assert!(rendered.contains("Provenant HTML Report"));
1708 }
1709
1710 #[test]
1711 fn test_custom_template_writer_renders_output_context() {
1712 let output = Output::from(&sample_internal_output());
1713 let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1714 let template_path = temp_dir.path().join("template.tera");
1715 fs::write(
1716 &template_path,
1717 "version={{ output.headers[0].output_format_version }} files={{ files | length }}",
1718 )
1719 .expect("template should be written");
1720
1721 let mut bytes = Vec::new();
1722 writer_for_format(OutputFormat::CustomTemplate)
1723 .write(
1724 &output,
1725 &mut bytes,
1726 &OutputWriteConfig {
1727 format: OutputFormat::CustomTemplate,
1728 custom_template: Some(template_path.to_string_lossy().to_string()),
1729 scanned_path: None,
1730 },
1731 )
1732 .expect("custom template write should succeed");
1733
1734 let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1735 assert!(rendered.contains("version=4.1.0"));
1736 assert!(rendered.contains("files=1"));
1737 }
1738
1739 #[test]
1740 fn test_custom_template_writer_exposes_scancode_namespace() {
1741 let output = Output::from(&sample_internal_output());
1742 let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1743 let template_path = temp_dir.path().join("template.j2");
1744 fs::write(
1747 &template_path,
1748 concat!(
1749 r#"lc={{ scancode.files.license_copyright["src/main.rs"] | length }} "#,
1750 r#"first={{ scancode.files.license_copyright["src/main.rs"][0].what }} "#,
1751 r#"name={{ scancode.files.infos["src/main.rs"].name }} "#,
1752 r#"pkg={{ scancode.files.package_data["src/main.rs"] | length }} "#,
1753 r#"ver={{ scancode.version }}"#,
1754 ),
1755 )
1756 .expect("template should be written");
1757
1758 let mut bytes = Vec::new();
1759 writer_for_format(OutputFormat::CustomTemplate)
1760 .write(
1761 &output,
1762 &mut bytes,
1763 &OutputWriteConfig {
1764 format: OutputFormat::CustomTemplate,
1765 custom_template: Some(template_path.to_string_lossy().to_string()),
1766 scanned_path: None,
1767 },
1768 )
1769 .expect("custom template write should succeed");
1770
1771 let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1772 assert!(rendered.contains("lc=2"), "rendered: {rendered}");
1774 assert!(rendered.contains("first=copyright"), "rendered: {rendered}");
1775 assert!(rendered.contains("name=main"), "rendered: {rendered}");
1776 assert!(rendered.contains("pkg=1"), "rendered: {rendered}");
1777 assert!(
1778 rendered.contains(&format!("ver={}", crate::version::BUILD_VERSION)),
1779 "rendered: {rendered}"
1780 );
1781 }
1782
1783 fn sample_internal_output() -> crate::models::Output {
1784 crate::models::Output {
1785 summary: None,
1786 tallies: None,
1787 tallies_of_key_files: None,
1788 tallies_by_facet: None,
1789 headers: vec![Header {
1790 tool_name: "provenant".to_string(),
1791 tool_version: crate::version::BUILD_VERSION.to_string(),
1792 options: serde_json::Map::new(),
1793 notice: crate::models::HEADER_NOTICE.to_string(),
1794 start_timestamp: "2026-01-01T000000.000000".to_string(),
1795 end_timestamp: "2026-01-01T000001.000000".to_string(),
1796 output_format_version: "4.1.0".to_string(),
1797 duration: 1.0,
1798 errors: vec![],
1799 warnings: vec![],
1800 extra_data: ExtraData {
1801 system_environment: SystemEnvironment {
1802 operating_system: "darwin".to_string(),
1803 cpu_architecture: "aarch64".to_string(),
1804 platform: "darwin".to_string(),
1805 platform_version: "26.3.1".to_string(),
1806 rust_version: "1.93.0".to_string(),
1807 },
1808 spdx_license_list_version: "3.27".to_string(),
1809 files_count: 1,
1810 directories_count: 1,
1811 excluded_count: 0,
1812 license_index_provenance: Some(crate::models::LicenseIndexProvenance {
1813 source: "embedded-artifact".to_string(),
1814 dataset_fingerprint: "test-fingerprint".to_string(),
1815 ignored_rules: vec![
1816 "gpl-2.0_and-unknown-license-reference_1.RULE".to_string(),
1817 ],
1818 ignored_licenses: vec![],
1819 ignored_rules_due_to_licenses: vec![],
1820 added_rules: vec![],
1821 replaced_rules: vec![],
1822 added_licenses: vec![],
1823 replaced_licenses: vec![],
1824 }),
1825 },
1826 }],
1827 packages: vec![],
1828 dependencies: vec![],
1829 license_detections: vec![],
1830 files: vec![FileInfo::new(
1831 "main.rs".to_string(),
1832 "main".to_string(),
1833 "rs".to_string(),
1834 "src/main.rs".to_string(),
1835 FileType::File,
1836 Some("text/plain".to_string()),
1837 None,
1838 42,
1839 None,
1840 Some(Sha1Digest::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").unwrap()),
1841 Some(Md5Digest::from_hex("d41d8cd98f00b204e9800998ecf8427e").unwrap()),
1842 Some(
1843 Sha256Digest::from_hex(
1844 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1845 )
1846 .unwrap(),
1847 ),
1848 Some("Rust".to_string()),
1849 vec![PackageData::default()],
1850 None,
1851 vec![LicenseDetection {
1852 license_expression: "mit".to_string(),
1853 license_expression_spdx: "MIT".to_string(),
1854 matches: vec![Match {
1855 license_expression: "mit".to_string(),
1856 license_expression_spdx: "MIT".to_string(),
1857 from_file: None,
1858 start_line: LineNumber::ONE,
1859 end_line: LineNumber::ONE,
1860 matcher: MatcherKind::Hash,
1861 score: MatchScore::MAX,
1862 matched_length: None,
1863 match_coverage: None,
1864 rule_relevance: None,
1865 rule_identifier: "mit_rule".to_string(),
1866 rule_url: None,
1867 matched_text: None,
1868 referenced_filenames: None,
1869 matched_text_diagnostics: None,
1870 }],
1871 detection_log: vec![],
1872 identifier: String::new(),
1873 }],
1874 vec![],
1875 vec![Copyright {
1876 copyright: "Copyright (c) Example".to_string(),
1877 normalized_copyright: None,
1878 start_line: LineNumber::ONE,
1879 end_line: LineNumber::ONE,
1880 }],
1881 vec![Holder {
1882 holder: "Example Org".to_string(),
1883 start_line: LineNumber::ONE,
1884 end_line: LineNumber::ONE,
1885 }],
1886 vec![Author {
1887 author: "Jane Doe".to_string(),
1888 start_line: LineNumber::ONE,
1889 end_line: LineNumber::ONE,
1890 }],
1891 vec![OutputEmail {
1892 email: "jane@example.com".to_string(),
1893 start_line: LineNumber::ONE,
1894 end_line: LineNumber::ONE,
1895 }],
1896 vec![OutputURL {
1897 url: "https://example.com".to_string(),
1898 start_line: LineNumber::ONE,
1899 end_line: LineNumber::ONE,
1900 }],
1901 vec![],
1902 vec![],
1903 )],
1904 license_references: vec![],
1905 license_rule_references: vec![],
1906 }
1907 }
1908}