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