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