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