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