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