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