1use std::fs::File;
2use std::io::{self, BufWriter, Write};
3
4use crate::models::Output;
5
6mod cyclonedx;
7mod debian;
8mod html;
9mod jsonl;
10mod shared;
11mod spdx;
12mod template;
13
14pub(crate) const EMPTY_SHA1: &str = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
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, output).map_err(shared::io_other)?;
68 writer.write_all(b"\n")
69 }
70 OutputFormat::JsonPretty => {
71 serde_json::to_writer_pretty(&mut *writer, output).map_err(shared::io_other)?;
72 writer.write_all(b"\n")
73 }
74 OutputFormat::Yaml => write_yaml(output, writer),
75 OutputFormat::JsonLines => jsonl::write_json_lines(output, writer),
76 OutputFormat::Debian => debian::write_debian_copyright(output, writer),
77 OutputFormat::Html => html::write_html_report(output, writer),
78 OutputFormat::CustomTemplate => template::write_custom_template(output, writer, config),
79 OutputFormat::SpdxTv => spdx::write_spdx_tag_value(output, writer, config),
80 OutputFormat::SpdxRdf => spdx::write_spdx_rdf_xml(output, writer, config),
81 OutputFormat::CycloneDxJson => cyclonedx::write_cyclonedx_json(output, writer),
82 OutputFormat::CycloneDxXml => cyclonedx::write_cyclonedx_xml(output, writer),
83 }
84 }
85}
86
87pub fn write_output_file(
88 output_file: &str,
89 output: &Output,
90 config: &OutputWriteConfig,
91) -> io::Result<()> {
92 if output_file == "-" {
93 let stdout = io::stdout();
94 let handle = stdout.lock();
95 let mut writer = BufWriter::with_capacity(OUTPUT_BUFFER_SIZE, handle);
96 writer_for_format(config.format).write(output, &mut writer, config)?;
97 return writer.flush();
98 }
99
100 let file = File::create(output_file)?;
101 let mut writer = BufWriter::with_capacity(OUTPUT_BUFFER_SIZE, file);
102 writer_for_format(config.format).write(output, &mut writer, config)?;
103 writer.flush()
104}
105
106fn write_yaml(output: &Output, writer: &mut dyn Write) -> io::Result<()> {
107 yaml_serde::to_writer(&mut *writer, output).map_err(shared::io_other)?;
108 writer.write_all(b"\n")
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use serde_json::Value;
115 use std::fs;
116
117 use crate::models::{
118 Author, Copyright, ExtraData, FileInfo, FileType, Header, Holder, LicenseDetection, Match,
119 OutputEmail, OutputURL, PackageData, SystemEnvironment,
120 };
121
122 #[test]
123 fn test_yaml_writer_outputs_yaml() {
124 let output = sample_output();
125 let mut bytes = Vec::new();
126 writer_for_format(OutputFormat::Yaml)
127 .write(&output, &mut bytes, &OutputWriteConfig::default())
128 .expect("yaml write should succeed");
129 let rendered = String::from_utf8(bytes).expect("yaml should be utf-8");
130 assert!(rendered.contains("headers:"));
131 assert!(rendered.contains("files:"));
132 }
133
134 #[test]
135 fn test_json_lines_writer_outputs_parseable_lines() {
136 let output = sample_output();
137 let mut bytes = Vec::new();
138 writer_for_format(OutputFormat::JsonLines)
139 .write(&output, &mut bytes, &OutputWriteConfig::default())
140 .expect("json-lines write should succeed");
141
142 let rendered = String::from_utf8(bytes).expect("json-lines should be utf-8");
143 let lines = rendered.lines().collect::<Vec<_>>();
144 assert!(lines.len() >= 2);
145 for line in lines {
146 serde_json::from_str::<Value>(line).expect("each line should be valid json");
147 }
148 }
149
150 #[test]
151 fn test_debian_writer_outputs_dep5_style_document() {
152 let mut output = sample_output();
153 output.files[0].license_expression = Some("mit".to_string());
154 output.files[0].license_detections[0].matches[0].matched_text = Some(
155 "Permission is hereby granted, free of charge, to any person obtaining a copy"
156 .to_string(),
157 );
158
159 let mut bytes = Vec::new();
160 writer_for_format(OutputFormat::Debian)
161 .write(&output, &mut bytes, &OutputWriteConfig::default())
162 .expect("debian write should succeed");
163
164 let rendered = String::from_utf8(bytes).expect("debian output should be utf-8");
165 assert!(rendered.contains(
166 "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/"
167 ));
168 assert!(rendered.contains("Comment: Generated with Provenant"));
169 assert!(rendered.contains("Files: src/main.rs"));
170 assert!(rendered.contains("Copyright: Example Org"));
171 assert!(rendered.contains("License: mit"));
172 assert!(rendered.contains(" Permission is hereby granted, free of charge"));
173 }
174
175 #[test]
176 fn test_debian_writer_skips_directories_and_deduplicates_license_texts() {
177 let mut output = sample_output();
178 output.files.insert(
179 0,
180 FileInfo::new(
181 "src".to_string(),
182 "src".to_string(),
183 String::new(),
184 "src".to_string(),
185 FileType::Directory,
186 None,
187 None,
188 0,
189 None,
190 None,
191 None,
192 None,
193 None,
194 vec![],
195 None,
196 vec![],
197 vec![],
198 vec![],
199 vec![],
200 vec![],
201 vec![],
202 vec![],
203 vec![],
204 vec![],
205 ),
206 );
207 output.files[1].license_expression = Some("mit".to_string());
208 output.files[1].license_detections[0].matches[0].matched_text =
209 Some("Same text".to_string());
210 output.files[1].license_detections[0].matches.push(Match {
211 license_expression: "mit".to_string(),
212 license_expression_spdx: "MIT".to_string(),
213 from_file: Some("src/main.rs".to_string()),
214 start_line: 1,
215 end_line: 1,
216 matcher: Some("2-aho".to_string()),
217 score: 100.0,
218 matched_length: Some(1),
219 match_coverage: Some(100.0),
220 rule_relevance: Some(100),
221 rule_identifier: Some("mit_rule".to_string()),
222 rule_url: None,
223 matched_text: Some("Same text again".to_string()),
224 referenced_filenames: None,
225 matched_text_diagnostics: None,
226 });
227
228 let mut bytes = Vec::new();
229 writer_for_format(OutputFormat::Debian)
230 .write(&output, &mut bytes, &OutputWriteConfig::default())
231 .expect("debian write should succeed");
232
233 let rendered = String::from_utf8(bytes).expect("debian output should be utf-8");
234 assert!(!rendered.contains("Files: src\n"));
235 assert_eq!(rendered.matches(" Same text").count(), 1);
236 }
237
238 #[test]
239 fn test_file_info_serialization_omits_info_fields_when_unset() {
240 let file = FileInfo::new(
241 "main.rs".to_string(),
242 "main".to_string(),
243 "rs".to_string(),
244 "src/main.rs".to_string(),
245 FileType::File,
246 None,
247 None,
248 42,
249 None,
250 None,
251 None,
252 None,
253 None,
254 vec![],
255 None,
256 vec![],
257 vec![],
258 vec![],
259 vec![],
260 vec![],
261 vec![],
262 vec![],
263 vec![],
264 vec![],
265 );
266
267 let value = serde_json::to_value(&file).expect("file info serializes");
268 let object = value.as_object().expect("file info object");
269
270 assert!(!object.contains_key("date"));
271 assert!(!object.contains_key("sha1"));
272 assert!(!object.contains_key("md5"));
273 assert!(!object.contains_key("sha256"));
274 assert!(!object.contains_key("sha1_git"));
275 assert!(!object.contains_key("mime_type"));
276 assert!(!object.contains_key("file_type"));
277 assert!(!object.contains_key("programming_language"));
278 assert!(!object.contains_key("is_binary"));
279 assert!(!object.contains_key("is_text"));
280 assert!(!object.contains_key("is_archive"));
281 assert!(!object.contains_key("is_media"));
282 assert!(!object.contains_key("is_source"));
283 assert!(!object.contains_key("is_script"));
284 assert!(!object.contains_key("files_count"));
285 assert!(!object.contains_key("dirs_count"));
286 assert!(!object.contains_key("size_count"));
287 assert!(!object.contains_key("license_policy"));
288 }
289
290 #[test]
291 fn test_file_info_serialization_keeps_license_policy_when_enabled() {
292 let mut file = FileInfo::new(
293 "main.rs".to_string(),
294 "main".to_string(),
295 "rs".to_string(),
296 "src/main.rs".to_string(),
297 FileType::File,
298 Some("text/plain".to_string()),
299 Some("text".to_string()),
300 42,
301 Some("2026-01-01T00:00:00Z".to_string()),
302 Some(EMPTY_SHA1.to_string()),
303 Some("d41d8cd98f00b204e9800998ecf8427e".to_string()),
304 Some("e3b0c44298fc1c149afbf4c8996fb924".to_string()),
305 Some("Rust".to_string()),
306 vec![],
307 None,
308 vec![],
309 vec![],
310 vec![],
311 vec![],
312 vec![],
313 vec![],
314 vec![],
315 vec![],
316 vec![],
317 );
318 file.license_policy = Some(vec![]);
319 file.sha1_git = Some(EMPTY_SHA1.to_string());
320 file.is_binary = Some(false);
321 file.is_text = Some(true);
322 file.is_archive = Some(false);
323 file.is_media = Some(false);
324 file.is_source = Some(true);
325 file.is_script = Some(false);
326 file.files_count = Some(0);
327 file.dirs_count = Some(0);
328 file.size_count = Some(0);
329
330 let value = serde_json::to_value(&file).expect("file info serializes");
331 let object = value.as_object().expect("file info object");
332
333 assert_eq!(object.get("license_policy"), Some(&serde_json::json!([])));
334 assert_eq!(object.get("file_type"), Some(&serde_json::json!("text")));
335 assert_eq!(object.get("is_binary"), Some(&serde_json::json!(false)));
336 assert_eq!(object.get("is_text"), Some(&serde_json::json!(true)));
337 assert_eq!(object.get("files_count"), Some(&serde_json::json!(0)));
338 assert_eq!(object.get("dirs_count"), Some(&serde_json::json!(0)));
339 assert_eq!(object.get("size_count"), Some(&serde_json::json!(0)));
340 }
341
342 #[test]
343 fn test_json_lines_writer_sorts_files_by_path_for_reproducibility() {
344 let mut output = sample_output();
345 output.files.reverse();
346 let mut bytes = Vec::new();
347 writer_for_format(OutputFormat::JsonLines)
348 .write(&output, &mut bytes, &OutputWriteConfig::default())
349 .expect("json-lines write should succeed");
350
351 let rendered = String::from_utf8(bytes).expect("json-lines should be utf-8");
352 let file_lines = rendered
353 .lines()
354 .filter_map(|line| {
355 let value: Value = serde_json::from_str(line).ok()?;
356 let files = value.get("files")?.as_array()?;
357 files.first()?.get("path")?.as_str().map(str::to_string)
358 })
359 .collect::<Vec<_>>();
360
361 let mut sorted = file_lines.clone();
362 sorted.sort();
363 assert_eq!(file_lines, sorted);
364 }
365
366 #[test]
367 fn test_spdx_tag_value_writer_contains_required_fields() {
368 let output = sample_output();
369 let mut bytes = Vec::new();
370 writer_for_format(OutputFormat::SpdxTv)
371 .write(
372 &output,
373 &mut bytes,
374 &OutputWriteConfig {
375 format: OutputFormat::SpdxTv,
376 custom_template: None,
377 scanned_path: Some("scan".to_string()),
378 },
379 )
380 .expect("spdx tv write should succeed");
381
382 let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
383 assert!(rendered.contains("SPDXVersion: SPDX-2.2"));
384 assert!(rendered.contains("FileName: ./src/main.rs"));
385 }
386
387 #[test]
388 fn test_spdx_rdf_writer_outputs_xml() {
389 let output = sample_output();
390 let mut bytes = Vec::new();
391 writer_for_format(OutputFormat::SpdxRdf)
392 .write(
393 &output,
394 &mut bytes,
395 &OutputWriteConfig {
396 format: OutputFormat::SpdxRdf,
397 custom_template: None,
398 scanned_path: Some("scan".to_string()),
399 },
400 )
401 .expect("spdx rdf write should succeed");
402
403 let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
404 assert!(rendered.contains("<rdf:RDF"));
405 assert!(rendered.contains("<spdx:SpdxDocument"));
406 }
407
408 #[test]
409 fn test_spdx_writers_emit_real_file_and_package_license_info() {
410 let output = sample_output();
411
412 let mut tv_bytes = Vec::new();
413 writer_for_format(OutputFormat::SpdxTv)
414 .write(
415 &output,
416 &mut tv_bytes,
417 &OutputWriteConfig {
418 format: OutputFormat::SpdxTv,
419 custom_template: None,
420 scanned_path: Some("scan".to_string()),
421 },
422 )
423 .expect("spdx tv write should succeed");
424 let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
425 assert!(tv_rendered.contains("PackageLicenseConcluded: NOASSERTION"));
426 assert!(tv_rendered.contains("PackageLicenseInfoFromFiles: MIT"));
427 assert!(tv_rendered.contains("LicenseConcluded: NOASSERTION"));
428 assert!(tv_rendered.contains("LicenseInfoInFile: MIT"));
429 assert!(tv_rendered.contains("PackageCopyrightText: Copyright (c) Example"));
430
431 let mut rdf_bytes = Vec::new();
432 writer_for_format(OutputFormat::SpdxRdf)
433 .write(
434 &output,
435 &mut rdf_bytes,
436 &OutputWriteConfig {
437 format: OutputFormat::SpdxRdf,
438 custom_template: None,
439 scanned_path: Some("scan".to_string()),
440 },
441 )
442 .expect("spdx rdf write should succeed");
443 let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
444 assert!(rdf_rendered.contains(
445 "<spdx:licenseInfoFromFiles rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
446 ));
447 assert!(
448 rdf_rendered.contains(
449 "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
450 )
451 );
452 assert!(rdf_rendered.contains(
453 "<spdx:licenseConcluded rdf:resource=\"http://spdx.org/rdf/terms#noassertion\"/>"
454 ));
455 }
456
457 #[test]
458 fn test_spdx_writers_emit_license_ref_metadata_and_matched_text() {
459 let mut output = sample_output();
460 output.files[0].license_detections = vec![LicenseDetection {
461 license_expression: "unknown-license-reference".to_string(),
462 license_expression_spdx: "LicenseRef-scancode-unknown-license-reference".to_string(),
463 matches: vec![Match {
464 license_expression: "unknown-license-reference".to_string(),
465 license_expression_spdx: "LicenseRef-scancode-unknown-license-reference"
466 .to_string(),
467 from_file: Some("src/main.rs".to_string()),
468 start_line: 1,
469 end_line: 2,
470 matcher: Some("2-aho".to_string()),
471 score: 100.0,
472 matched_length: Some(4),
473 match_coverage: Some(100.0),
474 rule_relevance: Some(100),
475 rule_identifier: Some("unknown-license-reference.RULE".to_string()),
476 rule_url: Some("https://example.com/unknown-license-reference.LICENSE".to_string()),
477 matched_text: Some("Custom license text".to_string()),
478 referenced_filenames: Some(vec!["LICENSE".to_string()]),
479 matched_text_diagnostics: None,
480 }],
481 detection_log: vec![],
482 identifier: Some("unknown-ref-id".to_string()),
483 }];
484 output.license_references = vec![crate::models::LicenseReference {
485 key: Some("unknown-license-reference".to_string()),
486 language: Some("en".to_string()),
487 name: "Unknown License Reference".to_string(),
488 short_name: "Unknown License Reference".to_string(),
489 owner: None,
490 homepage_url: None,
491 spdx_license_key: "LicenseRef-scancode-unknown-license-reference".to_string(),
492 other_spdx_license_keys: vec![],
493 osi_license_key: None,
494 text_urls: vec![],
495 osi_url: None,
496 faq_url: None,
497 other_urls: vec![],
498 category: None,
499 is_exception: false,
500 is_unknown: true,
501 is_generic: false,
502 notes: None,
503 minimum_coverage: None,
504 standard_notice: None,
505 ignorable_copyrights: vec![],
506 ignorable_holders: vec![],
507 ignorable_authors: vec![],
508 ignorable_urls: vec![],
509 ignorable_emails: vec![],
510 scancode_url: None,
511 licensedb_url: None,
512 spdx_url: None,
513 text: "Unused fallback text".to_string(),
514 }];
515
516 let mut tv_bytes = Vec::new();
517 writer_for_format(OutputFormat::SpdxTv)
518 .write(
519 &output,
520 &mut tv_bytes,
521 &OutputWriteConfig {
522 format: OutputFormat::SpdxTv,
523 custom_template: None,
524 scanned_path: Some("scan".to_string()),
525 },
526 )
527 .expect("spdx tv write should succeed");
528 let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
529 assert!(
530 tv_rendered
531 .contains("LicenseInfoInFile: LicenseRef-scancode-unknown-license-reference")
532 );
533 assert!(tv_rendered.contains(
534 "PackageLicenseInfoFromFiles: LicenseRef-scancode-unknown-license-reference"
535 ));
536 assert!(tv_rendered.contains("LicenseID: LicenseRef-scancode-unknown-license-reference"));
537 assert!(tv_rendered.contains("ExtractedText: <text>Custom license text"));
538 assert!(tv_rendered.contains("LicenseName: Unknown License Reference"));
539 assert!(tv_rendered.contains(
540 "LicenseComment: <text>See details at https://example.com/unknown-license-reference.LICENSE"
541 ));
542
543 let mut rdf_bytes = Vec::new();
544 writer_for_format(OutputFormat::SpdxRdf)
545 .write(
546 &output,
547 &mut rdf_bytes,
548 &OutputWriteConfig {
549 format: OutputFormat::SpdxRdf,
550 custom_template: None,
551 scanned_path: Some("scan".to_string()),
552 },
553 )
554 .expect("spdx rdf write should succeed");
555 let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
556 assert!(rdf_rendered.contains(
557 "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/licenses/LicenseRef-scancode-unknown-license-reference\"/>"
558 ));
559 assert!(rdf_rendered.contains(
560 "<spdx:hasExtractedLicensingInfo><spdx:ExtractedLicensingInfo rdf:about=\"#LicenseRef-scancode-unknown-license-reference\">"
561 ));
562 assert!(
563 rdf_rendered.contains("<spdx:extractedText>Custom license text</spdx:extractedText>")
564 );
565 }
566
567 #[test]
568 fn test_cyclonedx_json_writer_outputs_bom() {
569 let output = sample_output();
570 let mut bytes = Vec::new();
571 writer_for_format(OutputFormat::CycloneDxJson)
572 .write(&output, &mut bytes, &OutputWriteConfig::default())
573 .expect("cyclonedx json write should succeed");
574
575 let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
576 let value: Value = serde_json::from_str(&rendered).expect("valid json");
577 assert_eq!(value["bomFormat"], "CycloneDX");
578 assert_eq!(value["specVersion"], "1.3");
579 }
580
581 #[test]
582 fn test_json_writer_includes_summary_and_key_file_flags() {
583 let mut output = sample_output();
584 output.summary = Some(crate::models::Summary {
585 declared_license_expression: Some("apache-2.0".to_string()),
586 license_clarity_score: Some(crate::models::LicenseClarityScore {
587 score: 100,
588 declared_license: true,
589 identification_precision: true,
590 has_license_text: true,
591 declared_copyrights: true,
592 conflicting_license_categories: false,
593 ambiguous_compound_licensing: false,
594 }),
595 declared_holder: Some("Example Corp.".to_string()),
596 primary_language: Some("Ruby".to_string()),
597 other_license_expressions: vec![crate::models::TallyEntry {
598 value: Some("mit".to_string()),
599 count: 1,
600 }],
601 other_holders: vec![
602 crate::models::TallyEntry {
603 value: None,
604 count: 2,
605 },
606 crate::models::TallyEntry {
607 value: Some("Other Corp.".to_string()),
608 count: 1,
609 },
610 ],
611 other_languages: vec![crate::models::TallyEntry {
612 value: Some("Python".to_string()),
613 count: 2,
614 }],
615 });
616 output.files[0].is_legal = true;
617 output.files[0].is_top_level = true;
618 output.files[0].is_key_file = true;
619
620 let mut bytes = Vec::new();
621 writer_for_format(OutputFormat::Json)
622 .write(&output, &mut bytes, &OutputWriteConfig::default())
623 .expect("json write should succeed");
624
625 let rendered = String::from_utf8(bytes).expect("json should be utf-8");
626 let value: Value = serde_json::from_str(&rendered).expect("valid json");
627
628 assert_eq!(
629 value["summary"]["declared_license_expression"],
630 "apache-2.0"
631 );
632 assert_eq!(value["summary"]["license_clarity_score"]["score"], 100);
633 assert_eq!(value["summary"]["declared_holder"], "Example Corp.");
634 assert_eq!(value["summary"]["primary_language"], "Ruby");
635 assert_eq!(
636 value["summary"]["other_license_expressions"][0]["value"],
637 "mit"
638 );
639 assert!(value["summary"]["other_holders"][0]["value"].is_null());
640 assert_eq!(value["summary"]["other_holders"][1]["value"], "Other Corp.");
641 assert_eq!(value["summary"]["other_languages"][0]["value"], "Python");
642 assert_eq!(value["files"][0]["is_key_file"], true);
643 }
644
645 #[test]
646 fn test_json_and_json_lines_writers_include_top_level_tallies() {
647 let mut output = sample_output();
648 output.tallies = Some(crate::models::Tallies {
649 detected_license_expression: vec![crate::models::TallyEntry {
650 value: Some("mit".to_string()),
651 count: 2,
652 }],
653 copyrights: vec![crate::models::TallyEntry {
654 value: Some("Copyright (c) Example Org".to_string()),
655 count: 1,
656 }],
657 holders: vec![crate::models::TallyEntry {
658 value: Some("Example Org".to_string()),
659 count: 1,
660 }],
661 authors: vec![crate::models::TallyEntry {
662 value: Some("Jane Doe".to_string()),
663 count: 1,
664 }],
665 programming_language: vec![crate::models::TallyEntry {
666 value: Some("Rust".to_string()),
667 count: 1,
668 }],
669 });
670
671 let mut json_bytes = Vec::new();
672 writer_for_format(OutputFormat::Json)
673 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
674 .expect("json write should succeed");
675 let json_value: Value =
676 serde_json::from_slice(&json_bytes).expect("json output should parse");
677 assert_eq!(
678 json_value["tallies"]["detected_license_expression"][0]["value"],
679 "mit"
680 );
681 assert_eq!(
682 json_value["tallies"]["programming_language"][0]["value"],
683 "Rust"
684 );
685
686 let mut jsonl_bytes = Vec::new();
687 writer_for_format(OutputFormat::JsonLines)
688 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
689 .expect("json-lines write should succeed");
690 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
691 assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
692 }
693
694 #[test]
695 fn test_json_and_json_lines_writers_include_key_file_tallies() {
696 let mut output = sample_output();
697 output.tallies_of_key_files = Some(crate::models::Tallies {
698 detected_license_expression: vec![crate::models::TallyEntry {
699 value: Some("apache-2.0".to_string()),
700 count: 1,
701 }],
702 copyrights: vec![],
703 holders: vec![],
704 authors: vec![],
705 programming_language: vec![crate::models::TallyEntry {
706 value: Some("Markdown".to_string()),
707 count: 1,
708 }],
709 });
710
711 let mut json_bytes = Vec::new();
712 writer_for_format(OutputFormat::Json)
713 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
714 .expect("json write should succeed");
715 let json_value: Value =
716 serde_json::from_slice(&json_bytes).expect("json output should parse");
717 assert_eq!(
718 json_value["tallies_of_key_files"]["detected_license_expression"][0]["value"],
719 "apache-2.0"
720 );
721
722 let mut jsonl_bytes = Vec::new();
723 writer_for_format(OutputFormat::JsonLines)
724 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
725 .expect("json-lines write should succeed");
726 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
727 assert!(
728 rendered
729 .lines()
730 .any(|line| line.contains("\"tallies_of_key_files\""))
731 );
732 }
733
734 #[test]
735 fn test_json_and_json_lines_writers_include_file_tallies() {
736 let mut output = sample_output();
737 output.files[0].tallies = Some(crate::models::Tallies {
738 detected_license_expression: vec![crate::models::TallyEntry {
739 value: Some("mit".to_string()),
740 count: 1,
741 }],
742 copyrights: vec![crate::models::TallyEntry {
743 value: None,
744 count: 1,
745 }],
746 holders: vec![],
747 authors: vec![],
748 programming_language: vec![crate::models::TallyEntry {
749 value: Some("Rust".to_string()),
750 count: 1,
751 }],
752 });
753
754 let mut json_bytes = Vec::new();
755 writer_for_format(OutputFormat::Json)
756 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
757 .expect("json write should succeed");
758 let json_value: Value =
759 serde_json::from_slice(&json_bytes).expect("json output should parse");
760 assert_eq!(
761 json_value["files"][0]["tallies"]["detected_license_expression"][0]["value"],
762 "mit"
763 );
764
765 let mut jsonl_bytes = Vec::new();
766 writer_for_format(OutputFormat::JsonLines)
767 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
768 .expect("json-lines write should succeed");
769 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
770 assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
771 }
772
773 #[test]
774 fn test_json_and_json_lines_writers_include_facets_and_tallies_by_facet() {
775 let mut output = sample_output();
776 output.files[0].facets = vec!["core".to_string(), "docs".to_string()];
777 output.tallies_by_facet = Some(vec![crate::models::FacetTallies {
778 facet: "core".to_string(),
779 tallies: crate::models::Tallies {
780 detected_license_expression: vec![crate::models::TallyEntry {
781 value: Some("mit".to_string()),
782 count: 1,
783 }],
784 copyrights: vec![],
785 holders: vec![],
786 authors: vec![],
787 programming_language: vec![],
788 },
789 }]);
790
791 let mut json_bytes = Vec::new();
792 writer_for_format(OutputFormat::Json)
793 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
794 .expect("json write should succeed");
795 let json_value: Value =
796 serde_json::from_slice(&json_bytes).expect("json output should parse");
797 assert_eq!(json_value["files"][0]["facets"][0], "core");
798 assert_eq!(json_value["tallies_by_facet"][0]["facet"], "core");
799
800 let mut jsonl_bytes = Vec::new();
801 writer_for_format(OutputFormat::JsonLines)
802 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
803 .expect("json-lines write should succeed");
804 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
805 assert!(
806 rendered
807 .lines()
808 .any(|line| line.contains("\"tallies_by_facet\""))
809 );
810 }
811
812 #[test]
813 fn test_json_and_json_lines_writers_include_top_level_license_references() {
814 let mut output = sample_output();
815 output.license_references = vec![crate::models::LicenseReference {
816 key: Some("mit".to_string()),
817 language: Some("en".to_string()),
818 name: "MIT License".to_string(),
819 short_name: "MIT".to_string(),
820 owner: Some("Example Owner".to_string()),
821 homepage_url: Some("https://example.com/license".to_string()),
822 spdx_license_key: "MIT".to_string(),
823 other_spdx_license_keys: vec![],
824 osi_license_key: Some("MIT".to_string()),
825 text_urls: vec!["https://example.com/license.txt".to_string()],
826 osi_url: Some("https://opensource.org/licenses/MIT".to_string()),
827 faq_url: None,
828 other_urls: vec![],
829 category: None,
830 is_exception: false,
831 is_unknown: false,
832 is_generic: false,
833 notes: None,
834 minimum_coverage: None,
835 standard_notice: None,
836 ignorable_copyrights: vec![],
837 ignorable_holders: vec![],
838 ignorable_authors: vec![],
839 ignorable_urls: vec![],
840 ignorable_emails: vec![],
841 scancode_url: None,
842 licensedb_url: None,
843 spdx_url: None,
844 text: "MIT text".to_string(),
845 }];
846 output.license_rule_references = vec![crate::models::LicenseRuleReference {
847 identifier: "license-clue_1.RULE".to_string(),
848 license_expression: "unknown-license-reference".to_string(),
849 is_license_text: false,
850 is_license_notice: false,
851 is_license_reference: false,
852 is_license_tag: false,
853 is_license_clue: true,
854 is_license_intro: false,
855 language: None,
856 rule_url: None,
857 is_required_phrase: false,
858 skip_for_required_phrase_generation: false,
859 replaced_by: vec![],
860 is_continuous: false,
861 is_synthetic: false,
862 is_from_license: false,
863 length: 0,
864 relevance: None,
865 minimum_coverage: None,
866 referenced_filenames: vec![],
867 notes: None,
868 ignorable_copyrights: vec![],
869 ignorable_holders: vec![],
870 ignorable_authors: vec![],
871 ignorable_urls: vec![],
872 ignorable_emails: vec![],
873 text: None,
874 }];
875
876 let mut json_bytes = Vec::new();
877 writer_for_format(OutputFormat::Json)
878 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
879 .expect("json write should succeed");
880 let json_value: Value =
881 serde_json::from_slice(&json_bytes).expect("json output should parse");
882 assert_eq!(
883 json_value["license_references"][0]["spdx_license_key"],
884 "MIT"
885 );
886 assert_eq!(json_value["license_references"][0]["key"], "mit");
887 assert_eq!(json_value["license_references"][0]["language"], "en");
888 assert_eq!(
889 json_value["license_references"][0]["owner"],
890 "Example Owner"
891 );
892 assert_eq!(
893 json_value["license_references"][0]["homepage_url"],
894 "https://example.com/license"
895 );
896 assert_eq!(
897 json_value["license_references"][0]["osi_license_key"],
898 "MIT"
899 );
900 assert_eq!(
901 json_value["license_references"][0]["text_urls"][0],
902 "https://example.com/license.txt"
903 );
904 assert_eq!(
905 json_value["license_rule_references"][0]["identifier"],
906 "license-clue_1.RULE"
907 );
908 assert_eq!(
909 json_value["license_rule_references"][0]["relevance"],
910 Value::Null
911 );
912 assert_eq!(
913 json_value["license_rule_references"][0]["length"],
914 Value::from(0)
915 );
916
917 let mut jsonl_bytes = Vec::new();
918 writer_for_format(OutputFormat::JsonLines)
919 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
920 .expect("json-lines write should succeed");
921 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
922 assert!(
923 rendered
924 .lines()
925 .any(|line| line.contains("\"license_references\""))
926 );
927 assert!(
928 rendered
929 .lines()
930 .any(|line| line.contains("\"license_rule_references\""))
931 );
932 }
933
934 #[test]
935 fn test_json_and_json_lines_writers_include_top_level_license_detections() {
936 let mut output = sample_output();
937 output.license_detections = vec![crate::models::TopLevelLicenseDetection {
938 identifier: "mit-id".to_string(),
939 license_expression: "mit".to_string(),
940 license_expression_spdx: "MIT".to_string(),
941 detection_count: 2,
942 detection_log: vec![],
943 reference_matches: vec![crate::models::Match {
944 license_expression: "mit".to_string(),
945 license_expression_spdx: "MIT".to_string(),
946 from_file: Some("src/main.rs".to_string()),
947 start_line: 1,
948 end_line: 3,
949 matcher: Some("1-hash".to_string()),
950 score: 100.0,
951 matched_length: Some(10),
952 match_coverage: Some(100.0),
953 rule_relevance: Some(100),
954 rule_identifier: Some("mit.LICENSE".to_string()),
955 rule_url: None,
956 matched_text: None,
957 referenced_filenames: None,
958 matched_text_diagnostics: None,
959 }],
960 }];
961
962 let mut json_bytes = Vec::new();
963 writer_for_format(OutputFormat::Json)
964 .write(&output, &mut json_bytes, &OutputWriteConfig::default())
965 .expect("json write should succeed");
966 let json_value: Value =
967 serde_json::from_slice(&json_bytes).expect("json output should parse");
968 assert_eq!(json_value["license_detections"][0]["identifier"], "mit-id");
969 assert_eq!(json_value["license_detections"][0]["detection_count"], 2);
970
971 let mut jsonl_bytes = Vec::new();
972 writer_for_format(OutputFormat::JsonLines)
973 .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
974 .expect("json-lines write should succeed");
975 let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
976 assert!(
977 rendered
978 .lines()
979 .any(|line| line.contains("\"license_detections\""))
980 );
981 }
982
983 #[test]
984 fn test_cyclonedx_xml_writer_outputs_xml() {
985 let output = sample_output();
986 let mut bytes = Vec::new();
987 writer_for_format(OutputFormat::CycloneDxXml)
988 .write(&output, &mut bytes, &OutputWriteConfig::default())
989 .expect("cyclonedx xml write should succeed");
990
991 let rendered = String::from_utf8(bytes).expect("cyclonedx xml should be utf-8");
992 assert!(rendered.contains("<bom xmlns=\"http://cyclonedx.org/schema/bom/1.3\""));
993 assert!(rendered.contains("<components>"));
994 }
995
996 #[test]
997 fn test_cyclonedx_json_includes_component_license_expression() {
998 let mut output = sample_output();
999 output.packages = vec![crate::models::Package {
1000 package_type: Some(crate::models::PackageType::Maven),
1001 namespace: Some("example".to_string()),
1002 name: Some("gradle-project".to_string()),
1003 version: Some("1.0.0".to_string()),
1004 qualifiers: None,
1005 subpath: None,
1006 primary_language: Some("Java".to_string()),
1007 description: None,
1008 release_date: None,
1009 parties: vec![],
1010 keywords: vec![],
1011 homepage_url: None,
1012 download_url: None,
1013 size: None,
1014 sha1: None,
1015 md5: None,
1016 sha256: None,
1017 sha512: None,
1018 bug_tracking_url: None,
1019 code_view_url: None,
1020 vcs_url: None,
1021 copyright: None,
1022 holder: None,
1023 declared_license_expression: Some("Apache-2.0".to_string()),
1024 declared_license_expression_spdx: Some("Apache-2.0".to_string()),
1025 license_detections: vec![],
1026 other_license_expression: None,
1027 other_license_expression_spdx: None,
1028 other_license_detections: vec![],
1029 extracted_license_statement: Some("Apache-2.0".to_string()),
1030 notice_text: None,
1031 source_packages: vec![],
1032 is_private: false,
1033 is_virtual: false,
1034 extra_data: None,
1035 repository_homepage_url: None,
1036 repository_download_url: None,
1037 api_data_url: None,
1038 datasource_ids: vec![],
1039 purl: Some("pkg:maven/example/gradle-project@1.0.0".to_string()),
1040 package_uid: "pkg:maven/example/gradle-project@1.0.0?uuid=test".to_string(),
1041 datafile_paths: vec![],
1042 }];
1043
1044 let mut bytes = Vec::new();
1045 writer_for_format(OutputFormat::CycloneDxJson)
1046 .write(&output, &mut bytes, &OutputWriteConfig::default())
1047 .expect("cyclonedx json write should succeed");
1048
1049 let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
1050 let value: Value = serde_json::from_str(&rendered).expect("valid json");
1051
1052 assert_eq!(
1053 value["components"][0]["licenses"][0]["expression"],
1054 "Apache-2.0"
1055 );
1056 }
1057
1058 #[test]
1059 fn test_spdx_empty_scan_tag_value_matches_python_sentinel() {
1060 let output = Output {
1061 summary: None,
1062 tallies: None,
1063 tallies_of_key_files: None,
1064 tallies_by_facet: None,
1065 headers: vec![],
1066 packages: vec![],
1067 dependencies: vec![],
1068 license_detections: vec![],
1069 files: vec![],
1070 license_references: vec![],
1071 license_rule_references: vec![],
1072 };
1073 let mut bytes = Vec::new();
1074 writer_for_format(OutputFormat::SpdxTv)
1075 .write(
1076 &output,
1077 &mut bytes,
1078 &OutputWriteConfig {
1079 format: OutputFormat::SpdxTv,
1080 custom_template: None,
1081 scanned_path: Some("scan".to_string()),
1082 },
1083 )
1084 .expect("spdx tv write should succeed");
1085
1086 let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
1087 assert_eq!(rendered, "# No results for package 'scan'.\n");
1088 }
1089
1090 #[test]
1091 fn test_spdx_empty_scan_rdf_matches_python_sentinel() {
1092 let output = Output {
1093 summary: None,
1094 tallies: None,
1095 tallies_of_key_files: None,
1096 tallies_by_facet: None,
1097 headers: vec![],
1098 packages: vec![],
1099 dependencies: vec![],
1100 license_detections: vec![],
1101 files: vec![],
1102 license_references: vec![],
1103 license_rule_references: vec![],
1104 };
1105 let mut bytes = Vec::new();
1106 writer_for_format(OutputFormat::SpdxRdf)
1107 .write(
1108 &output,
1109 &mut bytes,
1110 &OutputWriteConfig {
1111 format: OutputFormat::SpdxRdf,
1112 custom_template: None,
1113 scanned_path: Some("scan".to_string()),
1114 },
1115 )
1116 .expect("spdx rdf write should succeed");
1117
1118 let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
1119 assert_eq!(rendered, "<!-- No results for package 'scan'. -->\n");
1120 }
1121
1122 #[test]
1123 fn test_html_writer_outputs_html_document() {
1124 let output = sample_output();
1125 let mut bytes = Vec::new();
1126 writer_for_format(OutputFormat::Html)
1127 .write(&output, &mut bytes, &OutputWriteConfig::default())
1128 .expect("html write should succeed");
1129 let rendered = String::from_utf8(bytes).expect("html should be utf-8");
1130 assert!(rendered.contains("<!doctype html>"));
1131 assert!(rendered.contains("Custom Template"));
1132 }
1133
1134 #[test]
1135 fn test_custom_template_writer_renders_output_context() {
1136 let output = sample_output();
1137 let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1138 let template_path = temp_dir.path().join("template.tera");
1139 fs::write(
1140 &template_path,
1141 "version={{ output.headers[0].output_format_version }} files={{ files | length }}",
1142 )
1143 .expect("template should be written");
1144
1145 let mut bytes = Vec::new();
1146 writer_for_format(OutputFormat::CustomTemplate)
1147 .write(
1148 &output,
1149 &mut bytes,
1150 &OutputWriteConfig {
1151 format: OutputFormat::CustomTemplate,
1152 custom_template: Some(template_path.to_string_lossy().to_string()),
1153 scanned_path: None,
1154 },
1155 )
1156 .expect("custom template write should succeed");
1157
1158 let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1159 assert!(rendered.contains("version=4.0.0"));
1160 assert!(rendered.contains("files=1"));
1161 }
1162
1163 fn sample_output() -> Output {
1164 Output {
1165 summary: None,
1166 tallies: None,
1167 tallies_of_key_files: None,
1168 tallies_by_facet: None,
1169 headers: vec![Header {
1170 start_timestamp: "2026-01-01T00:00:00Z".to_string(),
1171 end_timestamp: "2026-01-01T00:00:01Z".to_string(),
1172 duration: 1.0,
1173 extra_data: ExtraData {
1174 files_count: 1,
1175 directories_count: 1,
1176 excluded_count: 0,
1177 system_environment: SystemEnvironment {
1178 operating_system: Some("darwin".to_string()),
1179 cpu_architecture: "aarch64".to_string(),
1180 platform: "darwin".to_string(),
1181 rust_version: "1.93.0".to_string(),
1182 },
1183 },
1184 errors: vec![],
1185 output_format_version: "4.0.0".to_string(),
1186 }],
1187 packages: vec![],
1188 dependencies: vec![],
1189 license_detections: vec![],
1190 files: vec![FileInfo::new(
1191 "main.rs".to_string(),
1192 "main".to_string(),
1193 "rs".to_string(),
1194 "src/main.rs".to_string(),
1195 FileType::File,
1196 Some("text/plain".to_string()),
1197 None,
1198 42,
1199 None,
1200 Some(EMPTY_SHA1.to_string()),
1201 Some("d41d8cd98f00b204e9800998ecf8427e".to_string()),
1202 Some("e3b0c44298fc1c149afbf4c8996fb924".to_string()),
1203 Some("Rust".to_string()),
1204 vec![PackageData::default()],
1205 None,
1206 vec![LicenseDetection {
1207 license_expression: "mit".to_string(),
1208 license_expression_spdx: "MIT".to_string(),
1209 matches: vec![Match {
1210 license_expression: "mit".to_string(),
1211 license_expression_spdx: "MIT".to_string(),
1212 from_file: None,
1213 start_line: 1,
1214 end_line: 1,
1215 matcher: None,
1216 score: 100.0,
1217 matched_length: None,
1218 match_coverage: None,
1219 rule_relevance: None,
1220 rule_identifier: Some("mit_rule".to_string()),
1221 rule_url: None,
1222 matched_text: None,
1223 referenced_filenames: None,
1224 matched_text_diagnostics: None,
1225 }],
1226 detection_log: vec![],
1227 identifier: None,
1228 }],
1229 vec![],
1230 vec![Copyright {
1231 copyright: "Copyright (c) Example".to_string(),
1232 start_line: 1,
1233 end_line: 1,
1234 }],
1235 vec![Holder {
1236 holder: "Example Org".to_string(),
1237 start_line: 1,
1238 end_line: 1,
1239 }],
1240 vec![Author {
1241 author: "Jane Doe".to_string(),
1242 start_line: 1,
1243 end_line: 1,
1244 }],
1245 vec![OutputEmail {
1246 email: "jane@example.com".to_string(),
1247 start_line: 1,
1248 end_line: 1,
1249 }],
1250 vec![OutputURL {
1251 url: "https://example.com".to_string(),
1252 start_line: 1,
1253 end_line: 1,
1254 }],
1255 vec![],
1256 vec![],
1257 )],
1258 license_references: vec![],
1259 license_rule_references: vec![],
1260 }
1261 }
1262}