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