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