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.3");
981 }
982
983 #[test]
984 fn test_json_writer_includes_summary_and_key_file_flags() {
985 let mut internal = sample_internal_output();
986 internal.summary = Some(crate::models::Summary {
987 declared_license_expression: Some("apache-2.0".to_string()),
988 license_clarity_score: Some(crate::models::LicenseClarityScore {
989 score: 100,
990 declared_license: true,
991 identification_precision: true,
992 has_license_text: true,
993 declared_copyrights: true,
994 conflicting_license_categories: false,
995 ambiguous_compound_licensing: false,
996 }),
997 declared_holder: Some("Example Corp.".to_string()),
998 primary_language: Some("Ruby".to_string()),
999 other_license_expressions: vec![crate::models::TallyEntry {
1000 value: Some("mit".to_string()),
1001 count: 1,
1002 }],
1003 other_holders: vec![
1004 crate::models::TallyEntry {
1005 value: None,
1006 count: 2,
1007 },
1008 crate::models::TallyEntry {
1009 value: Some("Other Corp.".to_string()),
1010 count: 1,
1011 },
1012 ],
1013 other_languages: vec![crate::models::TallyEntry {
1014 value: Some("Python".to_string()),
1015 count: 2,
1016 }],
1017 });
1018 internal.files[0].is_legal = true;
1019 internal.files[0].is_top_level = true;
1020 internal.files[0].is_key_file = true;
1021 let output = Output::from(&internal);
1022
1023 let mut bytes = Vec::new();
1024 writer_for_format(OutputFormat::Json)
1025 .write(&output, &mut bytes, &OutputWriteConfig::default())
1026 .expect("json write should succeed");
1027
1028 let rendered = String::from_utf8(bytes).expect("json should be utf-8");
1029 let value: Value = serde_json::from_str(&rendered).expect("valid json");
1030
1031 assert_eq!(
1032 value["summary"]["declared_license_expression"],
1033 "apache-2.0"
1034 );
1035 assert_eq!(value["summary"]["license_clarity_score"]["score"], 100);
1036 assert_eq!(value["summary"]["declared_holder"], "Example Corp.");
1037 assert_eq!(value["summary"]["primary_language"], "Ruby");
1038 assert_eq!(
1039 value["summary"]["other_license_expressions"][0]["value"],
1040 "mit"
1041 );
1042 assert!(value["summary"]["other_holders"][0]["value"].is_null());
1043 assert_eq!(value["summary"]["other_holders"][1]["value"], "Other Corp.");
1044 assert_eq!(value["summary"]["other_languages"][0]["value"], "Python");
1045 assert_eq!(value["files"][0]["is_key_file"], true);
1046 }
1047
1048 #[test]
1049 fn test_json_and_json_lines_writers_include_top_level_tallies() {
1050 let mut internal = sample_internal_output();
1051 internal.tallies = Some(crate::models::Tallies {
1052 detected_license_expression: vec![crate::models::TallyEntry {
1053 value: Some("mit".to_string()),
1054 count: 2,
1055 }],
1056 copyrights: vec![crate::models::TallyEntry {
1057 value: Some("Copyright (c) Example Org".to_string()),
1058 count: 1,
1059 }],
1060 holders: vec![crate::models::TallyEntry {
1061 value: Some("Example Org".to_string()),
1062 count: 1,
1063 }],
1064 authors: vec![crate::models::TallyEntry {
1065 value: Some("Jane Doe".to_string()),
1066 count: 1,
1067 }],
1068 programming_language: vec![crate::models::TallyEntry {
1069 value: Some("Rust".to_string()),
1070 count: 1,
1071 }],
1072 });
1073 let output = Output::from(&internal);
1074
1075 let mut json_bytes = Vec::new();
1076 writer_for_format(OutputFormat::Json)
1077 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1078 .expect("json write should succeed");
1079 let json_value: Value =
1080 serde_json::from_slice(&json_bytes).expect("json output should parse");
1081 assert_eq!(
1082 json_value["tallies"]["detected_license_expression"][0]["value"],
1083 "mit"
1084 );
1085 assert_eq!(
1086 json_value["tallies"]["programming_language"][0]["value"],
1087 "Rust"
1088 );
1089
1090 let mut jsonl_bytes = Vec::new();
1091 writer_for_format(OutputFormat::JsonLines)
1092 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1093 .expect("json-lines write should succeed");
1094 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1095 assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
1096 }
1097
1098 #[test]
1099 fn test_json_and_json_lines_writers_include_key_file_tallies() {
1100 let mut internal = sample_internal_output();
1101 internal.tallies_of_key_files = Some(crate::models::Tallies {
1102 detected_license_expression: vec![crate::models::TallyEntry {
1103 value: Some("apache-2.0".to_string()),
1104 count: 1,
1105 }],
1106 copyrights: vec![],
1107 holders: vec![],
1108 authors: vec![],
1109 programming_language: vec![crate::models::TallyEntry {
1110 value: Some("Markdown".to_string()),
1111 count: 1,
1112 }],
1113 });
1114 let output = Output::from(&internal);
1115
1116 let mut json_bytes = Vec::new();
1117 writer_for_format(OutputFormat::Json)
1118 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1119 .expect("json write should succeed");
1120 let json_value: Value =
1121 serde_json::from_slice(&json_bytes).expect("json output should parse");
1122 assert_eq!(
1123 json_value["tallies_of_key_files"]["detected_license_expression"][0]["value"],
1124 "apache-2.0"
1125 );
1126
1127 let mut jsonl_bytes = Vec::new();
1128 writer_for_format(OutputFormat::JsonLines)
1129 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1130 .expect("json-lines write should succeed");
1131 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1132 assert!(
1133 rendered
1134 .lines()
1135 .any(|line| line.contains("\"tallies_of_key_files\""))
1136 );
1137 }
1138
1139 #[test]
1140 fn test_json_and_json_lines_writers_include_file_tallies() {
1141 let mut internal = sample_internal_output();
1142 internal.files[0].tallies = Some(crate::models::Tallies {
1143 detected_license_expression: vec![crate::models::TallyEntry {
1144 value: Some("mit".to_string()),
1145 count: 1,
1146 }],
1147 copyrights: vec![crate::models::TallyEntry {
1148 value: None,
1149 count: 1,
1150 }],
1151 holders: vec![],
1152 authors: vec![],
1153 programming_language: vec![crate::models::TallyEntry {
1154 value: Some("Rust".to_string()),
1155 count: 1,
1156 }],
1157 });
1158 let output = Output::from(&internal);
1159
1160 let mut json_bytes = Vec::new();
1161 writer_for_format(OutputFormat::Json)
1162 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1163 .expect("json write should succeed");
1164 let json_value: Value =
1165 serde_json::from_slice(&json_bytes).expect("json output should parse");
1166 assert_eq!(
1167 json_value["files"][0]["tallies"]["detected_license_expression"][0]["value"],
1168 "mit"
1169 );
1170
1171 let mut jsonl_bytes = Vec::new();
1172 writer_for_format(OutputFormat::JsonLines)
1173 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1174 .expect("json-lines write should succeed");
1175 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1176 assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
1177 }
1178
1179 #[test]
1180 fn test_json_and_json_lines_writers_include_facets_and_tallies_by_facet() {
1181 let mut internal = sample_internal_output();
1182 internal.files[0].facets = vec!["core".to_string(), "docs".to_string()];
1183 internal.tallies_by_facet = Some(vec![crate::models::FacetTallies {
1184 facet: "core".to_string(),
1185 tallies: crate::models::Tallies {
1186 detected_license_expression: vec![crate::models::TallyEntry {
1187 value: Some("mit".to_string()),
1188 count: 1,
1189 }],
1190 copyrights: vec![],
1191 holders: vec![],
1192 authors: vec![],
1193 programming_language: vec![],
1194 },
1195 }]);
1196 let output = Output::from(&internal);
1197
1198 let mut json_bytes = Vec::new();
1199 writer_for_format(OutputFormat::Json)
1200 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1201 .expect("json write should succeed");
1202 let json_value: Value =
1203 serde_json::from_slice(&json_bytes).expect("json output should parse");
1204 assert_eq!(json_value["files"][0]["facets"][0], "core");
1205 assert_eq!(json_value["tallies_by_facet"][0]["facet"], "core");
1206
1207 let mut jsonl_bytes = Vec::new();
1208 writer_for_format(OutputFormat::JsonLines)
1209 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1210 .expect("json-lines write should succeed");
1211 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1212 assert!(
1213 rendered
1214 .lines()
1215 .any(|line| line.contains("\"tallies_by_facet\""))
1216 );
1217 }
1218
1219 #[test]
1220 fn test_json_and_json_lines_writers_include_top_level_license_references() {
1221 let mut internal = sample_internal_output();
1222 internal.license_references = vec![crate::models::LicenseReference {
1223 key: Some("mit".to_string()),
1224 language: Some("en".to_string()),
1225 name: "MIT License".to_string(),
1226 short_name: "MIT".to_string(),
1227 owner: Some("Example Owner".to_string()),
1228 homepage_url: Some("https://example.com/license".to_string()),
1229 spdx_license_key: "MIT".to_string(),
1230 other_spdx_license_keys: vec![],
1231 osi_license_key: Some("MIT".to_string()),
1232 text_urls: vec!["https://example.com/license.txt".to_string()],
1233 osi_url: Some("https://opensource.org/licenses/MIT".to_string()),
1234 faq_url: None,
1235 other_urls: vec![],
1236 category: None,
1237 is_exception: false,
1238 is_unknown: false,
1239 is_generic: false,
1240 notes: None,
1241 minimum_coverage: None,
1242 standard_notice: None,
1243 ignorable_copyrights: vec![],
1244 ignorable_holders: vec![],
1245 ignorable_authors: vec![],
1246 ignorable_urls: vec![],
1247 ignorable_emails: vec![],
1248 scancode_url: None,
1249 licensedb_url: None,
1250 spdx_url: None,
1251 text: "MIT text".to_string(),
1252 }];
1253 internal.license_rule_references = vec![crate::models::LicenseRuleReference {
1254 identifier: "license-clue_1.RULE".to_string(),
1255 license_expression: "unknown-license-reference".to_string(),
1256 is_license_text: false,
1257 is_license_notice: false,
1258 is_license_reference: false,
1259 is_license_tag: false,
1260 is_license_clue: true,
1261 is_license_intro: false,
1262 language: None,
1263 rule_url: None,
1264 is_required_phrase: false,
1265 skip_for_required_phrase_generation: false,
1266 replaced_by: vec![],
1267 is_continuous: false,
1268 is_synthetic: false,
1269 is_from_license: false,
1270 length: 0,
1271 relevance: None,
1272 minimum_coverage: None,
1273 referenced_filenames: vec![],
1274 notes: None,
1275 ignorable_copyrights: vec![],
1276 ignorable_holders: vec![],
1277 ignorable_authors: vec![],
1278 ignorable_urls: vec![],
1279 ignorable_emails: vec![],
1280 text: None,
1281 }];
1282 let output = Output::from(&internal);
1283
1284 let mut json_bytes = Vec::new();
1285 writer_for_format(OutputFormat::Json)
1286 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1287 .expect("json write should succeed");
1288 let json_value: Value =
1289 serde_json::from_slice(&json_bytes).expect("json output should parse");
1290 assert_eq!(
1291 json_value["license_references"][0]["spdx_license_key"],
1292 "MIT"
1293 );
1294 assert_eq!(json_value["license_references"][0]["key"], "mit");
1295 assert_eq!(json_value["license_references"][0]["language"], "en");
1296 assert_eq!(
1297 json_value["license_references"][0]["owner"],
1298 "Example Owner"
1299 );
1300 assert_eq!(
1301 json_value["license_references"][0]["homepage_url"],
1302 "https://example.com/license"
1303 );
1304 assert_eq!(
1305 json_value["license_references"][0]["osi_license_key"],
1306 "MIT"
1307 );
1308 assert_eq!(
1309 json_value["license_references"][0]["text_urls"][0],
1310 "https://example.com/license.txt"
1311 );
1312 assert_eq!(
1313 json_value["license_rule_references"][0]["identifier"],
1314 "license-clue_1.RULE"
1315 );
1316 assert_eq!(
1317 json_value["license_rule_references"][0]["relevance"],
1318 Value::Null
1319 );
1320 assert_eq!(
1321 json_value["license_rule_references"][0]["length"],
1322 Value::from(0)
1323 );
1324
1325 let mut jsonl_bytes = Vec::new();
1326 writer_for_format(OutputFormat::JsonLines)
1327 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1328 .expect("json-lines write should succeed");
1329 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1330 assert!(
1331 rendered
1332 .lines()
1333 .any(|line| line.contains("\"license_references\""))
1334 );
1335 assert!(
1336 rendered
1337 .lines()
1338 .any(|line| line.contains("\"license_rule_references\""))
1339 );
1340 }
1341
1342 #[test]
1343 fn test_json_and_json_lines_writers_include_top_level_license_detections() {
1344 let mut internal = sample_internal_output();
1345 internal.license_detections = vec![crate::models::TopLevelLicenseDetection {
1346 identifier: "mit-id".to_string(),
1347 license_expression: "mit".to_string(),
1348 license_expression_spdx: "MIT".to_string(),
1349 detection_count: 2,
1350 detection_log: vec![],
1351 reference_matches: vec![crate::models::Match {
1352 license_expression: "mit".to_string(),
1353 license_expression_spdx: "MIT".to_string(),
1354 from_file: Some("src/main.rs".to_string()),
1355 start_line: LineNumber::ONE,
1356 end_line: LineNumber::new(3).unwrap(),
1357 matcher: MatcherKind::Hash,
1358 score: MatchScore::MAX,
1359 matched_length: Some(10),
1360 match_coverage: Some(100.0),
1361 rule_relevance: Some(100),
1362 rule_identifier: "mit.LICENSE".to_string(),
1363 rule_url: None,
1364 matched_text: None,
1365 referenced_filenames: None,
1366 matched_text_diagnostics: None,
1367 }],
1368 }];
1369 let output = Output::from(&internal);
1370
1371 let mut json_bytes = Vec::new();
1372 writer_for_format(OutputFormat::Json)
1373 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1374 .expect("json write should succeed");
1375 let json_value: Value =
1376 serde_json::from_slice(&json_bytes).expect("json output should parse");
1377 assert_eq!(json_value["license_detections"][0]["identifier"], "mit-id");
1378 assert_eq!(json_value["license_detections"][0]["detection_count"], 2);
1379
1380 let mut jsonl_bytes = Vec::new();
1381 writer_for_format(OutputFormat::JsonLines)
1382 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1383 .expect("json-lines write should succeed");
1384 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1385 assert!(
1386 rendered
1387 .lines()
1388 .any(|line| line.contains("\"license_detections\""))
1389 );
1390 }
1391
1392 #[test]
1393 fn test_json_and_json_lines_writers_keep_empty_top_level_license_detections() {
1394 let output = Output::from(&sample_internal_output());
1395
1396 let mut json_bytes = Vec::new();
1397 writer_for_format(OutputFormat::Json)
1398 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1399 .expect("json write should succeed");
1400 let json_value: Value =
1401 serde_json::from_slice(&json_bytes).expect("json output should parse");
1402 assert_eq!(json_value["license_detections"], Value::Array(vec![]));
1403
1404 let mut jsonl_bytes = Vec::new();
1405 writer_for_format(OutputFormat::JsonLines)
1406 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1407 .expect("json-lines write should succeed");
1408 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1409 assert!(
1410 rendered
1411 .lines()
1412 .any(|line| line == r#"{"license_detections":[]}"#)
1413 );
1414 }
1415
1416 #[test]
1417 fn test_public_writer_normalizes_empty_package_maps_without_changing_schema_output() {
1418 let mut internal = sample_internal_output();
1419 internal.packages.push(Package::from_package_data(
1420 &PackageData {
1421 package_type: Some(crate::models::PackageType::Npm),
1422 name: Some("demo".to_string()),
1423 version: Some("1.0.0".to_string()),
1424 ..PackageData::default()
1425 },
1426 "scan/package.json".to_string(),
1427 ));
1428
1429 let output = Output::from(&internal);
1430 let raw_schema = serde_json::to_value(&output).expect("schema output should serialize");
1431 assert_eq!(
1432 raw_schema["packages"][0]["qualifiers"],
1433 serde_json::json!({})
1434 );
1435 assert_eq!(
1436 raw_schema["packages"][0]["extra_data"],
1437 serde_json::json!({})
1438 );
1439
1440 let mut bytes = Vec::new();
1441 writer_for_format(OutputFormat::Json)
1442 .write(&output, &mut bytes, &OutputWriteConfig::default())
1443 .expect("json write should succeed");
1444 let public_value: Value = serde_json::from_slice(&bytes).expect("public json should parse");
1445
1446 assert!(public_value["packages"][0]["qualifiers"].is_null());
1447 assert!(public_value["packages"][0]["extra_data"].is_null());
1448 }
1449
1450 #[test]
1451 fn test_cyclonedx_xml_writer_outputs_xml() {
1452 let output = Output::from(&sample_internal_output());
1453 let mut bytes = Vec::new();
1454 writer_for_format(OutputFormat::CycloneDxXml)
1455 .write(&output, &mut bytes, &OutputWriteConfig::default())
1456 .expect("cyclonedx xml write should succeed");
1457
1458 let rendered = String::from_utf8(bytes).expect("cyclonedx xml should be utf-8");
1459 assert!(rendered.contains("<bom xmlns=\"http://cyclonedx.org/schema/bom/1.3\""));
1460 assert!(rendered.contains("<components>"));
1461 }
1462
1463 #[test]
1464 fn test_cyclonedx_json_includes_component_license_expression() {
1465 let mut internal = sample_internal_output();
1466 internal.packages = vec![crate::models::Package {
1467 package_type: Some(crate::models::PackageType::Maven),
1468 namespace: Some("example".to_string()),
1469 name: Some("gradle-project".to_string()),
1470 version: Some("1.0.0".to_string()),
1471 qualifiers: None,
1472 subpath: None,
1473 primary_language: Some("Java".to_string()),
1474 description: None,
1475 release_date: None,
1476 parties: vec![],
1477 keywords: vec![],
1478 homepage_url: None,
1479 download_url: None,
1480 size: None,
1481 sha1: None,
1482 md5: None,
1483 sha256: None,
1484 sha512: None,
1485 bug_tracking_url: None,
1486 code_view_url: None,
1487 vcs_url: None,
1488 copyright: None,
1489 holder: None,
1490 declared_license_expression: Some("Apache-2.0".to_string()),
1491 declared_license_expression_spdx: Some("Apache-2.0".to_string()),
1492 license_detections: vec![],
1493 other_license_expression: None,
1494 other_license_expression_spdx: None,
1495 other_license_detections: vec![],
1496 extracted_license_statement: Some("Apache-2.0".to_string()),
1497 notice_text: None,
1498 source_packages: vec![],
1499 is_private: false,
1500 is_virtual: false,
1501 extra_data: None,
1502 repository_homepage_url: None,
1503 repository_download_url: None,
1504 api_data_url: None,
1505 datasource_ids: vec![],
1506 purl: Some("pkg:maven/example/gradle-project@1.0.0".to_string()),
1507 package_uid: PackageUid::from_raw(
1508 "pkg:maven/example/gradle-project@1.0.0?uuid=test".to_string(),
1509 ),
1510 datafile_paths: vec![],
1511 }];
1512 let output = Output::from(&internal);
1513
1514 let mut bytes = Vec::new();
1515 writer_for_format(OutputFormat::CycloneDxJson)
1516 .write(&output, &mut bytes, &OutputWriteConfig::default())
1517 .expect("cyclonedx json write should succeed");
1518
1519 let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
1520 let value: Value = serde_json::from_str(&rendered).expect("valid json");
1521
1522 assert_eq!(
1523 value["components"][0]["licenses"][0]["expression"],
1524 "Apache-2.0"
1525 );
1526 }
1527
1528 #[test]
1529 fn test_cyclonedx_external_references_are_deduplicated() {
1530 let mut internal = sample_internal_output();
1531 internal.packages = vec![Package::from_package_data(
1532 &PackageData {
1533 package_type: Some(crate::models::PackageType::Npm),
1534 name: Some("demo".to_string()),
1535 version: Some("1.0.0".to_string()),
1536 download_url: Some("https://example.com/download.tgz".to_string()),
1537 repository_download_url: Some("https://example.com/download.tgz".to_string()),
1538 homepage_url: Some("https://example.com".to_string()),
1539 repository_homepage_url: Some("https://example.com".to_string()),
1540 ..PackageData::default()
1541 },
1542 "scan/package.json".to_string(),
1543 )];
1544 let output = Output::from(&internal);
1545
1546 let mut json_bytes = Vec::new();
1547 writer_for_format(OutputFormat::CycloneDxJson)
1548 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1549 .expect("cyclonedx json write should succeed");
1550 let value: Value = serde_json::from_slice(&json_bytes).expect("valid cyclonedx json");
1551 let refs = value["components"][0]["externalReferences"]
1552 .as_array()
1553 .expect("external references should be an array");
1554 assert_eq!(refs.len(), 2);
1555
1556 let mut xml_bytes = Vec::new();
1557 writer_for_format(OutputFormat::CycloneDxXml)
1558 .write(&output, &mut xml_bytes, &OutputWriteConfig::default())
1559 .expect("cyclonedx xml write should succeed");
1560 let xml = String::from_utf8(xml_bytes).expect("cyclonedx xml should be utf-8");
1561 let components_section = xml
1566 .split("<components>")
1567 .nth(1)
1568 .and_then(|s| s.split("</components>").next())
1569 .expect("xml must contain a components section");
1570 assert_eq!(
1571 components_section
1572 .matches("https://example.com/download.tgz")
1573 .count(),
1574 1
1575 );
1576 assert_eq!(
1577 components_section
1578 .matches("https://example.com</url>")
1579 .count(),
1580 1
1581 );
1582 }
1583
1584 #[test]
1585 fn test_spdx_prefers_single_detected_package_name_over_scan_root() {
1586 let mut internal = sample_internal_output();
1587 internal.packages = vec![Package::from_package_data(
1588 &PackageData {
1589 package_type: Some(crate::models::PackageType::Npm),
1590 name: Some("detected-package".to_string()),
1591 version: Some("1.0.0".to_string()),
1592 ..PackageData::default()
1593 },
1594 "scan/package.json".to_string(),
1595 )];
1596 let output = Output::from(&internal);
1597
1598 let mut tv_bytes = Vec::new();
1599 writer_for_format(OutputFormat::SpdxTv)
1600 .write(
1601 &output,
1602 &mut tv_bytes,
1603 &OutputWriteConfig {
1604 format: OutputFormat::SpdxTv,
1605 custom_template: None,
1606 scanned_path: Some("scan-root".to_string()),
1607 },
1608 )
1609 .expect("spdx tv write should succeed");
1610 let tv = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
1611 assert!(tv.contains("PackageName: detected-package"));
1612 assert!(tv.contains("DocumentNamespace: http://spdx.org/spdxdocs/detected-package"));
1613
1614 let mut rdf_bytes = Vec::new();
1615 writer_for_format(OutputFormat::SpdxRdf)
1616 .write(
1617 &output,
1618 &mut rdf_bytes,
1619 &OutputWriteConfig {
1620 format: OutputFormat::SpdxRdf,
1621 custom_template: None,
1622 scanned_path: Some("scan-root".to_string()),
1623 },
1624 )
1625 .expect("spdx rdf write should succeed");
1626 let rdf = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
1627 assert!(rdf.contains("<spdx:name>detected-package</spdx:name>"));
1628 }
1629
1630 #[test]
1631 fn test_spdx_empty_scan_tag_value_matches_python_sentinel() {
1632 let output = Output {
1633 summary: None,
1634 tallies: None,
1635 tallies_of_key_files: None,
1636 tallies_by_facet: None,
1637 headers: vec![],
1638 packages: vec![],
1639 dependencies: vec![],
1640 license_detections: vec![],
1641 files: vec![],
1642 license_references: vec![],
1643 license_rule_references: vec![],
1644 };
1645 let mut bytes = Vec::new();
1646 writer_for_format(OutputFormat::SpdxTv)
1647 .write(
1648 &output,
1649 &mut bytes,
1650 &OutputWriteConfig {
1651 format: OutputFormat::SpdxTv,
1652 custom_template: None,
1653 scanned_path: Some("scan".to_string()),
1654 },
1655 )
1656 .expect("spdx tv write should succeed");
1657
1658 let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
1659 assert_eq!(rendered, "# No results for package 'scan'.\n");
1660 }
1661
1662 #[test]
1663 fn test_spdx_empty_scan_rdf_matches_python_sentinel() {
1664 let output = Output {
1665 summary: None,
1666 tallies: None,
1667 tallies_of_key_files: None,
1668 tallies_by_facet: None,
1669 headers: vec![],
1670 packages: vec![],
1671 dependencies: vec![],
1672 license_detections: vec![],
1673 files: vec![],
1674 license_references: vec![],
1675 license_rule_references: vec![],
1676 };
1677 let mut bytes = Vec::new();
1678 writer_for_format(OutputFormat::SpdxRdf)
1679 .write(
1680 &output,
1681 &mut bytes,
1682 &OutputWriteConfig {
1683 format: OutputFormat::SpdxRdf,
1684 custom_template: None,
1685 scanned_path: Some("scan".to_string()),
1686 },
1687 )
1688 .expect("spdx rdf write should succeed");
1689
1690 let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
1691 assert_eq!(rendered, "<!-- No results for package 'scan'. -->\n");
1692 }
1693
1694 #[test]
1695 fn test_html_writer_outputs_html_document() {
1696 let output = Output::from(&sample_internal_output());
1697 let mut bytes = Vec::new();
1698 writer_for_format(OutputFormat::Html)
1699 .write(&output, &mut bytes, &OutputWriteConfig::default())
1700 .expect("html write should succeed");
1701 let rendered = String::from_utf8(bytes).expect("html should be utf-8");
1702 assert!(rendered.contains("<!doctype html>"));
1703 assert!(rendered.contains("Provenant HTML Report"));
1704 }
1705
1706 #[test]
1707 fn test_custom_template_writer_renders_output_context() {
1708 let output = Output::from(&sample_internal_output());
1709 let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1710 let template_path = temp_dir.path().join("template.tera");
1711 fs::write(
1712 &template_path,
1713 "version={{ output.headers[0].output_format_version }} files={{ files | length }}",
1714 )
1715 .expect("template should be written");
1716
1717 let mut bytes = Vec::new();
1718 writer_for_format(OutputFormat::CustomTemplate)
1719 .write(
1720 &output,
1721 &mut bytes,
1722 &OutputWriteConfig {
1723 format: OutputFormat::CustomTemplate,
1724 custom_template: Some(template_path.to_string_lossy().to_string()),
1725 scanned_path: None,
1726 },
1727 )
1728 .expect("custom template write should succeed");
1729
1730 let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1731 assert!(rendered.contains("version=4.1.0"));
1732 assert!(rendered.contains("files=1"));
1733 }
1734
1735 #[test]
1736 fn test_custom_template_writer_exposes_scancode_namespace() {
1737 let output = Output::from(&sample_internal_output());
1738 let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1739 let template_path = temp_dir.path().join("template.j2");
1740 fs::write(
1743 &template_path,
1744 concat!(
1745 r#"lc={{ scancode.files.license_copyright["src/main.rs"] | length }} "#,
1746 r#"first={{ scancode.files.license_copyright["src/main.rs"][0].what }} "#,
1747 r#"name={{ scancode.files.infos["src/main.rs"].name }} "#,
1748 r#"pkg={{ scancode.files.package_data["src/main.rs"] | length }} "#,
1749 r#"ver={{ scancode.version }}"#,
1750 ),
1751 )
1752 .expect("template should be written");
1753
1754 let mut bytes = Vec::new();
1755 writer_for_format(OutputFormat::CustomTemplate)
1756 .write(
1757 &output,
1758 &mut bytes,
1759 &OutputWriteConfig {
1760 format: OutputFormat::CustomTemplate,
1761 custom_template: Some(template_path.to_string_lossy().to_string()),
1762 scanned_path: None,
1763 },
1764 )
1765 .expect("custom template write should succeed");
1766
1767 let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1768 assert!(rendered.contains("lc=2"), "rendered: {rendered}");
1770 assert!(rendered.contains("first=copyright"), "rendered: {rendered}");
1771 assert!(rendered.contains("name=main"), "rendered: {rendered}");
1772 assert!(rendered.contains("pkg=1"), "rendered: {rendered}");
1773 assert!(
1774 rendered.contains(&format!("ver={}", crate::version::BUILD_VERSION)),
1775 "rendered: {rendered}"
1776 );
1777 }
1778
1779 fn sample_internal_output() -> crate::models::Output {
1780 crate::models::Output {
1781 summary: None,
1782 tallies: None,
1783 tallies_of_key_files: None,
1784 tallies_by_facet: None,
1785 headers: vec![Header {
1786 tool_name: "provenant".to_string(),
1787 tool_version: crate::version::BUILD_VERSION.to_string(),
1788 options: serde_json::Map::new(),
1789 notice: crate::models::HEADER_NOTICE.to_string(),
1790 start_timestamp: "2026-01-01T000000.000000".to_string(),
1791 end_timestamp: "2026-01-01T000001.000000".to_string(),
1792 output_format_version: "4.1.0".to_string(),
1793 duration: 1.0,
1794 errors: vec![],
1795 warnings: vec![],
1796 extra_data: ExtraData {
1797 system_environment: SystemEnvironment {
1798 operating_system: "darwin".to_string(),
1799 cpu_architecture: "aarch64".to_string(),
1800 platform: "darwin".to_string(),
1801 platform_version: "26.3.1".to_string(),
1802 rust_version: "1.93.0".to_string(),
1803 },
1804 spdx_license_list_version: "3.27".to_string(),
1805 files_count: 1,
1806 directories_count: 1,
1807 excluded_count: 0,
1808 license_index_provenance: Some(crate::models::LicenseIndexProvenance {
1809 source: "embedded-artifact".to_string(),
1810 dataset_fingerprint: "test-fingerprint".to_string(),
1811 ignored_rules: vec![
1812 "gpl-2.0_and-unknown-license-reference_1.RULE".to_string(),
1813 ],
1814 ignored_licenses: vec![],
1815 ignored_rules_due_to_licenses: vec![],
1816 added_rules: vec![],
1817 replaced_rules: vec![],
1818 added_licenses: vec![],
1819 replaced_licenses: vec![],
1820 }),
1821 },
1822 }],
1823 packages: vec![],
1824 dependencies: vec![],
1825 license_detections: vec![],
1826 files: vec![FileInfo::new(
1827 "main.rs".to_string(),
1828 "main".to_string(),
1829 "rs".to_string(),
1830 "src/main.rs".to_string(),
1831 FileType::File,
1832 Some("text/plain".to_string()),
1833 None,
1834 42,
1835 None,
1836 Some(Sha1Digest::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").unwrap()),
1837 Some(Md5Digest::from_hex("d41d8cd98f00b204e9800998ecf8427e").unwrap()),
1838 Some(
1839 Sha256Digest::from_hex(
1840 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1841 )
1842 .unwrap(),
1843 ),
1844 Some("Rust".to_string()),
1845 vec![PackageData::default()],
1846 None,
1847 vec![LicenseDetection {
1848 license_expression: "mit".to_string(),
1849 license_expression_spdx: "MIT".to_string(),
1850 matches: vec![Match {
1851 license_expression: "mit".to_string(),
1852 license_expression_spdx: "MIT".to_string(),
1853 from_file: None,
1854 start_line: LineNumber::ONE,
1855 end_line: LineNumber::ONE,
1856 matcher: MatcherKind::Hash,
1857 score: MatchScore::MAX,
1858 matched_length: None,
1859 match_coverage: None,
1860 rule_relevance: None,
1861 rule_identifier: "mit_rule".to_string(),
1862 rule_url: None,
1863 matched_text: None,
1864 referenced_filenames: None,
1865 matched_text_diagnostics: None,
1866 }],
1867 detection_log: vec![],
1868 identifier: String::new(),
1869 }],
1870 vec![],
1871 vec![Copyright {
1872 copyright: "Copyright (c) Example".to_string(),
1873 normalized_copyright: None,
1874 start_line: LineNumber::ONE,
1875 end_line: LineNumber::ONE,
1876 }],
1877 vec![Holder {
1878 holder: "Example Org".to_string(),
1879 start_line: LineNumber::ONE,
1880 end_line: LineNumber::ONE,
1881 }],
1882 vec![Author {
1883 author: "Jane Doe".to_string(),
1884 start_line: LineNumber::ONE,
1885 end_line: LineNumber::ONE,
1886 }],
1887 vec![OutputEmail {
1888 email: "jane@example.com".to_string(),
1889 start_line: LineNumber::ONE,
1890 end_line: LineNumber::ONE,
1891 }],
1892 vec![OutputURL {
1893 url: "https://example.com".to_string(),
1894 start_line: LineNumber::ONE,
1895 end_line: LineNumber::ONE,
1896 }],
1897 vec![],
1898 vec![],
1899 )],
1900 license_references: vec![],
1901 license_rule_references: vec![],
1902 }
1903 }
1904}