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 assert!(
550 rendered.contains("Creator: Tool: Provenant-"),
551 "SPDX TV must emit CreationInfo Creator for spdx-tools"
552 );
553 assert!(
554 rendered.contains("Created:"),
555 "SPDX TV must emit CreationInfo Created for spdx-tools"
556 );
557 assert!(
558 rendered.contains("Relationship: SPDXRef-DOCUMENT DESCRIBES SPDXRef-001"),
559 "SPDX TV must describe the package from the document"
560 );
561 }
562
563 #[test]
564 fn test_spdx_rdf_writer_outputs_xml() {
565 let output = Output::from(&sample_internal_output());
566 let mut bytes = Vec::new();
567 writer_for_format(OutputFormat::SpdxRdf)
568 .write(
569 &output,
570 &mut bytes,
571 &OutputWriteConfig {
572 format: OutputFormat::SpdxRdf,
573 custom_template: None,
574 scanned_path: Some("scan".to_string()),
575 },
576 )
577 .expect("spdx rdf write should succeed");
578
579 let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
580 assert!(rendered.contains("<rdf:RDF"));
581 assert!(rendered.contains("<spdx:SpdxDocument"));
582 assert!(rendered.contains("<spdx:created>2026-01-01T00:00:00Z</spdx:created>"));
583 assert!(
584 rendered.contains("<spdx:creator>Tool: Provenant-"),
585 "SPDX RDF must emit a creator Actor for spdx-tools"
586 );
587 assert!(
588 rendered.contains("relationshipType_describes"),
589 "SPDX RDF must include Document DESCRIBES package"
590 );
591 }
592
593 #[test]
594 fn test_cyclonedx_writers_keep_iso_timestamps_when_headers_use_scancode_format() {
595 let mut internal = sample_internal_output();
596 internal.packages.push(Package::from_package_data(
597 &PackageData {
598 name: Some("demo".to_string()),
599 version: Some("1.0.0".to_string()),
600 ..PackageData::default()
601 },
602 "scan/package.json".to_string(),
603 ));
604 let output = Output::from(&internal);
605
606 let mut json_bytes = Vec::new();
607 writer_for_format(OutputFormat::CycloneDxJson)
608 .write(
609 &output,
610 &mut json_bytes,
611 &OutputWriteConfig {
612 format: OutputFormat::CycloneDxJson,
613 custom_template: None,
614 scanned_path: Some("scan".to_string()),
615 },
616 )
617 .expect("cyclonedx json write should succeed");
618 let json_value: Value =
619 serde_json::from_slice(&json_bytes).expect("cyclonedx json should parse");
620 assert_eq!(
621 json_value["metadata"]["timestamp"].as_str(),
622 Some("2026-01-01T00:00:01Z")
623 );
624
625 let mut xml_bytes = Vec::new();
626 writer_for_format(OutputFormat::CycloneDxXml)
627 .write(
628 &output,
629 &mut xml_bytes,
630 &OutputWriteConfig {
631 format: OutputFormat::CycloneDxXml,
632 custom_template: None,
633 scanned_path: Some("scan".to_string()),
634 },
635 )
636 .expect("cyclonedx xml write should succeed");
637 let xml = String::from_utf8(xml_bytes).expect("cyclonedx xml should be utf-8");
638 assert!(xml.contains("<timestamp>2026-01-01T00:00:01Z</timestamp>"));
639 }
640
641 #[test]
642 fn test_spdx_writers_emit_real_file_and_package_license_info() {
643 let output = Output::from(&sample_internal_output());
644
645 let mut tv_bytes = Vec::new();
646 writer_for_format(OutputFormat::SpdxTv)
647 .write(
648 &output,
649 &mut tv_bytes,
650 &OutputWriteConfig {
651 format: OutputFormat::SpdxTv,
652 custom_template: None,
653 scanned_path: Some("scan".to_string()),
654 },
655 )
656 .expect("spdx tv write should succeed");
657 let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
658 assert!(tv_rendered.contains("PackageLicenseConcluded: NOASSERTION"));
659 assert!(tv_rendered.contains("PackageLicenseInfoFromFiles: MIT"));
660 assert!(tv_rendered.contains("LicenseConcluded: NOASSERTION"));
661 assert!(tv_rendered.contains("LicenseInfoInFile: MIT"));
662 assert!(tv_rendered.contains("PackageCopyrightText: Copyright (c) Example"));
663
664 let mut rdf_bytes = Vec::new();
665 writer_for_format(OutputFormat::SpdxRdf)
666 .write(
667 &output,
668 &mut rdf_bytes,
669 &OutputWriteConfig {
670 format: OutputFormat::SpdxRdf,
671 custom_template: None,
672 scanned_path: Some("scan".to_string()),
673 },
674 )
675 .expect("spdx rdf write should succeed");
676 let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
677 assert!(rdf_rendered.contains(
678 "<spdx:licenseInfoFromFiles rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
679 ));
680 assert!(
681 rdf_rendered.contains(
682 "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
683 )
684 );
685 assert!(rdf_rendered.contains(
686 "<spdx:licenseConcluded rdf:resource=\"http://spdx.org/rdf/terms#noassertion\"/>"
687 ));
688 }
689
690 #[test]
691 fn test_spdx_writers_emit_license_ref_metadata_and_matched_text() {
692 let mut internal = sample_internal_output();
693 internal.files[0].license_detections = vec![LicenseDetection {
694 license_expression: "unknown-license-reference".to_string(),
695 license_expression_spdx: "LicenseRef-scancode-unknown-license-reference".to_string(),
696 matches: vec![Match {
697 license_expression: "unknown-license-reference".to_string(),
698 license_expression_spdx: "LicenseRef-scancode-unknown-license-reference"
699 .to_string(),
700 from_file: Some("src/main.rs".to_string()),
701 start_line: LineNumber::ONE,
702 end_line: LineNumber::new(2).unwrap(),
703 matcher: MatcherKind::Aho,
704 score: MatchScore::MAX,
705 matched_length: Some(4),
706 match_coverage: Some(100.0),
707 rule_relevance: Some(100),
708 rule_identifier: "unknown-license-reference.RULE".to_string(),
709 rule_url: Some("https://example.com/unknown-license-reference.LICENSE".to_string()),
710 matched_text: Some("Custom license text".to_string()),
711 referenced_filenames: Some(vec!["LICENSE".to_string()]),
712 matched_text_diagnostics: None,
713 }],
714 detection_log: vec![],
715 identifier: "unknown-ref-id".to_string(),
716 }];
717 internal.license_references = vec![crate::models::LicenseReference {
718 key: Some("unknown-license-reference".to_string()),
719 language: Some("en".to_string()),
720 name: "Unknown License Reference".to_string(),
721 short_name: "Unknown License Reference".to_string(),
722 owner: None,
723 homepage_url: None,
724 spdx_license_key: "LicenseRef-scancode-unknown-license-reference".to_string(),
725 other_spdx_license_keys: vec![],
726 osi_license_key: None,
727 text_urls: vec![],
728 osi_url: None,
729 faq_url: None,
730 other_urls: vec![],
731 category: None,
732 is_exception: false,
733 is_unknown: true,
734 is_generic: false,
735 notes: None,
736 minimum_coverage: None,
737 standard_notice: None,
738 ignorable_copyrights: vec![],
739 ignorable_holders: vec![],
740 ignorable_authors: vec![],
741 ignorable_urls: vec![],
742 ignorable_emails: vec![],
743 scancode_url: None,
744 licensedb_url: None,
745 spdx_url: None,
746 text: "Unused fallback text".to_string(),
747 }];
748 let output = Output::from(&internal);
749
750 let mut tv_bytes = Vec::new();
751 writer_for_format(OutputFormat::SpdxTv)
752 .write(
753 &output,
754 &mut tv_bytes,
755 &OutputWriteConfig {
756 format: OutputFormat::SpdxTv,
757 custom_template: None,
758 scanned_path: Some("scan".to_string()),
759 },
760 )
761 .expect("spdx tv write should succeed");
762 let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
763 assert!(
764 tv_rendered
765 .contains("LicenseInfoInFile: LicenseRef-scancode-unknown-license-reference")
766 );
767 assert!(tv_rendered.contains(
768 "PackageLicenseInfoFromFiles: LicenseRef-scancode-unknown-license-reference"
769 ));
770 assert!(tv_rendered.contains("LicenseID: LicenseRef-scancode-unknown-license-reference"));
771 assert!(tv_rendered.contains("ExtractedText: <text>Custom license text"));
772 assert!(tv_rendered.contains("LicenseName: Unknown License Reference"));
773 assert!(tv_rendered.contains(
774 "LicenseComment: <text>See details at https://example.com/unknown-license-reference.LICENSE"
775 ));
776
777 let mut rdf_bytes = Vec::new();
778 writer_for_format(OutputFormat::SpdxRdf)
779 .write(
780 &output,
781 &mut rdf_bytes,
782 &OutputWriteConfig {
783 format: OutputFormat::SpdxRdf,
784 custom_template: None,
785 scanned_path: Some("scan".to_string()),
786 },
787 )
788 .expect("spdx rdf write should succeed");
789 let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
790 assert!(rdf_rendered.contains(
791 "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/licenses/LicenseRef-scancode-unknown-license-reference\"/>"
792 ));
793 assert!(rdf_rendered.contains(
794 "<spdx:hasExtractedLicensingInfo><spdx:ExtractedLicensingInfo rdf:about=\"http://spdx.org/spdxdocs/scan#LicenseRef-scancode-unknown-license-reference\">"
795 ));
796 assert!(
797 rdf_rendered.contains("<spdx:extractedText>Custom license text</spdx:extractedText>")
798 );
799 }
800
801 #[test]
802 fn test_cyclonedx_json_writer_outputs_bom() {
803 let output = Output::from(&sample_internal_output());
804 let mut bytes = Vec::new();
805 writer_for_format(OutputFormat::CycloneDxJson)
806 .write(&output, &mut bytes, &OutputWriteConfig::default())
807 .expect("cyclonedx json write should succeed");
808
809 let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
810 let value: Value = serde_json::from_str(&rendered).expect("valid json");
811 assert_eq!(value["bomFormat"], "CycloneDX");
812 assert_eq!(value["specVersion"], "1.3");
813 }
814
815 #[test]
816 fn test_json_writer_includes_summary_and_key_file_flags() {
817 let mut internal = sample_internal_output();
818 internal.summary = Some(crate::models::Summary {
819 declared_license_expression: Some("apache-2.0".to_string()),
820 license_clarity_score: Some(crate::models::LicenseClarityScore {
821 score: 100,
822 declared_license: true,
823 identification_precision: true,
824 has_license_text: true,
825 declared_copyrights: true,
826 conflicting_license_categories: false,
827 ambiguous_compound_licensing: false,
828 }),
829 declared_holder: Some("Example Corp.".to_string()),
830 primary_language: Some("Ruby".to_string()),
831 other_license_expressions: vec![crate::models::TallyEntry {
832 value: Some("mit".to_string()),
833 count: 1,
834 }],
835 other_holders: vec![
836 crate::models::TallyEntry {
837 value: None,
838 count: 2,
839 },
840 crate::models::TallyEntry {
841 value: Some("Other Corp.".to_string()),
842 count: 1,
843 },
844 ],
845 other_languages: vec![crate::models::TallyEntry {
846 value: Some("Python".to_string()),
847 count: 2,
848 }],
849 });
850 internal.files[0].is_legal = true;
851 internal.files[0].is_top_level = true;
852 internal.files[0].is_key_file = true;
853 let output = Output::from(&internal);
854
855 let mut bytes = Vec::new();
856 writer_for_format(OutputFormat::Json)
857 .write(&output, &mut bytes, &OutputWriteConfig::default())
858 .expect("json write should succeed");
859
860 let rendered = String::from_utf8(bytes).expect("json should be utf-8");
861 let value: Value = serde_json::from_str(&rendered).expect("valid json");
862
863 assert_eq!(
864 value["summary"]["declared_license_expression"],
865 "apache-2.0"
866 );
867 assert_eq!(value["summary"]["license_clarity_score"]["score"], 100);
868 assert_eq!(value["summary"]["declared_holder"], "Example Corp.");
869 assert_eq!(value["summary"]["primary_language"], "Ruby");
870 assert_eq!(
871 value["summary"]["other_license_expressions"][0]["value"],
872 "mit"
873 );
874 assert!(value["summary"]["other_holders"][0]["value"].is_null());
875 assert_eq!(value["summary"]["other_holders"][1]["value"], "Other Corp.");
876 assert_eq!(value["summary"]["other_languages"][0]["value"], "Python");
877 assert_eq!(value["files"][0]["is_key_file"], true);
878 }
879
880 #[test]
881 fn test_json_and_json_lines_writers_include_top_level_tallies() {
882 let mut internal = sample_internal_output();
883 internal.tallies = Some(crate::models::Tallies {
884 detected_license_expression: vec![crate::models::TallyEntry {
885 value: Some("mit".to_string()),
886 count: 2,
887 }],
888 copyrights: vec![crate::models::TallyEntry {
889 value: Some("Copyright (c) Example Org".to_string()),
890 count: 1,
891 }],
892 holders: vec![crate::models::TallyEntry {
893 value: Some("Example Org".to_string()),
894 count: 1,
895 }],
896 authors: vec![crate::models::TallyEntry {
897 value: Some("Jane Doe".to_string()),
898 count: 1,
899 }],
900 programming_language: vec![crate::models::TallyEntry {
901 value: Some("Rust".to_string()),
902 count: 1,
903 }],
904 });
905 let output = Output::from(&internal);
906
907 let mut json_bytes = Vec::new();
908 writer_for_format(OutputFormat::Json)
909 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
910 .expect("json write should succeed");
911 let json_value: Value =
912 serde_json::from_slice(&json_bytes).expect("json output should parse");
913 assert_eq!(
914 json_value["tallies"]["detected_license_expression"][0]["value"],
915 "mit"
916 );
917 assert_eq!(
918 json_value["tallies"]["programming_language"][0]["value"],
919 "Rust"
920 );
921
922 let mut jsonl_bytes = Vec::new();
923 writer_for_format(OutputFormat::JsonLines)
924 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
925 .expect("json-lines write should succeed");
926 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
927 assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
928 }
929
930 #[test]
931 fn test_json_and_json_lines_writers_include_key_file_tallies() {
932 let mut internal = sample_internal_output();
933 internal.tallies_of_key_files = Some(crate::models::Tallies {
934 detected_license_expression: vec![crate::models::TallyEntry {
935 value: Some("apache-2.0".to_string()),
936 count: 1,
937 }],
938 copyrights: vec![],
939 holders: vec![],
940 authors: vec![],
941 programming_language: vec![crate::models::TallyEntry {
942 value: Some("Markdown".to_string()),
943 count: 1,
944 }],
945 });
946 let output = Output::from(&internal);
947
948 let mut json_bytes = Vec::new();
949 writer_for_format(OutputFormat::Json)
950 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
951 .expect("json write should succeed");
952 let json_value: Value =
953 serde_json::from_slice(&json_bytes).expect("json output should parse");
954 assert_eq!(
955 json_value["tallies_of_key_files"]["detected_license_expression"][0]["value"],
956 "apache-2.0"
957 );
958
959 let mut jsonl_bytes = Vec::new();
960 writer_for_format(OutputFormat::JsonLines)
961 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
962 .expect("json-lines write should succeed");
963 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
964 assert!(
965 rendered
966 .lines()
967 .any(|line| line.contains("\"tallies_of_key_files\""))
968 );
969 }
970
971 #[test]
972 fn test_json_and_json_lines_writers_include_file_tallies() {
973 let mut internal = sample_internal_output();
974 internal.files[0].tallies = Some(crate::models::Tallies {
975 detected_license_expression: vec![crate::models::TallyEntry {
976 value: Some("mit".to_string()),
977 count: 1,
978 }],
979 copyrights: vec![crate::models::TallyEntry {
980 value: None,
981 count: 1,
982 }],
983 holders: vec![],
984 authors: vec![],
985 programming_language: vec![crate::models::TallyEntry {
986 value: Some("Rust".to_string()),
987 count: 1,
988 }],
989 });
990 let output = Output::from(&internal);
991
992 let mut json_bytes = Vec::new();
993 writer_for_format(OutputFormat::Json)
994 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
995 .expect("json write should succeed");
996 let json_value: Value =
997 serde_json::from_slice(&json_bytes).expect("json output should parse");
998 assert_eq!(
999 json_value["files"][0]["tallies"]["detected_license_expression"][0]["value"],
1000 "mit"
1001 );
1002
1003 let mut jsonl_bytes = Vec::new();
1004 writer_for_format(OutputFormat::JsonLines)
1005 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1006 .expect("json-lines write should succeed");
1007 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1008 assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
1009 }
1010
1011 #[test]
1012 fn test_json_and_json_lines_writers_include_facets_and_tallies_by_facet() {
1013 let mut internal = sample_internal_output();
1014 internal.files[0].facets = vec!["core".to_string(), "docs".to_string()];
1015 internal.tallies_by_facet = Some(vec![crate::models::FacetTallies {
1016 facet: "core".to_string(),
1017 tallies: crate::models::Tallies {
1018 detected_license_expression: vec![crate::models::TallyEntry {
1019 value: Some("mit".to_string()),
1020 count: 1,
1021 }],
1022 copyrights: vec![],
1023 holders: vec![],
1024 authors: vec![],
1025 programming_language: vec![],
1026 },
1027 }]);
1028 let output = Output::from(&internal);
1029
1030 let mut json_bytes = Vec::new();
1031 writer_for_format(OutputFormat::Json)
1032 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1033 .expect("json write should succeed");
1034 let json_value: Value =
1035 serde_json::from_slice(&json_bytes).expect("json output should parse");
1036 assert_eq!(json_value["files"][0]["facets"][0], "core");
1037 assert_eq!(json_value["tallies_by_facet"][0]["facet"], "core");
1038
1039 let mut jsonl_bytes = Vec::new();
1040 writer_for_format(OutputFormat::JsonLines)
1041 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1042 .expect("json-lines write should succeed");
1043 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1044 assert!(
1045 rendered
1046 .lines()
1047 .any(|line| line.contains("\"tallies_by_facet\""))
1048 );
1049 }
1050
1051 #[test]
1052 fn test_json_and_json_lines_writers_include_top_level_license_references() {
1053 let mut internal = sample_internal_output();
1054 internal.license_references = vec![crate::models::LicenseReference {
1055 key: Some("mit".to_string()),
1056 language: Some("en".to_string()),
1057 name: "MIT License".to_string(),
1058 short_name: "MIT".to_string(),
1059 owner: Some("Example Owner".to_string()),
1060 homepage_url: Some("https://example.com/license".to_string()),
1061 spdx_license_key: "MIT".to_string(),
1062 other_spdx_license_keys: vec![],
1063 osi_license_key: Some("MIT".to_string()),
1064 text_urls: vec!["https://example.com/license.txt".to_string()],
1065 osi_url: Some("https://opensource.org/licenses/MIT".to_string()),
1066 faq_url: None,
1067 other_urls: vec![],
1068 category: None,
1069 is_exception: false,
1070 is_unknown: false,
1071 is_generic: false,
1072 notes: None,
1073 minimum_coverage: None,
1074 standard_notice: None,
1075 ignorable_copyrights: vec![],
1076 ignorable_holders: vec![],
1077 ignorable_authors: vec![],
1078 ignorable_urls: vec![],
1079 ignorable_emails: vec![],
1080 scancode_url: None,
1081 licensedb_url: None,
1082 spdx_url: None,
1083 text: "MIT text".to_string(),
1084 }];
1085 internal.license_rule_references = vec![crate::models::LicenseRuleReference {
1086 identifier: "license-clue_1.RULE".to_string(),
1087 license_expression: "unknown-license-reference".to_string(),
1088 is_license_text: false,
1089 is_license_notice: false,
1090 is_license_reference: false,
1091 is_license_tag: false,
1092 is_license_clue: true,
1093 is_license_intro: false,
1094 language: None,
1095 rule_url: None,
1096 is_required_phrase: false,
1097 skip_for_required_phrase_generation: false,
1098 replaced_by: vec![],
1099 is_continuous: false,
1100 is_synthetic: false,
1101 is_from_license: false,
1102 length: 0,
1103 relevance: None,
1104 minimum_coverage: None,
1105 referenced_filenames: vec![],
1106 notes: None,
1107 ignorable_copyrights: vec![],
1108 ignorable_holders: vec![],
1109 ignorable_authors: vec![],
1110 ignorable_urls: vec![],
1111 ignorable_emails: vec![],
1112 text: None,
1113 }];
1114 let output = Output::from(&internal);
1115
1116 let mut json_bytes = Vec::new();
1117 writer_for_format(OutputFormat::Json)
1118 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1119 .expect("json write should succeed");
1120 let json_value: Value =
1121 serde_json::from_slice(&json_bytes).expect("json output should parse");
1122 assert_eq!(
1123 json_value["license_references"][0]["spdx_license_key"],
1124 "MIT"
1125 );
1126 assert_eq!(json_value["license_references"][0]["key"], "mit");
1127 assert_eq!(json_value["license_references"][0]["language"], "en");
1128 assert_eq!(
1129 json_value["license_references"][0]["owner"],
1130 "Example Owner"
1131 );
1132 assert_eq!(
1133 json_value["license_references"][0]["homepage_url"],
1134 "https://example.com/license"
1135 );
1136 assert_eq!(
1137 json_value["license_references"][0]["osi_license_key"],
1138 "MIT"
1139 );
1140 assert_eq!(
1141 json_value["license_references"][0]["text_urls"][0],
1142 "https://example.com/license.txt"
1143 );
1144 assert_eq!(
1145 json_value["license_rule_references"][0]["identifier"],
1146 "license-clue_1.RULE"
1147 );
1148 assert_eq!(
1149 json_value["license_rule_references"][0]["relevance"],
1150 Value::Null
1151 );
1152 assert_eq!(
1153 json_value["license_rule_references"][0]["length"],
1154 Value::from(0)
1155 );
1156
1157 let mut jsonl_bytes = Vec::new();
1158 writer_for_format(OutputFormat::JsonLines)
1159 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1160 .expect("json-lines write should succeed");
1161 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1162 assert!(
1163 rendered
1164 .lines()
1165 .any(|line| line.contains("\"license_references\""))
1166 );
1167 assert!(
1168 rendered
1169 .lines()
1170 .any(|line| line.contains("\"license_rule_references\""))
1171 );
1172 }
1173
1174 #[test]
1175 fn test_json_and_json_lines_writers_include_top_level_license_detections() {
1176 let mut internal = sample_internal_output();
1177 internal.license_detections = vec![crate::models::TopLevelLicenseDetection {
1178 identifier: "mit-id".to_string(),
1179 license_expression: "mit".to_string(),
1180 license_expression_spdx: "MIT".to_string(),
1181 detection_count: 2,
1182 detection_log: vec![],
1183 reference_matches: vec![crate::models::Match {
1184 license_expression: "mit".to_string(),
1185 license_expression_spdx: "MIT".to_string(),
1186 from_file: Some("src/main.rs".to_string()),
1187 start_line: LineNumber::ONE,
1188 end_line: LineNumber::new(3).unwrap(),
1189 matcher: MatcherKind::Hash,
1190 score: MatchScore::MAX,
1191 matched_length: Some(10),
1192 match_coverage: Some(100.0),
1193 rule_relevance: Some(100),
1194 rule_identifier: "mit.LICENSE".to_string(),
1195 rule_url: None,
1196 matched_text: None,
1197 referenced_filenames: None,
1198 matched_text_diagnostics: None,
1199 }],
1200 }];
1201 let output = Output::from(&internal);
1202
1203 let mut json_bytes = Vec::new();
1204 writer_for_format(OutputFormat::Json)
1205 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1206 .expect("json write should succeed");
1207 let json_value: Value =
1208 serde_json::from_slice(&json_bytes).expect("json output should parse");
1209 assert_eq!(json_value["license_detections"][0]["identifier"], "mit-id");
1210 assert_eq!(json_value["license_detections"][0]["detection_count"], 2);
1211
1212 let mut jsonl_bytes = Vec::new();
1213 writer_for_format(OutputFormat::JsonLines)
1214 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1215 .expect("json-lines write should succeed");
1216 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1217 assert!(
1218 rendered
1219 .lines()
1220 .any(|line| line.contains("\"license_detections\""))
1221 );
1222 }
1223
1224 #[test]
1225 fn test_json_and_json_lines_writers_keep_empty_top_level_license_detections() {
1226 let output = Output::from(&sample_internal_output());
1227
1228 let mut json_bytes = Vec::new();
1229 writer_for_format(OutputFormat::Json)
1230 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1231 .expect("json write should succeed");
1232 let json_value: Value =
1233 serde_json::from_slice(&json_bytes).expect("json output should parse");
1234 assert_eq!(json_value["license_detections"], Value::Array(vec![]));
1235
1236 let mut jsonl_bytes = Vec::new();
1237 writer_for_format(OutputFormat::JsonLines)
1238 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1239 .expect("json-lines write should succeed");
1240 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1241 assert!(
1242 rendered
1243 .lines()
1244 .any(|line| line == r#"{"license_detections":[]}"#)
1245 );
1246 }
1247
1248 #[test]
1249 fn test_public_writer_normalizes_empty_package_maps_without_changing_schema_output() {
1250 let mut internal = sample_internal_output();
1251 internal.packages.push(Package::from_package_data(
1252 &PackageData {
1253 package_type: Some(crate::models::PackageType::Npm),
1254 name: Some("demo".to_string()),
1255 version: Some("1.0.0".to_string()),
1256 ..PackageData::default()
1257 },
1258 "scan/package.json".to_string(),
1259 ));
1260
1261 let output = Output::from(&internal);
1262 let raw_schema = serde_json::to_value(&output).expect("schema output should serialize");
1263 assert_eq!(
1264 raw_schema["packages"][0]["qualifiers"],
1265 serde_json::json!({})
1266 );
1267 assert_eq!(
1268 raw_schema["packages"][0]["extra_data"],
1269 serde_json::json!({})
1270 );
1271
1272 let mut bytes = Vec::new();
1273 writer_for_format(OutputFormat::Json)
1274 .write(&output, &mut bytes, &OutputWriteConfig::default())
1275 .expect("json write should succeed");
1276 let public_value: Value = serde_json::from_slice(&bytes).expect("public json should parse");
1277
1278 assert!(public_value["packages"][0]["qualifiers"].is_null());
1279 assert!(public_value["packages"][0]["extra_data"].is_null());
1280 }
1281
1282 #[test]
1283 fn test_cyclonedx_xml_writer_outputs_xml() {
1284 let output = Output::from(&sample_internal_output());
1285 let mut bytes = Vec::new();
1286 writer_for_format(OutputFormat::CycloneDxXml)
1287 .write(&output, &mut bytes, &OutputWriteConfig::default())
1288 .expect("cyclonedx xml write should succeed");
1289
1290 let rendered = String::from_utf8(bytes).expect("cyclonedx xml should be utf-8");
1291 assert!(rendered.contains("<bom xmlns=\"http://cyclonedx.org/schema/bom/1.3\""));
1292 assert!(rendered.contains("<components>"));
1293 }
1294
1295 #[test]
1296 fn test_cyclonedx_json_includes_component_license_expression() {
1297 let mut internal = sample_internal_output();
1298 internal.packages = vec![crate::models::Package {
1299 package_type: Some(crate::models::PackageType::Maven),
1300 namespace: Some("example".to_string()),
1301 name: Some("gradle-project".to_string()),
1302 version: Some("1.0.0".to_string()),
1303 qualifiers: None,
1304 subpath: None,
1305 primary_language: Some("Java".to_string()),
1306 description: None,
1307 release_date: None,
1308 parties: vec![],
1309 keywords: vec![],
1310 homepage_url: None,
1311 download_url: None,
1312 size: None,
1313 sha1: None,
1314 md5: None,
1315 sha256: None,
1316 sha512: None,
1317 bug_tracking_url: None,
1318 code_view_url: None,
1319 vcs_url: None,
1320 copyright: None,
1321 holder: None,
1322 declared_license_expression: Some("Apache-2.0".to_string()),
1323 declared_license_expression_spdx: Some("Apache-2.0".to_string()),
1324 license_detections: vec![],
1325 other_license_expression: None,
1326 other_license_expression_spdx: None,
1327 other_license_detections: vec![],
1328 extracted_license_statement: Some("Apache-2.0".to_string()),
1329 notice_text: None,
1330 source_packages: vec![],
1331 is_private: false,
1332 is_virtual: false,
1333 extra_data: None,
1334 repository_homepage_url: None,
1335 repository_download_url: None,
1336 api_data_url: None,
1337 datasource_ids: vec![],
1338 purl: Some("pkg:maven/example/gradle-project@1.0.0".to_string()),
1339 package_uid: PackageUid::from_raw(
1340 "pkg:maven/example/gradle-project@1.0.0?uuid=test".to_string(),
1341 ),
1342 datafile_paths: vec![],
1343 }];
1344 let output = Output::from(&internal);
1345
1346 let mut bytes = Vec::new();
1347 writer_for_format(OutputFormat::CycloneDxJson)
1348 .write(&output, &mut bytes, &OutputWriteConfig::default())
1349 .expect("cyclonedx json write should succeed");
1350
1351 let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
1352 let value: Value = serde_json::from_str(&rendered).expect("valid json");
1353
1354 assert_eq!(
1355 value["components"][0]["licenses"][0]["expression"],
1356 "Apache-2.0"
1357 );
1358 }
1359
1360 #[test]
1361 fn test_cyclonedx_external_references_are_deduplicated() {
1362 let mut internal = sample_internal_output();
1363 internal.packages = vec![Package::from_package_data(
1364 &PackageData {
1365 package_type: Some(crate::models::PackageType::Npm),
1366 name: Some("demo".to_string()),
1367 version: Some("1.0.0".to_string()),
1368 download_url: Some("https://example.com/download.tgz".to_string()),
1369 repository_download_url: Some("https://example.com/download.tgz".to_string()),
1370 homepage_url: Some("https://example.com".to_string()),
1371 repository_homepage_url: Some("https://example.com".to_string()),
1372 ..PackageData::default()
1373 },
1374 "scan/package.json".to_string(),
1375 )];
1376 let output = Output::from(&internal);
1377
1378 let mut json_bytes = Vec::new();
1379 writer_for_format(OutputFormat::CycloneDxJson)
1380 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1381 .expect("cyclonedx json write should succeed");
1382 let value: Value = serde_json::from_slice(&json_bytes).expect("valid cyclonedx json");
1383 let refs = value["components"][0]["externalReferences"]
1384 .as_array()
1385 .expect("external references should be an array");
1386 assert_eq!(refs.len(), 2);
1387
1388 let mut xml_bytes = Vec::new();
1389 writer_for_format(OutputFormat::CycloneDxXml)
1390 .write(&output, &mut xml_bytes, &OutputWriteConfig::default())
1391 .expect("cyclonedx xml write should succeed");
1392 let xml = String::from_utf8(xml_bytes).expect("cyclonedx xml should be utf-8");
1393 assert_eq!(xml.matches("https://example.com/download.tgz").count(), 1);
1394 assert_eq!(xml.matches("https://example.com</url>").count(), 1);
1395 }
1396
1397 #[test]
1398 fn test_spdx_prefers_single_detected_package_name_over_scan_root() {
1399 let mut internal = sample_internal_output();
1400 internal.packages = vec![Package::from_package_data(
1401 &PackageData {
1402 package_type: Some(crate::models::PackageType::Npm),
1403 name: Some("detected-package".to_string()),
1404 version: Some("1.0.0".to_string()),
1405 ..PackageData::default()
1406 },
1407 "scan/package.json".to_string(),
1408 )];
1409 let output = Output::from(&internal);
1410
1411 let mut tv_bytes = Vec::new();
1412 writer_for_format(OutputFormat::SpdxTv)
1413 .write(
1414 &output,
1415 &mut tv_bytes,
1416 &OutputWriteConfig {
1417 format: OutputFormat::SpdxTv,
1418 custom_template: None,
1419 scanned_path: Some("scan-root".to_string()),
1420 },
1421 )
1422 .expect("spdx tv write should succeed");
1423 let tv = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
1424 assert!(tv.contains("PackageName: detected-package"));
1425 assert!(tv.contains("DocumentNamespace: http://spdx.org/spdxdocs/detected-package"));
1426
1427 let mut rdf_bytes = Vec::new();
1428 writer_for_format(OutputFormat::SpdxRdf)
1429 .write(
1430 &output,
1431 &mut rdf_bytes,
1432 &OutputWriteConfig {
1433 format: OutputFormat::SpdxRdf,
1434 custom_template: None,
1435 scanned_path: Some("scan-root".to_string()),
1436 },
1437 )
1438 .expect("spdx rdf write should succeed");
1439 let rdf = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
1440 assert!(rdf.contains("<spdx:name>detected-package</spdx:name>"));
1441 }
1442
1443 #[test]
1444 fn test_spdx_empty_scan_tag_value_matches_python_sentinel() {
1445 let output = Output {
1446 summary: None,
1447 tallies: None,
1448 tallies_of_key_files: None,
1449 tallies_by_facet: None,
1450 headers: vec![],
1451 packages: vec![],
1452 dependencies: vec![],
1453 license_detections: vec![],
1454 files: vec![],
1455 license_references: vec![],
1456 license_rule_references: vec![],
1457 };
1458 let mut bytes = Vec::new();
1459 writer_for_format(OutputFormat::SpdxTv)
1460 .write(
1461 &output,
1462 &mut bytes,
1463 &OutputWriteConfig {
1464 format: OutputFormat::SpdxTv,
1465 custom_template: None,
1466 scanned_path: Some("scan".to_string()),
1467 },
1468 )
1469 .expect("spdx tv write should succeed");
1470
1471 let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
1472 assert_eq!(rendered, "# No results for package 'scan'.\n");
1473 }
1474
1475 #[test]
1476 fn test_spdx_empty_scan_rdf_matches_python_sentinel() {
1477 let output = Output {
1478 summary: None,
1479 tallies: None,
1480 tallies_of_key_files: None,
1481 tallies_by_facet: None,
1482 headers: vec![],
1483 packages: vec![],
1484 dependencies: vec![],
1485 license_detections: vec![],
1486 files: vec![],
1487 license_references: vec![],
1488 license_rule_references: vec![],
1489 };
1490 let mut bytes = Vec::new();
1491 writer_for_format(OutputFormat::SpdxRdf)
1492 .write(
1493 &output,
1494 &mut bytes,
1495 &OutputWriteConfig {
1496 format: OutputFormat::SpdxRdf,
1497 custom_template: None,
1498 scanned_path: Some("scan".to_string()),
1499 },
1500 )
1501 .expect("spdx rdf write should succeed");
1502
1503 let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
1504 assert_eq!(rendered, "<!-- No results for package 'scan'. -->\n");
1505 }
1506
1507 #[test]
1508 fn test_html_writer_outputs_html_document() {
1509 let output = Output::from(&sample_internal_output());
1510 let mut bytes = Vec::new();
1511 writer_for_format(OutputFormat::Html)
1512 .write(&output, &mut bytes, &OutputWriteConfig::default())
1513 .expect("html write should succeed");
1514 let rendered = String::from_utf8(bytes).expect("html should be utf-8");
1515 assert!(rendered.contains("<!doctype html>"));
1516 assert!(rendered.contains("Provenant HTML Report"));
1517 }
1518
1519 #[test]
1520 fn test_custom_template_writer_renders_output_context() {
1521 let output = Output::from(&sample_internal_output());
1522 let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1523 let template_path = temp_dir.path().join("template.tera");
1524 fs::write(
1525 &template_path,
1526 "version={{ output.headers[0].output_format_version }} files={{ files | length }}",
1527 )
1528 .expect("template should be written");
1529
1530 let mut bytes = Vec::new();
1531 writer_for_format(OutputFormat::CustomTemplate)
1532 .write(
1533 &output,
1534 &mut bytes,
1535 &OutputWriteConfig {
1536 format: OutputFormat::CustomTemplate,
1537 custom_template: Some(template_path.to_string_lossy().to_string()),
1538 scanned_path: None,
1539 },
1540 )
1541 .expect("custom template write should succeed");
1542
1543 let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1544 assert!(rendered.contains("version=4.1.0"));
1545 assert!(rendered.contains("files=1"));
1546 }
1547
1548 #[test]
1549 fn test_custom_template_writer_exposes_scancode_namespace() {
1550 let output = Output::from(&sample_internal_output());
1551 let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1552 let template_path = temp_dir.path().join("template.j2");
1553 fs::write(
1556 &template_path,
1557 concat!(
1558 r#"lc={{ scancode.files.license_copyright["src/main.rs"] | length }} "#,
1559 r#"first={{ scancode.files.license_copyright["src/main.rs"][0].what }} "#,
1560 r#"name={{ scancode.files.infos["src/main.rs"].name }} "#,
1561 r#"pkg={{ scancode.files.package_data["src/main.rs"] | length }} "#,
1562 r#"ver={{ scancode.version }}"#,
1563 ),
1564 )
1565 .expect("template should be written");
1566
1567 let mut bytes = Vec::new();
1568 writer_for_format(OutputFormat::CustomTemplate)
1569 .write(
1570 &output,
1571 &mut bytes,
1572 &OutputWriteConfig {
1573 format: OutputFormat::CustomTemplate,
1574 custom_template: Some(template_path.to_string_lossy().to_string()),
1575 scanned_path: None,
1576 },
1577 )
1578 .expect("custom template write should succeed");
1579
1580 let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1581 assert!(rendered.contains("lc=2"), "rendered: {rendered}");
1583 assert!(rendered.contains("first=copyright"), "rendered: {rendered}");
1584 assert!(rendered.contains("name=main"), "rendered: {rendered}");
1585 assert!(rendered.contains("pkg=1"), "rendered: {rendered}");
1586 assert!(
1587 rendered.contains(&format!("ver={}", crate::version::BUILD_VERSION)),
1588 "rendered: {rendered}"
1589 );
1590 }
1591
1592 fn sample_internal_output() -> crate::models::Output {
1593 crate::models::Output {
1594 summary: None,
1595 tallies: None,
1596 tallies_of_key_files: None,
1597 tallies_by_facet: None,
1598 headers: vec![Header {
1599 tool_name: "provenant".to_string(),
1600 tool_version: crate::version::BUILD_VERSION.to_string(),
1601 options: serde_json::Map::new(),
1602 notice: crate::models::HEADER_NOTICE.to_string(),
1603 start_timestamp: "2026-01-01T000000.000000".to_string(),
1604 end_timestamp: "2026-01-01T000001.000000".to_string(),
1605 output_format_version: "4.1.0".to_string(),
1606 duration: 1.0,
1607 errors: vec![],
1608 warnings: vec![],
1609 extra_data: ExtraData {
1610 system_environment: SystemEnvironment {
1611 operating_system: "darwin".to_string(),
1612 cpu_architecture: "aarch64".to_string(),
1613 platform: "darwin".to_string(),
1614 platform_version: "26.3.1".to_string(),
1615 rust_version: "1.93.0".to_string(),
1616 },
1617 spdx_license_list_version: "3.27".to_string(),
1618 files_count: 1,
1619 directories_count: 1,
1620 excluded_count: 0,
1621 license_index_provenance: Some(crate::models::LicenseIndexProvenance {
1622 source: "embedded-artifact".to_string(),
1623 dataset_fingerprint: "test-fingerprint".to_string(),
1624 ignored_rules: vec![
1625 "gpl-2.0_and-unknown-license-reference_1.RULE".to_string(),
1626 ],
1627 ignored_licenses: vec![],
1628 ignored_rules_due_to_licenses: vec![],
1629 added_rules: vec![],
1630 replaced_rules: vec![],
1631 added_licenses: vec![],
1632 replaced_licenses: vec![],
1633 }),
1634 },
1635 }],
1636 packages: vec![],
1637 dependencies: vec![],
1638 license_detections: vec![],
1639 files: vec![FileInfo::new(
1640 "main.rs".to_string(),
1641 "main".to_string(),
1642 "rs".to_string(),
1643 "src/main.rs".to_string(),
1644 FileType::File,
1645 Some("text/plain".to_string()),
1646 None,
1647 42,
1648 None,
1649 Some(Sha1Digest::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").unwrap()),
1650 Some(Md5Digest::from_hex("d41d8cd98f00b204e9800998ecf8427e").unwrap()),
1651 Some(
1652 Sha256Digest::from_hex(
1653 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1654 )
1655 .unwrap(),
1656 ),
1657 Some("Rust".to_string()),
1658 vec![PackageData::default()],
1659 None,
1660 vec![LicenseDetection {
1661 license_expression: "mit".to_string(),
1662 license_expression_spdx: "MIT".to_string(),
1663 matches: vec![Match {
1664 license_expression: "mit".to_string(),
1665 license_expression_spdx: "MIT".to_string(),
1666 from_file: None,
1667 start_line: LineNumber::ONE,
1668 end_line: LineNumber::ONE,
1669 matcher: MatcherKind::Hash,
1670 score: MatchScore::MAX,
1671 matched_length: None,
1672 match_coverage: None,
1673 rule_relevance: None,
1674 rule_identifier: "mit_rule".to_string(),
1675 rule_url: None,
1676 matched_text: None,
1677 referenced_filenames: None,
1678 matched_text_diagnostics: None,
1679 }],
1680 detection_log: vec![],
1681 identifier: String::new(),
1682 }],
1683 vec![],
1684 vec![Copyright {
1685 copyright: "Copyright (c) Example".to_string(),
1686 normalized_copyright: None,
1687 start_line: LineNumber::ONE,
1688 end_line: LineNumber::ONE,
1689 }],
1690 vec![Holder {
1691 holder: "Example Org".to_string(),
1692 start_line: LineNumber::ONE,
1693 end_line: LineNumber::ONE,
1694 }],
1695 vec![Author {
1696 author: "Jane Doe".to_string(),
1697 start_line: LineNumber::ONE,
1698 end_line: LineNumber::ONE,
1699 }],
1700 vec![OutputEmail {
1701 email: "jane@example.com".to_string(),
1702 start_line: LineNumber::ONE,
1703 end_line: LineNumber::ONE,
1704 }],
1705 vec![OutputURL {
1706 url: "https://example.com".to_string(),
1707 start_line: LineNumber::ONE,
1708 end_line: LineNumber::ONE,
1709 }],
1710 vec![],
1711 vec![],
1712 )],
1713 license_references: vec![],
1714 license_rule_references: vec![],
1715 }
1716 }
1717}