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