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