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